├── .bumpversion.cfg ├── .flake8 ├── .github └── workflows │ ├── codequality.yaml │ ├── installation.yml │ ├── release.yaml │ └── unittest.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── LICENSE ├── Makefile ├── README.md ├── docs ├── api.md ├── build.sh ├── environment.yml ├── examples.md ├── getting-started.md ├── index.md ├── installing.md ├── jupyter-lite.json ├── jupyterlite_config.json ├── libraries.md ├── overrides.json ├── testing.md ├── understanding.md └── xeus-python-environment.yml ├── make.bat ├── mkdocs.yml ├── mypy.ini ├── notebooks ├── calculator.ipynb ├── click-button.ipynb ├── markdown.ipynb └── todo-app.ipynb ├── pyproject.toml ├── react_ipywidgets ├── __init__.py └── core.py ├── reacton ├── __init__.py ├── _version.py ├── bqplot.py ├── core.py ├── core_test.py ├── deprecated │ ├── __init__.py │ └── ipyvuetify.py ├── find.py ├── find_test.py ├── generate.py ├── generate_test.py ├── ipycanvas.py ├── ipyvue.py ├── ipyvuetify.py ├── ipywidgets.py ├── logging.py ├── patch.py ├── patch_display.py ├── py.typed ├── rx.py ├── test.vue ├── test_rx.py ├── utils.py ├── utils_test.py └── work.py ├── release.md └── release.sh /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.9.1 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:reacton/_version.py] 7 | 8 | [bumpversion:file:release.md] 9 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 160 3 | per-file-ignores = 4 | # line too long (170 > 160 characters) 5 | ipywidgets.py: E501 6 | -------------------------------------------------------------------------------- /.github/workflows/codequality.yaml: -------------------------------------------------------------------------------- 1 | name: Code quality 2 | on: 3 | workflow_call: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | code-quality: 8 | runs-on: ubuntu-20.04 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13"] # pre-commit does not support Python < 3.8 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Install dependencies 21 | run: | 22 | pip install ".[dev]" 23 | - name: Install pre-commit 24 | run: pre-commit install 25 | - name: Run pre-commit 26 | run: pre-commit run --all-files 27 | -------------------------------------------------------------------------------- /.github/workflows/installation.yml: -------------------------------------------------------------------------------- 1 | name: Test installation 2 | 3 | on: 4 | workflow_call: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | test-installation: 9 | runs-on: ubuntu-22.04 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Python 3.7 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: 3.7 17 | - name: Install hatch 18 | run: pip install hatch 19 | - name: Build 20 | run: hatch build 21 | - name: Install 22 | run: pip install dist/*.whl 23 | - name: Test import 24 | run: python -c "import react_ipywidgets; import reacton" 25 | - uses: actions/upload-artifact@v4 26 | with: 27 | name: reacton-build-${{ github.run_number }} 28 | path: ./dist 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | test: 10 | uses: ./.github/workflows/unittest.yml 11 | 12 | release: 13 | needs: test 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Python 3.12 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: "3.12" 21 | - name: Install hatch 22 | run: pip install hatch 23 | - name: Build 24 | run: hatch build 25 | - name: Install 26 | run: pip install dist/*.whl 27 | - name: Test import 28 | run: python -c "import react_ipywidgets; import reacton;" 29 | - name: Publish a Python distribution to PyPI 30 | env: 31 | HATCH_INDEX_USER: __token__ 32 | HATCH_INDEX_AUTH: ${{ secrets.pypi_password }} 33 | run: | 34 | openssl sha256 dist/* 35 | hatch publish 36 | - uses: actions/upload-artifact@v4 37 | with: 38 | name: distributions 39 | path: ./dist 40 | -------------------------------------------------------------------------------- /.github/workflows/unittest.yml: -------------------------------------------------------------------------------- 1 | name: Unit testing 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | workflow_dispatch: 9 | workflow_call: 10 | 11 | jobs: 12 | build: 13 | uses: ./.github/workflows/installation.yml 14 | 15 | code-quality: 16 | uses: ./.github/workflows/codequality.yaml 17 | 18 | unit-test: 19 | needs: [build, code-quality] 20 | runs-on: ubuntu-20.04 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | python-version: [3.6, 3.7, 3.8, 3.9, "3.10", "3.11", "3.12", "3.13"] 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Set up Python ${{ matrix.python-version }} 29 | uses: actions/setup-python@v5 30 | with: 31 | python-version: ${{ matrix.python-version }} 32 | - uses: actions/download-artifact@v4 33 | with: 34 | name: reacton-build-${{ github.run_number }} 35 | path: ./dist 36 | - name: Install 37 | run: | 38 | pip install `echo dist/*.whl`[dev] 39 | - name: test 40 | run: pytest --cov=reacton reacton 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.o 2 | *.so 3 | *.mkv 4 | *.mk4 5 | #/*.png 6 | *.eps 7 | *.pgm 8 | *.pdf 9 | *.fld 10 | *.vtk 11 | *.mp4 12 | */**/*.pyc 13 | *.pyc 14 | jenkins/jenkins.war 15 | /nosetests.xml 16 | /.idea 17 | misc/experiments/ 18 | /.coverage 19 | /coverage.xml 20 | 21 | 22 | Untitled*.ipynb 23 | 24 | /experiments/* 25 | /htmlcov 26 | 27 | InterestingSubspacesNew.txt 28 | Makefile.in 29 | config.guess 30 | config.sub 31 | libtool 32 | ltmain.sh 33 | m4/libtool.m4 34 | m4/ltoptions.m4 35 | m4/ltversion.m4 36 | m4/lt~obsolete.m4 37 | aclocal.m4 38 | autom4te.cache 39 | config.h 40 | config.h.in 41 | config.log 42 | config.status 43 | configure 44 | depcomp 45 | install-sh 46 | missing 47 | src/.deps/ 48 | src/.libs 49 | src/Makefile 50 | src/Makefile.in 51 | src/libsubfind.a 52 | subspacefind*.tar.gz 53 | src/subspacefind?la* 54 | **/*.pyc 55 | data/* 56 | build/ 57 | doc 58 | docs/build 59 | meeting 60 | #misc 61 | /**/.DS_Store 62 | /**/.ipynb_checkpoints 63 | app 64 | dist 65 | python/.idea 66 | test/.idea 67 | venv 68 | .venv 69 | tmp 70 | python/vaex/version.py # gets generated 71 | **/*.egg-info/ 72 | .eggs/ 73 | bin/.idea 74 | *.npy 75 | **/__pycache__/* 76 | .vscode 77 | 78 | # output file from the docs 79 | build/ 80 | 81 | tmp 82 | examples 83 | site 84 | _site 85 | .mypy_cache 86 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: check-yaml 6 | args: [--unsafe] 7 | - id: end-of-file-fixer 8 | - id: trailing-whitespace 9 | - repo: https://github.com/astral-sh/ruff-pre-commit 10 | rev: v0.8.3 11 | hooks: 12 | - id: ruff 13 | stages: [pre-commit] 14 | - id: ruff-format 15 | stages: [pre-commit] 16 | - repo: https://github.com/pre-commit/mirrors-mypy 17 | rev: "v1.13.0" # Use the sha / tag you want to point at 18 | hooks: 19 | - id: mypy 20 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | conda: 3 | environment: docs/environment.yml 4 | mkdocs: 5 | configuration: mkdocs.yml 6 | fail_on_warning: false 7 | build: 8 | os: "ubuntu-20.04" 9 | tools: 10 | python: "mambaforge-4.10" 11 | jobs: 12 | pre_build: 13 | - ./docs/build.sh 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Maarten A. Breddels 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile components 16 | 17 | components: reacton/ipywidgets.py reacton/bqplot.py reacton/ipyvue.py reacton/ipyvuetify.py reacton/ipycanvas.py 18 | 19 | reacton/ipywidgets.py: reacton/generate.py 20 | python -m reacton.ipywidgets 21 | 22 | reacton/bqplot.py: reacton/generate.py 23 | python -m reacton.bqplot 24 | python -c "import reacton.bqplot" 25 | 26 | reacton/ipyvue.py: reacton/generate.py 27 | python -m reacton.ipyvue 28 | 29 | reacton/ipyvuetify.py: reacton/generate.py 30 | python -m reacton.ipyvuetify 31 | 32 | reacton/ipycanvas.py: reacton/generate.py 33 | python -m reacton.ipycanvas 34 | 35 | # Catch-all target: route all unknown targets to Sphinx using the new 36 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 37 | # %: Makefile 38 | # @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Documentation](https://readthedocs.org/projects/react-ipywidgets/badge/?version=latest)](https://reacton.solara.dev/) 2 | [![Jupyter Lab](https://jupyterlite.rtfd.io/en/latest/_static/badge.svg)](https://reacton.solara.dev/en/latest/_output/lab/index.html) 3 | [![Supported Python Versions](https://img.shields.io/pypi/pyversions/reacton)](https://pypi.org/project/reacton/) 4 | 5 | 6 | # Reacton: React for ipywidgets 7 | 8 | Write ipywidgets like Reacton. Creating a Web-based UI from Python, using ipywidgets made easier, fun, and without bugs. 9 | 10 | ![logo](https://user-images.githubusercontent.com/1765949/207259505-077acebd-1d74-4273-abf5-3a0226c03efd.png) 11 | 12 | 13 | ## What is it? 14 | 15 | A way to write reusable components in a React-like way, to make Python-based UI's using the ipywidgets ecosystem (ipywidgets, ipyvolume, bqplot, threejs, leaflet, ipyvuetify, ...). 16 | 17 | ## Why? What is the problem? 18 | 19 | Non-declarative UI's are complex: You have to attach and detach event handlers at the right point, there are many possibles states your UI can be in, and moving from one state to the other can be very hard to do manually and is very error-prone. 20 | 21 | Using Reacton, you write a component that gives a declarative description of the UI you want based on data. If the data changes, your component render function re-executes, and Reacton will find out how to go from the previous state to the new state. No more manual "diffing" on the UI, no more manual tracking of which event handlers to attach and detach. 22 | 23 | A common issue we also see is that there is one piece of code to set up the UI, and scattered around in many event handlers the changes that are almost repetitions of the initialization code. 24 | 25 | ## Why use a React-like solution 26 | 27 | Using a declarative way, in a React (JS) style, makes your codebase smaller, less error-prone, and easier to reason about. We don't see a good reason *not* to use it. 28 | 29 | Also, React has proven itself, and by adopting a proven technology, we can stand on the shoulders of giants, make use of a lot of existing resources, and do not have to reinvent the wheel. 30 | 31 | ## What does Reacton do for me? 32 | 33 | Instead of telling ipywidgets what to do, e.g.: 34 | 35 | * Responding to events. 36 | * Changing properties. 37 | * Attaching and detaching event handlers. 38 | * Adding and removing children. 39 | * Manage widget lifetimes (creating and destroying). 40 | 41 | You tell reacton what you want (which Widgets you want to have), and you let reacton take care of the above. 42 | 43 | ## Installing 44 | 45 | ```bash 46 | $ pip install reacton 47 | ``` 48 | ## Simple example 49 | 50 | ### Using plain ipywidgets 51 | 52 | Take, for example, this simple example of a button that counts the number of clicks 53 | ```python 54 | import ipywidgets as widgets 55 | 56 | clicks = 0 # issue 3 57 | def on_click(button): 58 | global clicks # issue 3 59 | clicks += 1 60 | button.description = f"Clicked {clicks} times" # issue 1 61 | button = widgets.Button(description="Clicked 0 times") # issue 1 62 | button.on_click(on_click) # issue 2 63 | display(button) 64 | ``` 65 | ![Button with counter - bad](https://user-images.githubusercontent.com/1765949/207260036-9aba0b23-1783-4fea-ade4-216c6aabc8c6.gif) 66 | 67 | We see the following issues: 68 | 69 | 1. The button description text is repeated in two places (initialization and the event handler). 70 | 2. An event handler is attached, but without a defined life cycle, it can be difficult to know when to detach it to avoid memory leaks. 71 | 3. The "clicks" variable is stored in the global scope, which may be concerning for some developers. 72 | 4. The code is not easily reusable or composable. 73 | 74 | These issues can be solved, but the burden is on you to come up with solutions. 75 | 76 | ### Using Reacton 77 | 78 | If we solve the same problem using reacton, we create (like ReactJS) a reusable component that describes the widgets we want, and it's up to `reacton` to show/update/modify the widget in an efficient way. 79 | 80 | Using `reacton.use_state`, we explicitly say we need a piece of local state, with an initial value of `0`. Using `on_click`, your event handler will be attached and detached when needed, and your function will be re-executed when the state changes (the click count). 81 | 82 | ```python 83 | import reacton 84 | import reacton.ipywidgets as w 85 | 86 | 87 | @reacton.component 88 | def ButtonClick(): 89 | # first render, this return 0, after that, the last argument 90 | # of set_clicks 91 | clicks, set_clicks = reacton.use_state(0) 92 | 93 | def my_click_handler(): 94 | # trigger a new render with a new value for clicks 95 | set_clicks(clicks+1) 96 | 97 | button = w.Button(description=f"Clicked {clicks} times", 98 | on_click=my_click_handler) 99 | return button 100 | 101 | ButtonClick() 102 | ``` 103 | ![Button with counter using Reacton](https://user-images.githubusercontent.com/1765949/207261815-c41c1dbf-6d8a-4741-863b-b84c43b657a6.gif) 104 | 105 | We now have a simple component that we can reuse, e.g., like this: 106 | ```python 107 | @reacton.component 108 | def ManyButtons(count=10): 109 | count, set_count = reacton.use_state(count) 110 | slider = w.IntSlider(min=0, max=20, value=count, on_value=set_count) 111 | buttons = [ButtonClick() for i in range(count)] 112 | return w.VBox(children=[slider, *buttons]) 113 | display(ManyButtons()) 114 | ``` 115 | ![Many buttons](https://user-images.githubusercontent.com/1765949/207262265-56052f1b-0cc3-42aa-8c35-bf10650c8514.gif) 116 | 117 | We take care of not re-creating new Buttons widgets (which is relatively expensive). We reuse existing widgets when we can and create new ones when needed. 118 | 119 | *Try creating the `ManyButtons` component without using pure ipywidgets, and you will really appreciate reacton* 120 | 121 | 122 | ## Markdown component example 123 | 124 | Given this [suggestion](https://github.com/jupyter-widgets/ipywidgets/issues/2428#issuecomment-500084610) on how to make a widget with markdown, we don't have an obvious path forward to create a new Markdown widget that can be reused. Should we inherit? From which class? Should we compose and inherit from VBox or HBox and add the HTML widget as a single child? 125 | 126 | With Reacton there is an obvious way: 127 | ```python 128 | import reacton 129 | import markdown 130 | import reacton.ipywidgets as w 131 | 132 | 133 | @reacton.component 134 | def Markdown(md: str): 135 | html = markdown.markdown(md) 136 | return w.HTML(value=html) 137 | 138 | display(Markdown("# Reacton rocks\nSeriously **bold** idea!")) 139 | ``` 140 | 141 | This `Markdown` component can now be reused to create a markdown editor: 142 | 143 | ```python 144 | @reacton.component 145 | def MarkdownEditor(md : str): 146 | md, set_md = reacton.use_state(md) 147 | edit, set_edit = reacton.use_state(True) 148 | with w.VBox() as main: 149 | Markdown(md) 150 | w.ToggleButton(description="Edit", 151 | value=edit, 152 | on_value=set_edit) 153 | if edit: 154 | w.Textarea(value=md, on_value=set_md, rows=10) 155 | return main 156 | display(MarkdownEditor("# Reacton rocks\nSeriously **bold** idea!")) 157 | ``` 158 | ![Markdown component and editor](https://user-images.githubusercontent.com/1765949/207259602-e671087f-67bf-41b3-81ee-27730c0693df.gif) 159 | 160 | The `MarkdownEditor` component also shows another feature we can provide: All container widgets (like HBox, VBox, and all ipyvuetify widgets) can act as a context manager, which will add all widgets elements created within it as its children. Using a context manager leads to better readable code (less parenthesis and parenthsis issues). 161 | 162 | # Documentation 163 | 164 | 165 | [![Documentation](https://readthedocs.org/projects/react-ipywidgets/badge/?version=latest)](https://reacton.solara.dev/) 166 | 167 | 168 | ## Examples 169 | 170 | API documentation is great, but like writing, you learn by reading. 171 | 172 | Our example notebooks can be found at: 173 | 174 | * [https://github.com/widgetti/reacton/tree/master/notebooks](https://github.com/widgetti/reacton/tree/master/notebooks) 175 | 176 | 177 | Or try them out directly in a Jupyter environment (JupyterLite) 178 | 179 | * [![Jupyter Lab](https://jupyterlite.rtfd.io/en/latest/_static/badge.svg)](https://reacton.solara.dev/en/latest/_output/lab/index.html) 180 | 181 | Direct link to examples: 182 | 183 | * [ButtonClick](./_output/lab/index.html?path=click-button.ipynb) 184 | ![Button with counter](https://user-images.githubusercontent.com/1765949/207259596-42cedcf1-d671-4e47-827d-fff9aaea55a6.gif) 185 | * [Calculator](./_output/lab/index.html?path=calculator.ipynb) 186 | ![Calculator](https://user-images.githubusercontent.com/1765949/207259600-aacc4d6f-edec-4f52-9f26-d730a6332db2.gif) 187 | * [Todo-app](./_output/lab/index.html?path=todo-app.ipynb) 188 | ![Todo app](https://user-images.githubusercontent.com/1765949/207259603-cc1222ec-d3db-4905-967d-b1401bb9697c.gif) 189 | * [Markdown](./_output/lab/index.html?path=markdown.ipynb) 190 | ![Markdown component and editor](https://user-images.githubusercontent.com/1765949/207259602-e671087f-67bf-41b3-81ee-27730c0693df.gif) 191 | 192 | 193 | # Installation 194 | ## User 195 | 196 | Most users: 197 | 198 | $ pip install reacton 199 | 200 | Conda users: 201 | 202 | $ conda install -c conda-forge install reacton 203 | 204 | 205 | ## Development 206 | 207 | To get an editable install, use the `-e` flag. 208 | 209 | $ pip install -e . 210 | 211 | 212 | ## Article 213 | 214 | Read [Advance your ipywidget app development with Reacton — A pure Python port of React for faster development](https://maartenbreddels.medium.com/advance-your-ipywidget-app-development-with-reacton-6734a5607d69) for a longer background and introduction to Reacton. 215 | 216 | 217 | ### Solara 218 | 219 | In this article, we also reveal our next framework, Solara, inspired on NextJS. If you are interested in a production ready framework, built on top of Reacton, for building larger data apps, you may consider signing up for our beta test. 220 | 221 | [![Solara](https://cdn-images-1.medium.com/max/1600/1*3_hAS8kKxMokCSyB_jfy9g.png)](https://interest.solara.dev/) 222 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | ## API 2 | 3 | 4 | ### Core 5 | 6 | #### component 7 | 8 | ```py 9 | FuncT = TypeVar("FuncT", bound=Callable[..., Element]) 10 | 11 | @overload 12 | def component(obj: FuncT) -> FuncT: 13 | ... 14 | ``` 15 | 16 | Decorator that turns a function into a Reacton component. Should return an element. Note that the type signature is formally incorrect (we do not return a FuncT but a reacton.core.ComponentFunction instance), but this 17 | gives good type hints in editors and good type checker support. 18 | 19 | Example 20 | ```py 21 | @reacton.component 22 | def ButtonClick(): 23 | clicks, set_clicks = reacton.use_state(0) 24 | def my_click_handler(): 25 | set_clicks(clicks+1) 26 | button = w.Button(description=f"Clicked {clicks} times", 27 | on_click=my_click_handler) 28 | return button 29 | ``` 30 | 31 | Note that calling the component will not execute the function directly, but will return an `Element` (not the Button element!) that can be passed to [render](#render). All argument used on the call to the component are bound to the element such that they can be passed onto the render function when needed. 32 | 33 | #### render 34 | 35 | 36 | ```py 37 | def render( 38 | element: Element[T], container: None = None, children_trait="children", handle_error: bool = True 39 | ) -> Tuple[widgets.Widget, _RenderContext]: 40 | ... 41 | ``` 42 | 43 | Execute the first render pass of the element to turn it into a set of widets. If no container is provided, an `ipywidgets.VBox` is created and returned. The second 44 | return value is a `_RenderContext` (which is currently considered a private API class). 45 | The resulting top level widget (associated to element being passed in) will be set as the first child of the container. 46 | Note that re-renders may change this first child (since a component can return a different element each render), hence the need for the container. 47 | 48 | Subsequent renders will be triggered by internal calls to the setter of [use_state](#use_state) or calls to [provide_context](#provide_context). 49 | 50 | If re-renders needs to be triggered due to an external data change, the following pattern is often used: 51 | 52 | ```py hl_lines="3 4 6 10" 53 | @reacton.component 54 | def SomeComponent(): 55 | counter, set_counter = reacton.use_state(0) 56 | def force_update(): 57 | # note the use of the lambda to avoid stale data 58 | set_counter(lambda counter: counter+1) 59 | 60 | def listen(): 61 | def on_change(): 62 | force_update() 63 | 64 | # listen to external data changes 65 | handle = external_service.subscribe(on_change) 66 | 67 | def cleanup(): 68 | external_service.unsubscribe(handle) 69 | return cleanup 70 | 71 | use_effect(listen, []) # will only subscribe and unsubscribe once 72 | 73 | # use external data 74 | value = external_service.value 75 | ... 76 | ``` 77 | 78 | 79 | 80 | 81 | ### Hooks 82 | 83 | It might be worth [reading the ReactJS documentation on Hooks](https://reactjs.org/docs/hooks-reference.html) as well: 84 | 85 | Rules of hooks (see also [ReactJS](https://reactjs.org/docs/hooks-rules.html)): 86 | 87 | * Do not use hooks conditionally 88 | * Do not use hooks in a loop 89 | 90 | Note that this is *per* component. 91 | The reason behind this is that hooks return values depend on the order of the hooks, i.e.the 2nd hook call should always be the second hook call. 92 | 93 | For every new component the 'hooks call counter' will be reset. 94 | 95 | For instance, instead of creating an element and calling a hook in a loop, create a new component that uses that hook, and create the element for that component in a loop instead. 96 | 97 | For instance, do not do this: 98 | 99 | ```py 100 | @reacton.component 101 | def Wrong(count:int = 0): 102 | with w.VBox(): 103 | for i in range(count): 104 | el = SomeThing() 105 | use_effect(...) # not a fixed amount of hooks calls! 106 | ``` 107 | 108 | Refactor it like this: 109 | 110 | ```py 111 | @reacton.component 112 | def Right(count:int = 0): 113 | with w.VBox(): 114 | for i in range(count): 115 | SomeComponent() 116 | 117 | 118 | @reacton.component 119 | def SomeComponent(count:int = 0): 120 | el = SomeThing() 121 | use_effect(...) # just 1 hook call per component 122 | return el 123 | 124 | ``` 125 | 126 | #### use_state 127 | 128 | ```py 129 | def use_state(initial: T, key: str = None, 130 | eq: Callable[[Any, Any], bool] = None 131 | ) -> Tuple[T, Callable[[Union[T, Callable[[T], T]]], None]]: 132 | ... 133 | ``` 134 | 135 | Returns a `(value, setter)` tuple that is used to manage state in a component. 136 | 137 | This function can only be called from a component function. 138 | 139 | The value returns the current state (which equals `initial` at the first render call). Or the value that was last set using the setter. 140 | 141 | Note that the setter function can be used in two ways. 142 | 143 | Directly setting the value: 144 | 145 | ```py hl_lines="5" 146 | @reacton.component 147 | def ButtonClick(): 148 | clicks, set_clicks = reacton.use_state(0) 149 | def my_click_handler(): 150 | set_clicks(clicks+1) 151 | button = w.Button(description=f"Clicked {clicks} times", 152 | on_click=my_click_handler) 153 | return button 154 | ``` 155 | 156 | Updating the value based on the previous/current value. 157 | 158 | ```py hl_lines="5" 159 | @reacton.component 160 | def ButtonClick(): 161 | clicks, set_clicks = reacton.use_state(0) 162 | def my_click_handler(): 163 | set_clicks(lambda prev: prev+1) 164 | button = w.Button(description=f"Clicked {clicks} times", 165 | on_click=my_click_handler) 166 | return button 167 | ``` 168 | 169 | The last one avoid issues with stale data, which means you have a reference to the value of an old render pass (not present in this simple example). 170 | 171 | 172 | 173 | #### use_effect 174 | 175 | ```python 176 | EffectCleanupCallable = Callable[[], None] 177 | EffectCallable = Callable[[], Optional[EffectCleanupCallable]] 178 | 179 | def use_effect(effect: EffectCallable, dependencies=None): 180 | ... 181 | ``` 182 | 183 | Executes non-declarative code in a callback, for instance to cause side effects like attaching event handlers. 184 | 185 | The `effect` callable will run after the component is turned into concrete widgets, which allows us to get a reference to the real underlying 186 | widgets using [get_widget](#get_widget). 187 | 188 | The return value of the callback can return a cleanup function which will be called when the component is removed, or before the effect 189 | is invoked again (when using dependencies). 190 | 191 | If no dependencies are given, the effect and its cleanup will be executed after each render. 192 | 193 | Example usage: 194 | 195 | ```py 196 | @reacton.component 197 | def SomeComponent(): 198 | def listen(): 199 | handle = external_service.subscribe(...) 200 | 201 | def cleanup(): 202 | external_service.unsubscribe(handle) 203 | return cleanup 204 | # will only subscribe and unsubscribe once 205 | use_effect(listen, []) 206 | # the following will 207 | # * subscribe once at first render 208 | # * unsubscribe/subcribe after each render 209 | # * unsubscribe once when the component gets removed 210 | # use_effect(listen) 211 | ... 212 | ``` 213 | 214 | #### use_memo 215 | 216 | ``` 217 | def use_memo(f: Callable, dependencies=None, debug_name: str = None): 218 | .... 219 | ``` 220 | 221 | [Memoize](https://en.wikipedia.org/wiki/Memoization) the last function return based on its dependencies. The function will only be executed the first time, or when its dependencies changes. 222 | If dependencies is `None` the dependencies are obtained automatically by inspecting the nonlocal variables. Pass an empty list (or any fixed value that supports comparison ) to only execute the function once for the lifetime of the component. 223 | 224 | Example relying on the automatic detection of dependencies: 225 | ```py 226 | @reacton.component 227 | def Test(x): 228 | def square(): 229 | # use_memo will automatically detect x as a dependency 230 | return x**2 231 | 232 | y = reacton.use_memo(square) 233 | return w.Label(value=f"{x} - {y}") 234 | ``` 235 | 236 | 237 | Sometimes, automatic detection on variables is not ideal when the dependencies are expensive to calculate, or do not even 238 | support comparison. In that case, we can manually define dependencies. 239 | 240 | ```py 241 | @reacton.component 242 | def Test(count): 243 | x = np.arange(count) 244 | def cumulative_sum(): 245 | # automatic detection would compare the numpy arrays 246 | # which could be slow 247 | return x.cumsum() 248 | 249 | # instead, we know that the underlying dependency is `count' 250 | y = reacton.use_memo(cumulative_sum, [count]) 251 | return w.Label(value=f"{value} - {y}") 252 | ``` 253 | 254 | 255 | #### use_context 256 | 257 | ```py 258 | def use_context(user_context: UserContext[T]) -> T: 259 | ... 260 | ``` 261 | 262 | Returns the current value for the context created with [create_context](#create_context). The current value is defined as the value provided by 263 | the nearest ancestor who provided the value using [provide_context](#provide_context) or the default value of [create_context](#create_context). 264 | 265 | Note that the type passed in [create_context](#create_context) defines the return type of `use_context` (meaning we have type safety). 266 | 267 | ```python 268 | # typed with int 269 | myvalue_context = reacton.create_context(1) 270 | some_other_value_context = reacton.create_context(33) 271 | 272 | 273 | @reacton.component 274 | def RootComponent(): 275 | myvalue_context.provide(42) 276 | # this is a different context, just to show you can have multiple 277 | # user contexts with the same type 278 | some_other_value_context.provide(333) 279 | return ChildComponent() 280 | 281 | 282 | @reacton.component 283 | def ChildComponent(): 284 | return SubChildComponent() 285 | 286 | 287 | @reacton.component 288 | def SubChildComponent(): 289 | return SubSubChildComponent() 290 | 291 | 292 | @reacton.component 293 | def SubSubChildComponent(): 294 | # many layers between the root component and this component 295 | # but we are able to pass it down without having to do this via 296 | # argument. value should be 42 due to `RootComponent` 297 | value = reacton.use_context(myvalue_context) 298 | return w.IntSlider(value=value) 299 | ``` 300 | 301 | 302 | #### use_reducer 303 | 304 | ```py 305 | T = TypeVar("T") 306 | U = TypeVar("U") 307 | 308 | def use_reducer(reduce: Callable[[T, U], T], initial_state: T) -> Tuple[T, Callable[[U], None]]: 309 | ... 310 | ``` 311 | 312 | See [The ReactJS documentation](https://reactjs.org/docs/hooks-reference.html#usereducer) 313 | 314 | #### use_ref 315 | 316 | ```py 317 | class Ref(Generic[T]): 318 | def __init__(self, initial_value: T): 319 | self.current = initial_value 320 | 321 | def use_ref(initial_value: T) -> Ref[T]: 322 | def make_ref(): 323 | return Ref(initial_value) 324 | 325 | ref = use_memo(make_ref, []) 326 | return ref 327 | ``` 328 | 329 | Returns a mutable proxy object that initially holds the initial value and can be mutated by anyone who has a reference to the proxy. 330 | 331 | The implementation is so trivial, that the full source code is added here. 332 | 333 | Note that assigning a new value will not trigger a rerender. 334 | 335 | 336 | #### provide_context 337 | 338 | ```py 339 | # this does not work well with mypy, UserContext[T] and obj:T 340 | # so for type hints it is better to use user_context.provide 341 | def provide_context(user_context: UserContext[T], obj: T): 342 | ... 343 | ``` 344 | 345 | Sets the value for the `user_context`. Any call to `use_context` after the call to `provide_context`, or in any child component will 346 | return the `obj` value. 347 | 348 | Note that because mypy does not give type warnings for `provide_context`, it may be more convenient to use `user_context.provide` (see [use_context](#use_context) for an example) 349 | 350 | ### Other 351 | #### create_context 352 | 353 | ```py 354 | def create_context(default_value: T, name: str = None) -> UserContext[T]: 355 | ... 356 | ``` 357 | 358 | Create a context object to be used with [use_context](#use_context) and [provide_context](#provide_context). 359 | This is used to 'transport' objects down a component hierarchy, without having to pass it down the arguments 360 | of each child component. This reduces the number of explicit dependencies/argument of your components. 361 | 362 | See [use_context](#use_context) for usuage. 363 | 364 | #### get_widget 365 | 366 | 367 | ```py 368 | def get_widget(el: Element): 369 | ... 370 | ``` 371 | 372 | 373 | Returns the real underlying widget, can only be used in use_effect. Note that if the same element it used twice in a component, the widget corresponding to the last element will be returned. 374 | -------------------------------------------------------------------------------- /docs/build.sh: -------------------------------------------------------------------------------- 1 | echo `pwd` 2 | ls -lR . 3 | cd docs 4 | jupyter lite build --config jupyterlite_config.json --contents ../notebooks 5 | ls -lR . 6 | -------------------------------------------------------------------------------- /docs/environment.yml: -------------------------------------------------------------------------------- 1 | name: reacton-docs 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - mkdocs 6 | - mkdocs-material 7 | - mamba 8 | - empack 9 | - pip: 10 | - jupyterlite-core==0.1.0b20 11 | - jupyterlite-sphinx>=0.8.0,<0.9.0 12 | - jupyterlite-xeus-python>=0.7.0,<0.8.0 13 | - jupyterlab-night 14 | - jupyterlab_google_analytics 15 | -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | 2 | ## Examples 3 | 4 | API documentation is great, but like writing, you learn by reading. 5 | 6 | Our example notebooks can be found at: 7 | 8 | * [https://github.com/widgetti/Reacton/tree/master/notebooks](https://github.com/widgetti/Reacton/tree/master/notebooks) 9 | 10 | 11 | Or try them out directly in a Jupyter environment (JupyterLite) 12 | 13 | * [![Jupyter Lab](https://jupyterlite.rtfd.io/en/latest/_static/badge.svg)](./_output/lab/index.html) 14 | 15 | Direct link to examples: 16 | 17 | * [ButtonClick](./_output/lab/index.html?path=click-button.ipynb) 18 | ![Button with counter](https://user-images.githubusercontent.com/1765949/207259596-42cedcf1-d671-4e47-827d-fff9aaea55a6.gif) 19 | * [Calculator](./_output/lab/index.html?path=calculator.ipynb) 20 | ![Calculator](https://user-images.githubusercontent.com/1765949/207259600-aacc4d6f-edec-4f52-9f26-d730a6332db2.gif) 21 | * [Todo-app](./_output/lab/index.html?path=todo-app.ipynb) 22 | ![Todo app](https://user-images.githubusercontent.com/1765949/207259603-cc1222ec-d3db-4905-967d-b1401bb9697c.gif) 23 | * [Markdown](./_output/lab/index.html?path=markdown.ipynb) 24 | ![Markdown component and editor](https://user-images.githubusercontent.com/1765949/207259602-e671087f-67bf-41b3-81ee-27730c0693df.gif) 25 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | 2 | ## Getting started 3 | 4 | Put this in the Jupyter notebook: 5 | 6 | ```py 7 | import reacton 8 | import reacton.ipywidgets as w 9 | 10 | @reacton.component 11 | def ButtonClick(): 12 | clicks, set_clicks = reacton.use_state(0) 13 | def my_click_handler(): 14 | set_clicks(clicks+1) 15 | button = w.Button(description=f"Clicked {clicks} times", 16 | on_click=my_click_handler) 17 | return button 18 | ``` 19 | 20 | Make the last expression of your cell: 21 | 22 | ```py 23 | ButtonClick() 24 | ``` 25 | 26 | Or explicitly display it using: 27 | 28 | ```py 29 | el = ButtonClick() 30 | display(el) 31 | ``` 32 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Write ipywidgets like React. Create a Web-based UI from Python, using ipywidgets made easier, fun, and without bugs. 4 | 5 | ![logo](https://user-images.githubusercontent.com/1765949/207259505-077acebd-1d74-4273-abf5-3a0226c03efd.png) 6 | 7 | ## What is Reacton 8 | 9 | A way to write reusable components in a React-like way, to make Python-based UI's using the ipywidgets ecosystem (ipywidgets, ipyvolume, bqplot, threejs, leaflet, ipyvuetify, ...). 10 | 11 | ## Why? What is the problem? 12 | 13 | Non-declarative UI's are complex: You have to attach and detach event handlers at the right point, there are many possibles states your UI can be in, and moving from one state to the other can be very hard to do manually and is very error-prone. 14 | 15 | Using Reacton, you write a component that gives a declarative description of the UI you want based on data. If the data changes, your component render function re-executes, and Reacton will find out how to go from the previous state to the new state. No more manual "diffing" on the UI, no more manual tracking of which event handlers to attach and detach. 16 | 17 | A common issue we also see is that there is one piece of code to set up the UI, and scattered around in many event handlers the changes that are almost repetitions of the initialization code. 18 | 19 | ## Why use a React-like solution 20 | 21 | Using a declarative way, in a React (JS) style, makes your codebase smaller, less error-prone, and easier to reason about. We don't see a good reason *not* to use it. 22 | 23 | Also, React has proven itself, and by adopting a proven technology, we can stand on the shoulders of giants, make use of a lot of existing resources, and do not have to reinvent the wheel. 24 | 25 | ## What does Reacton do for me? 26 | 27 | Instead of telling ipywidgets what to do, e.g.: 28 | 29 | * Responding to events. 30 | * Changing properties. 31 | * Attaching and detaching event handlers. 32 | * Adding and removing children. 33 | * Manage widget lifetimes (creating and destroying). 34 | 35 | You tell Reacton what you want (which Widgets you want to have), and you let Reacton take care of the above. 36 | -------------------------------------------------------------------------------- /docs/installing.md: -------------------------------------------------------------------------------- 1 | ## Installing 2 | 3 | ```bash 4 | $ pip install reacton 5 | ``` 6 | -------------------------------------------------------------------------------- /docs/jupyter-lite.json: -------------------------------------------------------------------------------- 1 | { 2 | "jupyter-config-data": { 3 | "appName": "Reacton Demo" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /docs/jupyterlite_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "XeusPythonEnv": { 3 | "environment_file": "xeus-python-environment.yml" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /docs/libraries.md: -------------------------------------------------------------------------------- 1 | # What libraries does Reacton support 2 | 3 | Reacton can work with any ipywidget library, such as [ipyleaflet](https://github.com/jupyter-widgets/ipyleaflet), [ipydatagrid](https://github.com/bloomberg/ipydatagrid) or [bqplot](https://github.com/bqplot/bqplot). 4 | 5 | When Reacton is imported, we add the `.element(...)` method to the base `ipywidget.Widget` base class. This allows us to create a Reacton element for all existing widgets. For example: 6 | 7 | ```python 8 | import ipywidgets 9 | button_element = ipywidgets.Button.element(description="Click me") 10 | ``` 11 | 12 | However, because we care about type safety, we generate wrapper components for some libraries. This enables type completion in VSCode, type checks with VSCode, and mypy. 13 | 14 | The following libraries are fully wrapped: 15 | 16 | * `ipywidgets` wrapper: `reacton.ipywidgets` 17 | * `ipyvuetify` wrapper: `reacton.ipyvuetify` 18 | * `bqplot` wrapper: `reacton.bqplot` 19 | * `ipycanvas` wrapper: `reacton.ipycanvas` 20 | 21 | This allows us to do instead: 22 | ```python 23 | import reacton.ipywidgets as w 24 | button_element = w.Button(description="Click me") 25 | ``` 26 | 27 | And enjoy auto complete and type checking. 28 | 29 | ## Create your own wrapper 30 | 31 | The best example would be to take a look at the source code for now: 32 | 33 | * [ipywidgets](https://github.com/widgetti/reacton/blob/master/reacton/ipywidgets.py) 34 | * [bqplot](https://github.com/widgetti/reacton/blob/master/reacton/bqplot.py) 35 | * [ipyvuetify](https://github.com/widgetti/reacton/blob/master/reacton/ipyvuetify.py) 36 | 37 | The code is generated by executing: 38 | 39 | $ python -m reacton.ipywidgets 40 | 41 | 42 | ## Limitation 43 | 44 | Reacton assumes the widget constructor arguments match the traits. If this is not the case, this may result in runtime errors. If this leads to issues, please open an [Issue](https://github.com/widgetti/reacton/issues/new) to discuss this. 45 | -------------------------------------------------------------------------------- /docs/overrides.json: -------------------------------------------------------------------------------- 1 | { 2 | "jupyterlab-google-analytics:plugin": { 3 | "trackingId": "UA-240047566-1" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /docs/testing.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | Testing can be done by explictly rendering, and checking the `ipywidgets` that Reacton produces. 4 | 5 | ```python 6 | import reacton 7 | import ipywidgets as widgets 8 | import reacton.ipywidgets as w 9 | 10 | def test_docs_example(): 11 | set_state = None 12 | 13 | @react.component 14 | def Test(): 15 | nonlocal set_state 16 | state, set_state = react.use_state(0) 17 | if state == 0: 18 | return w.Button(description="Hi") 19 | else: 20 | return w.FloatSlider() 21 | 22 | box, rc = react.render(Test(), handle_error=False) 23 | assert isinstance(box.children[0], widgets.Button) 24 | assert set_state is not None 25 | set_state(1) 26 | 27 | assert isinstance(box.children[0], widgets.FloatSlider) 28 | ``` 29 | 30 | This can become tiresome, and we have a playwright-like API to find widgets: 31 | ```python 32 | ... 33 | # usind the find api 34 | rc.find(widgets.FloatSlider).assert_single() 35 | rc.find(widgets.Button).assert_empty() 36 | set_state(0) 37 | rc.find(widgets.FloatSlider).assert_empty() 38 | rc.find(widgets.Button).assert_single() 39 | ``` 40 | 41 | Or be more specific about matching properties: 42 | ```python 43 | ... 44 | rc.find(widgets.Button, description="Hello").assert_empty() 45 | rc.find(widgets.Button, description="Hi").assert_single() 46 | ``` 47 | 48 | ## Tagging elements/widgets 49 | 50 | Often, you just want to know if a widget is rendered, or you want to find a widget very deep into you application tree. In those cases, we can attach meta data to the widget, and query based on that: 51 | 52 | ```python 53 | def test_docs_example_meta(): 54 | @react.component 55 | def Test(): 56 | # add {'meta': 'ref'} to the widget 57 | return w.Button(description="1").meta(ref="some_button") 58 | 59 | box, rc = react.render(Test(), handle_error=False) 60 | # we just want to know, did it render? 61 | rc.find(meta_ref="some_button").assert_single() 62 | ``` 63 | 64 | ## Async support 65 | 66 | 67 | In you need to wait for UI changes, because some work is being done in a thread, use `.wait_for()`: 68 | 69 | ```python 70 | import reacton 71 | import time 72 | import threading 73 | 74 | def test_docs_example_async(): 75 | @react.component 76 | def Test(): 77 | state, set_state = react.use_state(0) 78 | 79 | def thread_run(): 80 | time.sleep(0.5) 81 | set_state(1) 82 | 83 | reacton.use_effect(lambda: threading.Thread(target=thread_run).run(), []) 84 | 85 | if state == 0: 86 | return w.Button(description="Hi") 87 | else: 88 | return w.FloatSlider() 89 | 90 | box, rc = react.render(Test(), handle_error=False) 91 | rc.find(widgets.FloatSlider).wait_for().assert_single() 92 | ``` 93 | 94 | ## Examples 95 | 96 | All of these examples can be found in the tests of Reacton itself, don't be afraid to read the test code: 97 | 98 | * [find_test.py](https://github.com/widgetti/reacton/blob/master/reacton/find_test.py) 99 | * [core_test.py](https://github.com/widgetti/reacton/blob/master/reacton/core_test.py) 100 | -------------------------------------------------------------------------------- /docs/understanding.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Understanding 4 | 5 | To help you better understand how Reacton works, we have create the following sequence diagram 6 | that shows what happens when you create the `ButtonClick` element, let Reacton 7 | render it, and then click on the button once. 8 | 9 | 10 | ```mermaid 11 | sequenceDiagram 12 | actor You 13 | participant Frontend as Frontend 14 | participant ipywidgets as IPyWidgets 15 | participant react as Reacton 16 | participant component as ButtonClick 17 | participant app as Yourapp 18 | 19 | app->>component: el=ButtonClick() 20 | app->>react: render(el) 21 | 22 | activate react 23 | react->>component: render() 24 | component->>react: use_state(0) (returns 0) 25 | 26 | react->>react: reconsolidate() 27 | react->>ipywidgets: create Button widget(description="Clicked: 0 times") 28 | deactivate react 29 | ipywidgets--)Frontend: create Button view 30 | 31 | You->>Frontend: clicks button 32 | 33 | Frontend--)ipywidgets: Button clicked 34 | ipywidgets->>component: on_click 35 | activate component 36 | component->>react: set_clicks(1) 37 | activate react 38 | deactivate component 39 | react->>+component: render() 40 | component->>-react: use_state(0) (now returns 1) 41 | react->>react: reconsolidate() 42 | react->>ipywidgets: update Button.description="Clicked 1 times" 43 | deactivate react 44 | ipywidgets--)Frontend: update Button.description="Clicked 1 times" 45 | ``` 46 | 47 | In words 48 | 49 | 1. We create an element `el = ButtonClick()` 50 | 1. The `display(el)` triggers the call to Reacton' [render](#render). 51 | 1. The render call enters the render phase, which will call the function body (which we call render function) of the `ButtonClick` component. 52 | 1. Our ButtonClick render function calls [`reacton.use_state`](#use_state). Because this is our first render phase, this returns the initial value (0). 53 | 1. The ButtonClick render function returns a Button element (not a widget!) with `description="Clicked: 0 times"`. 54 | 1. The Reacton render call is done with the render phase, and enters the reconciliation phase, where it looks at the difference between the real widgets and the virtual widgets tree (represented by the Reacton elements). We find there is no previous widget associated with the virtual widget (or element) and decide to create a widget. 55 | 1. Asynchronously via the Jupyter protocol, a widget model and view are created and displayed to the user in the browser. 56 | 1. The user clicks on the button. 57 | 1. The `on_click` handler gets triggered on the Python side, inside of the `ButtonClick` component (called `my_click_handler`). 58 | 1. `my_click_handler` handler calls `set_clicks(1)` which triggers a re-render. 59 | 1. The render call enters the render phase, which calls the render function of `ButtonClick` for the second time. 60 | 1. Our ButtonClick render function calls [`reacton.use_state`](#use_state). Because this is our second render phase, this returns the last set value, which is 1. 61 | 1. The ButtonClick render function returns a new Button element (not a widget!) with the description `"Clicked: 1 times"`. 62 | 1. The Reacton render call is done with the render phase, and enters the reconciliation phase, where it looks at the difference between the real widgets and the virtual widgets tree (represented by the Reacton elements). We find there is a widget associated with the virtual widget (or element) and decide to update the changed attributes of the widget and set `description` to `"Clicked: 1 times"`. 63 | 1. Asynchronously via the Jupyter protocol, the widet model and view are being updated in the browser. 64 | -------------------------------------------------------------------------------- /docs/xeus-python-environment.yml: -------------------------------------------------------------------------------- 1 | name: reacton-demo 2 | channels: 3 | - https://repo.mamba.pm/emscripten-forge 4 | - https://repo.mamba.pm/conda-forge 5 | dependencies: 6 | - reacton 7 | - bqplot 8 | - ipyvue 9 | - ipyvuetify 10 | - markdown 11 | -------------------------------------------------------------------------------- /make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.https://www.sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Reacton 2 | theme: 3 | name: material 4 | repo_url: https://github.com/widgetti/reacton/ 5 | 6 | nav: 7 | - index.md 8 | - installing.md 9 | - getting-started.md 10 | - examples.md 11 | - API: api.md 12 | - understanding.md 13 | - Libraries: libraries.md 14 | - Testing: testing.md 15 | 16 | markdown_extensions: 17 | - pymdownx.highlight: 18 | anchor_linenums: true 19 | - pymdownx.inlinehilite 20 | - pymdownx.snippets 21 | - pymdownx.superfences: 22 | custom_fences: 23 | - name: mermaid 24 | class: mermaid 25 | format: !!python/name:pymdownx.superfences.fence_code_format 26 | extra: 27 | analytics: 28 | provider: google 29 | property: UA-240047566-1 30 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | check_untyped_defs = True 3 | ignore_missing_imports = True 4 | no_implicit_optional = False 5 | allow_empty_bodies = True 6 | -------------------------------------------------------------------------------- /notebooks/calculator.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "96381759", 6 | "metadata": {}, 7 | "source": [ 8 | "# Calculator\n", 9 | "All the calculator logic is put in a 'reducer'" 10 | ] 11 | }, 12 | { 13 | "cell_type": "code", 14 | "execution_count": null, 15 | "id": "20bae5ea", 16 | "metadata": {}, 17 | "outputs": [], 18 | "source": [ 19 | "import ast\n", 20 | "import dataclasses\n", 21 | "import operator\n", 22 | "from typing import Any, Optional\n", 23 | "\n", 24 | "DEBUG = False\n", 25 | "operator_map = {\n", 26 | " \"x\": operator.mul,\n", 27 | " \"/\": operator.truediv,\n", 28 | " \"+\": operator.add,\n", 29 | " \"-\": operator.sub,\n", 30 | "}\n", 31 | "\n", 32 | "\n", 33 | "@dataclasses.dataclass(frozen=True)\n", 34 | "class CalculatorState:\n", 35 | " input: str = \"\"\n", 36 | " output: str = \"\"\n", 37 | " left: float = 0\n", 38 | " right: Optional[float] = None\n", 39 | " operator: Any = operator.add\n", 40 | " error: str = \"\"\n", 41 | "\n", 42 | "\n", 43 | "initial_state = CalculatorState()\n", 44 | "\n", 45 | "\n", 46 | "def calculate(state: CalculatorState):\n", 47 | " result = state.operator(state.left, state.right)\n", 48 | " return dataclasses.replace(state, left=result)\n", 49 | "\n", 50 | "\n", 51 | "def calculator_reducer(state: CalculatorState, action):\n", 52 | " action_type, payload = action\n", 53 | " if DEBUG:\n", 54 | " print(\"reducer\", state, action_type, payload) # noqa\n", 55 | " state = dataclasses.replace(state, error=\"\")\n", 56 | "\n", 57 | " if action_type == \"digit\":\n", 58 | " digit = payload\n", 59 | " input = state.input + digit\n", 60 | " return dataclasses.replace(state, input=input, output=input)\n", 61 | " elif action_type == \"percent\":\n", 62 | " if state.input:\n", 63 | " try:\n", 64 | " value = ast.literal_eval(state.input)\n", 65 | " except Exception as e:\n", 66 | " return dataclasses.replace(state, error=str(e))\n", 67 | " state = dataclasses.replace(state, right=value / 100)\n", 68 | " state = calculate(state)\n", 69 | " output = f\"{value / 100:,}\"\n", 70 | " return dataclasses.replace(state, output=output, input=\"\")\n", 71 | " else:\n", 72 | " output = f\"{state.left / 100:,}\"\n", 73 | " return dataclasses.replace(state, left=state.left / 100, output=output)\n", 74 | " elif action_type == \"negate\":\n", 75 | " if state.input:\n", 76 | " input = state.output\n", 77 | " input = input[1:] if input[0] == \"-\" else \"-\" + input\n", 78 | " output = input\n", 79 | " return dataclasses.replace(state, input=input, output=output)\n", 80 | " else:\n", 81 | " output = f\"{-state.left:,}\"\n", 82 | " return dataclasses.replace(state, left=-state.left, output=output)\n", 83 | " elif action_type == \"clear\":\n", 84 | " return dataclasses.replace(state, input=\"\", output=\"\")\n", 85 | " elif action_type == \"reset\":\n", 86 | " return initial_state\n", 87 | " elif action_type == \"calculate\":\n", 88 | " if state.input:\n", 89 | " try:\n", 90 | " value = ast.literal_eval(state.input)\n", 91 | " except Exception as e:\n", 92 | " return dataclasses.replace(state, error=str(e))\n", 93 | " state = dataclasses.replace(state, right=value)\n", 94 | " state = calculate(state)\n", 95 | " output = f\"{state.left:,}\"\n", 96 | " state = dataclasses.replace(state, output=output, input=\"\")\n", 97 | " return state\n", 98 | " elif action_type == \"operator\":\n", 99 | " if state.input:\n", 100 | " state = calculator_reducer(state, (\"calculate\", None))\n", 101 | " state = dataclasses.replace(state, operator=payload, input=\"\")\n", 102 | " else:\n", 103 | " # e.g. 2+3=*= should give 5,25\n", 104 | " state = dataclasses.replace(state, operator=payload, right=state.left)\n", 105 | " return state\n", 106 | " else:\n", 107 | " print(\"invalid action\", action) # noqa\n", 108 | " return state" 109 | ] 110 | }, 111 | { 112 | "cell_type": "markdown", 113 | "id": "1bb5252e", 114 | "metadata": {}, 115 | "source": [ 116 | "## UI using ipywidgets\n", 117 | "\n", 118 | "The reducer is used for the logic, we only declare a UI in the component." 119 | ] 120 | }, 121 | { 122 | "cell_type": "code", 123 | "execution_count": null, 124 | "id": "1512b84b", 125 | "metadata": {}, 126 | "outputs": [], 127 | "source": [ 128 | "import reacton\n", 129 | "import reacton.ipywidgets as w\n", 130 | "\n", 131 | "\n", 132 | "@reacton.component\n", 133 | "def Calculator():\n", 134 | " state, dispatch = reacton.use_reducer(calculator_reducer, initial_state)\n", 135 | " with w.VBox() as main:\n", 136 | " w.HTML(value=\"

Calculator

Using Reacton

\")\n", 137 | " with w.VBox():\n", 138 | " w.Label(value=state.error or state.output or \"0\")\n", 139 | "\n", 140 | " with w.HBox():\n", 141 | " if state.input:\n", 142 | " w.Button(description=\"C\", on_click=lambda: dispatch((\"clear\", None)))\n", 143 | " else:\n", 144 | " w.Button(description=\"AC\", on_click=lambda: dispatch((\"reset\", None)))\n", 145 | " w.Button(description=\"+/-\", on_click=lambda: dispatch((\"negate\", None)))\n", 146 | " w.Button(description=\"%\", on_click=lambda: dispatch((\"percent\", None)))\n", 147 | " w.Button(description=\"/\", on_click=lambda: dispatch((\"operator\", operator_map[\"/\"])))\n", 148 | "\n", 149 | " column_op = [\"x\", \"-\", \"+\"]\n", 150 | " for i in range(3):\n", 151 | " with w.HBox():\n", 152 | " for j in range(3):\n", 153 | " digit = str(j + (2 - i) * 3 + 1)\n", 154 | " w.Button(description=digit, on_click=lambda digit=digit: dispatch((\"digit\", digit)))\n", 155 | " op_symbol = column_op[i]\n", 156 | " op = operator_map[op_symbol]\n", 157 | " w.Button(description=op_symbol, on_click=lambda op=op: dispatch((\"operator\", op)))\n", 158 | " with w.HBox():\n", 159 | "\n", 160 | " def boom():\n", 161 | " print(\"boom\")\n", 162 | " raise ValueError(\"boom\")\n", 163 | "\n", 164 | " w.Button(description=\"?\", on_click=boom)\n", 165 | "\n", 166 | " w.Button(description=\"0\", on_click=lambda: dispatch((\"digit\", \"0\")))\n", 167 | " w.Button(description=\".\", on_click=lambda: dispatch((\"digit\", \".\")))\n", 168 | "\n", 169 | " w.Button(description=\"=\", on_click=lambda: dispatch((\"calculate\", None)))\n", 170 | "\n", 171 | " return main\n", 172 | "\n", 173 | "\n", 174 | "Calculator()" 175 | ] 176 | }, 177 | { 178 | "cell_type": "markdown", 179 | "id": "33756dbf", 180 | "metadata": {}, 181 | "source": [ 182 | "## Using ipyvuetify\n", 183 | "\n", 184 | "We can make it prettier using ipyvuetify, using the *same* reducer, so we do not need to repeat the calculator logic." 185 | ] 186 | }, 187 | { 188 | "cell_type": "code", 189 | "execution_count": null, 190 | "id": "d15b1a72", 191 | "metadata": {}, 192 | "outputs": [], 193 | "source": [ 194 | "import reacton.ipyvuetify as v\n", 195 | "\n", 196 | "\n", 197 | "@reacton.component\n", 198 | "def CalculatorVuetify():\n", 199 | " state, dispatch = reacton.use_reducer(calculator_reducer, initial_state)\n", 200 | " with v.Card(elevation=10, class_=\"ma-4\") as main:\n", 201 | " with v.CardTitle(children=[\"Calculator\"]):\n", 202 | " pass\n", 203 | " with v.CardSubtitle(children=[\"With ipyvuetify and Reacton\"]):\n", 204 | " pass\n", 205 | " with v.CardText():\n", 206 | " with w.VBox():\n", 207 | " v.Label(children=[state.error or state.output or \"0\"])\n", 208 | " class_ = \"pa-0 ma-1\"\n", 209 | "\n", 210 | " with w.HBox():\n", 211 | " if state.input:\n", 212 | " v.BtnWithClick(children=\"C\", on_click=lambda: dispatch((\"clear\", None)), dark=True, class_=class_)\n", 213 | " else:\n", 214 | " v.BtnWithClick(children=\"AC\", on_click=lambda: dispatch((\"reset\", None)), dark=True, class_=class_)\n", 215 | " v.BtnWithClick(children=\"+/-\", on_click=lambda: dispatch((\"negate\", None)), dark=True, class_=class_)\n", 216 | " v.BtnWithClick(children=\"%\", on_click=lambda: dispatch((\"percent\", None)), dark=True, class_=class_)\n", 217 | " v.BtnWithClick(children=\"/\", color=\"primary\", on_click=lambda: dispatch((\"operator\", operator_map[\"/\"])), class_=class_)\n", 218 | "\n", 219 | " column_op = [\"x\", \"-\", \"+\"]\n", 220 | " for i in range(3):\n", 221 | " with w.HBox():\n", 222 | " for j in range(3):\n", 223 | " digit = str(j + (2 - i) * 3 + 1)\n", 224 | " v.BtnWithClick(children=digit, on_click=lambda digit=digit: dispatch((\"digit\", digit)), class_=class_)\n", 225 | " op_symbol = column_op[i]\n", 226 | " op = operator_map[op_symbol]\n", 227 | " v.BtnWithClick(children=op_symbol, color=\"primary\", on_click=lambda op=op: dispatch((\"operator\", op)), class_=class_)\n", 228 | " with w.HBox():\n", 229 | "\n", 230 | " def boom():\n", 231 | " print(\"boom\")\n", 232 | " raise ValueError(\"boom\")\n", 233 | "\n", 234 | " v.BtnWithClick(children=\"?\", on_click=boom, class_=class_)\n", 235 | "\n", 236 | " v.BtnWithClick(children=\"0\", on_click=lambda: dispatch((\"digit\", \"0\")), class_=class_)\n", 237 | " v.BtnWithClick(children=\".\", on_click=lambda: dispatch((\"digit\", \".\")), class_=class_)\n", 238 | "\n", 239 | " v.BtnWithClick(children=\"=\", color=\"primary\", on_click=lambda: dispatch((\"calculate\", None)), class_=class_)\n", 240 | "\n", 241 | " return main\n", 242 | "\n", 243 | "\n", 244 | "CalculatorVuetify()" 245 | ] 246 | }, 247 | { 248 | "cell_type": "code", 249 | "execution_count": null, 250 | "id": "2f7a7582", 251 | "metadata": {}, 252 | "outputs": [], 253 | "source": [] 254 | } 255 | ], 256 | "metadata": { 257 | "kernelspec": { 258 | "display_name": "Python 3.9.10 ('dev')", 259 | "language": "python", 260 | "name": "python3" 261 | }, 262 | "language_info": { 263 | "codemirror_mode": { 264 | "name": "ipython", 265 | "version": 3 266 | }, 267 | "file_extension": ".py", 268 | "mimetype": "text/x-python", 269 | "name": "python", 270 | "nbconvert_exporter": "python", 271 | "pygments_lexer": "ipython3", 272 | "version": "3.9.10" 273 | }, 274 | "vscode": { 275 | "interpreter": { 276 | "hash": "3f54047370d637df4a365f9bae65e296d7b1c0737aca7baed81d825616d991e7" 277 | } 278 | } 279 | }, 280 | "nbformat": 4, 281 | "nbformat_minor": 5 282 | } 283 | -------------------------------------------------------------------------------- /notebooks/click-button.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "b775a4db", 6 | "metadata": {}, 7 | "source": [ 8 | "# The anti-example\n", 9 | "\n", 10 | "A non-reusable pattern with the following problem:\n", 11 | "\n", 12 | " 1. The button description text is repeated in two places (initialization and the event handler).\n", 13 | " 2. An event handler is attached, but without a defined life cycle, it can be difficult to know when to detach it to avoid memory leaks.\n", 14 | " 3. The \"clicks\" variable is stored in the global scope, which may be concerning for some developers.\n", 15 | " 4. The code is not easily reusable or composable." 16 | ] 17 | }, 18 | { 19 | "cell_type": "code", 20 | "execution_count": null, 21 | "id": "07612fd4", 22 | "metadata": {}, 23 | "outputs": [], 24 | "source": [ 25 | "import ipywidgets as widgets\n", 26 | "\n", 27 | "\n", 28 | "clicks = 0 # issue 3\n", 29 | "\n", 30 | "\n", 31 | "def on_click(button):\n", 32 | " global clicks # issue 3\n", 33 | " clicks += 1\n", 34 | " button.description = f\"Clicked {clicks} times\" # issue 1\n", 35 | "\n", 36 | "\n", 37 | "button = widgets.Button(description=\"Clicked 0 times\") # issue 1\n", 38 | "button.on_click(on_click) # issue 2\n", 39 | "display(button)" 40 | ] 41 | }, 42 | { 43 | "cell_type": "markdown", 44 | "id": "50a81948", 45 | "metadata": {}, 46 | "source": [ 47 | "# Using Reacton\n", 48 | "\n", 49 | " 1. The description f-string is only at 1 place.\n", 50 | " 2. The Event handler will be removed.\n", 51 | " 3. State is local, not mutable by external code.\n", 52 | " 4. We can reuse this Component.\n" 53 | ] 54 | }, 55 | { 56 | "cell_type": "code", 57 | "execution_count": null, 58 | "id": "c2d9d28d", 59 | "metadata": {}, 60 | "outputs": [], 61 | "source": [ 62 | "import reacton\n", 63 | "import reacton.ipywidgets as w\n", 64 | "\n", 65 | "\n", 66 | "@reacton.component\n", 67 | "def ButtonClick():\n", 68 | " # first render, this return 0, after that, the last argument\n", 69 | " # of set_clicks\n", 70 | " clicks, set_clicks = reacton.use_state(0)\n", 71 | "\n", 72 | " def my_click_handler():\n", 73 | " # trigger a new render with a new value for clicks\n", 74 | " set_clicks(clicks + 1)\n", 75 | "\n", 76 | " button = w.Button(description=f\"Clicked {clicks} times\", on_click=my_click_handler)\n", 77 | " return button\n", 78 | "\n", 79 | "\n", 80 | "ButtonClick()" 81 | ] 82 | }, 83 | { 84 | "cell_type": "code", 85 | "execution_count": null, 86 | "id": "952021df-e11c-4483-967f-1fc2b97398f2", 87 | "metadata": {}, 88 | "outputs": [], 89 | "source": [ 90 | "@reacton.component\n", 91 | "def ManyButtons(count=4):\n", 92 | " count, set_count = reacton.use_state(count)\n", 93 | " slider = w.IntSlider(min=0, max=20, value=count, on_value=set_count)\n", 94 | " buttons = [ButtonClick() for i in range(count)]\n", 95 | " return w.VBox(children=[slider, *buttons])\n", 96 | "\n", 97 | "\n", 98 | "display(ManyButtons())" 99 | ] 100 | }, 101 | { 102 | "cell_type": "markdown", 103 | "id": "36ecb76f", 104 | "metadata": {}, 105 | "source": [ 106 | "# Using ipyvuetify" 107 | ] 108 | }, 109 | { 110 | "cell_type": "code", 111 | "execution_count": null, 112 | "id": "95b7d6c2", 113 | "metadata": {}, 114 | "outputs": [], 115 | "source": [ 116 | "import reacton\n", 117 | "import reacton.ipyvuetify as rv\n", 118 | "\n", 119 | "\n", 120 | "@reacton.component\n", 121 | "def ButtonClick():\n", 122 | " # first render, this return 0, after that, the last argument\n", 123 | " # of set_clicks\n", 124 | " clicks, set_clicks = reacton.use_state(0)\n", 125 | "\n", 126 | " def my_click_handler(*ignore_args):\n", 127 | " # trigger a new render with a new value for clicks\n", 128 | " set_clicks(clicks + 1)\n", 129 | "\n", 130 | " button = rv.Btn(children=[f\"Clicked {clicks} times\"])\n", 131 | " rv.use_event(button, \"click\", my_click_handler)\n", 132 | " return button\n", 133 | "\n", 134 | "\n", 135 | "ButtonClick()" 136 | ] 137 | }, 138 | { 139 | "cell_type": "code", 140 | "execution_count": null, 141 | "id": "7c851d6e", 142 | "metadata": {}, 143 | "outputs": [], 144 | "source": [] 145 | } 146 | ], 147 | "metadata": { 148 | "kernelspec": { 149 | "display_name": "Python 3.9.10 ('dev')", 150 | "language": "python", 151 | "name": "python3" 152 | }, 153 | "language_info": { 154 | "codemirror_mode": { 155 | "name": "ipython", 156 | "version": 3 157 | }, 158 | "file_extension": ".py", 159 | "mimetype": "text/x-python", 160 | "name": "python", 161 | "nbconvert_exporter": "python", 162 | "pygments_lexer": "ipython3", 163 | "version": "3.9.10" 164 | }, 165 | "vscode": { 166 | "interpreter": { 167 | "hash": "3f54047370d637df4a365f9bae65e296d7b1c0737aca7baed81d825616d991e7" 168 | } 169 | } 170 | }, 171 | "nbformat": 4, 172 | "nbformat_minor": 5 173 | } 174 | -------------------------------------------------------------------------------- /notebooks/markdown.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "2d740c7d", 6 | "metadata": {}, 7 | "source": [ 8 | "# Markdown component\n", 9 | "Given this [suggestion](https://github.com/jupyter-widgets/ipywidgets/issues/2428#issuecomment-500084610) on how to make a widget with markdown, we don't have an obvious path forward to create a new Markdown widget that can be reused. Should we inherit? From which class? Should we compose and inherit from VBox or HBox and add the HTML widget as a single child?\n", 10 | "\n", 11 | "With react-ipywidgest there is an obvious way:" 12 | ] 13 | }, 14 | { 15 | "cell_type": "code", 16 | "execution_count": null, 17 | "id": "858fe6ef", 18 | "metadata": {}, 19 | "outputs": [], 20 | "source": [ 21 | "import reacton\n", 22 | "import markdown\n", 23 | "import reacton.ipywidgets as w\n", 24 | "\n", 25 | "\n", 26 | "@reacton.component\n", 27 | "def Markdown(md: str):\n", 28 | " html = markdown.markdown(md)\n", 29 | " return w.HTML(value=html)\n", 30 | "\n", 31 | "\n", 32 | "Markdown(\"# Reacton rocks\\nSeriously **bold** idea!\")" 33 | ] 34 | }, 35 | { 36 | "cell_type": "markdown", 37 | "id": "e6ce3df3", 38 | "metadata": {}, 39 | "source": [ 40 | "# Markdown editor\n", 41 | "\n", 42 | "Now we can reuse this component, to make a Markdown editor." 43 | ] 44 | }, 45 | { 46 | "cell_type": "code", 47 | "execution_count": null, 48 | "id": "6aa67310", 49 | "metadata": {}, 50 | "outputs": [], 51 | "source": [ 52 | "@reacton.component\n", 53 | "def MarkdownEditor(md: str):\n", 54 | " md, set_md = reacton.use_state(md)\n", 55 | " edit, set_edit = reacton.use_state(True)\n", 56 | " with w.VBox() as main:\n", 57 | " Markdown(md)\n", 58 | " w.ToggleButton(description=\"Edit\", value=edit, on_value=set_edit)\n", 59 | " if edit:\n", 60 | " w.Textarea(value=md, on_value=set_md, rows=10)\n", 61 | " return main\n", 62 | "\n", 63 | "\n", 64 | "MarkdownEditor(\"# Reacton rocks\\nSeriously **bold** idea!\")" 65 | ] 66 | }, 67 | { 68 | "cell_type": "code", 69 | "execution_count": null, 70 | "id": "0573b7e7", 71 | "metadata": {}, 72 | "outputs": [], 73 | "source": [] 74 | } 75 | ], 76 | "metadata": { 77 | "kernelspec": { 78 | "display_name": "Python 3 (ipykernel)", 79 | "language": "python", 80 | "name": "python3" 81 | }, 82 | "language_info": { 83 | "codemirror_mode": { 84 | "name": "ipython", 85 | "version": 3 86 | }, 87 | "file_extension": ".py", 88 | "mimetype": "text/x-python", 89 | "name": "python", 90 | "nbconvert_exporter": "python", 91 | "pygments_lexer": "ipython3", 92 | "version": "3.9.10" 93 | } 94 | }, 95 | "nbformat": 4, 96 | "nbformat_minor": 5 97 | } 98 | -------------------------------------------------------------------------------- /notebooks/todo-app.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "id": "243ee5cd", 7 | "metadata": { 8 | "scrolled": false 9 | }, 10 | "outputs": [], 11 | "source": [ 12 | "from typing import Callable, List\n", 13 | "import reacton\n", 14 | "import reacton.ipyvuetify as rv\n", 15 | "import reacton.ipywidgets as w\n", 16 | "import dataclasses\n", 17 | "\n", 18 | "\n", 19 | "# our model for a todo item, immutable/frozen avoids common bugs\n", 20 | "@dataclasses.dataclass(frozen=True)\n", 21 | "class TodoItem:\n", 22 | " text: str\n", 23 | " done: bool\n", 24 | "\n", 25 | "\n", 26 | "@reacton.component\n", 27 | "def TodoListItem(item: TodoItem, on_item_change: Callable[[TodoItem], None], on_delete: Callable[[], None]):\n", 28 | " \"\"\"Displays a single todo item\"\"\"\n", 29 | "\n", 30 | " def on_change_done(done: bool):\n", 31 | " on_item_change(dataclasses.replace(item, done=done))\n", 32 | "\n", 33 | " def on_change_text(text: str):\n", 34 | " on_item_change(dataclasses.replace(item, text=text))\n", 35 | "\n", 36 | " with rv.ListItem() as main:\n", 37 | " with rv.Btn(icon=True) as button_delete:\n", 38 | " rv.Icon(children=[\"mdi-delete\"])\n", 39 | " rv.use_event(button_delete, \"click\", lambda *ignore_events: on_delete())\n", 40 | " rv.Checkbox(v_model=item.done, on_v_model=on_change_done, color=\"success\")\n", 41 | " rv.TextField(v_model=item.text, on_v_model=on_change_text)\n", 42 | " return main\n", 43 | "\n", 44 | "\n", 45 | "@reacton.component\n", 46 | "def TodoNew(on_new_item: Callable[[TodoItem], None]):\n", 47 | " \"\"\"Component that managed entering new todo items\"\"\"\n", 48 | " new_text, set_new_text = reacton.use_state(\"\")\n", 49 | " text_field = rv.TextField(v_model=new_text, on_v_model=set_new_text, label=\"Enter a new todo item\")\n", 50 | "\n", 51 | " def create_new_item(*ignore_args):\n", 52 | " if not new_text:\n", 53 | " return\n", 54 | " # add it\n", 55 | " new_item = TodoItem(text=new_text, done=False)\n", 56 | " on_new_item(new_item)\n", 57 | " # reset text\n", 58 | " set_new_text(\"\")\n", 59 | "\n", 60 | " rv.use_event(text_field, \"keydown.enter\", create_new_item)\n", 61 | " return text_field\n", 62 | "\n", 63 | "\n", 64 | "@reacton.component\n", 65 | "def TodoStatus(items: List[TodoItem]):\n", 66 | " \"\"\"Status of our todo list\"\"\"\n", 67 | " count = len(items)\n", 68 | " items_done = [item for item in items if item.done]\n", 69 | " count_done = len(items_done)\n", 70 | "\n", 71 | " if count != count_done:\n", 72 | " with rv.Row(style_=\"margin 5px\") as main:\n", 73 | " layout = w.Layout(margin=\"0\")\n", 74 | " w.HTML(value=f\"Remaining: {count-count_done}\", layout=layout)\n", 75 | " rv.Divider(vertical=True, style_=\"margin: 10px\")\n", 76 | " w.HTML(value=f\"Completed: {count_done}\", layout=layout)\n", 77 | " progress = 100 * count_done // count\n", 78 | " rv.Spacer()\n", 79 | " rv.ProgressCircular(value=progress, color=\"green\" if progress > 50 else \"orange\")\n", 80 | " else:\n", 81 | " main = rv.Alert(type=\"success\", children=[\"All done, awesome!\"], dense=True)\n", 82 | " return main\n", 83 | "\n", 84 | "\n", 85 | "@reacton.component\n", 86 | "def TodoApp(items: List[TodoItem]):\n", 87 | " items, set_items = reacton.use_state(items)\n", 88 | "\n", 89 | " def on_new_item(new_item: TodoItem):\n", 90 | " new_items = [new_item, *items]\n", 91 | " set_items(new_items)\n", 92 | "\n", 93 | " with rv.Container() as main:\n", 94 | " TodoNew(on_new_item=on_new_item)\n", 95 | "\n", 96 | " if items:\n", 97 | " TodoStatus(items)\n", 98 | " for index, item in enumerate(items):\n", 99 | "\n", 100 | " def on_item_change(changed_item, index=index):\n", 101 | " new_items = items.copy() # copy because we mutate\n", 102 | " new_items[index] = changed_item\n", 103 | " set_items(new_items)\n", 104 | "\n", 105 | " def on_delete(index=index):\n", 106 | " new_items = items.copy() # copy because we mutate\n", 107 | " new_items.pop(index)\n", 108 | " set_items(new_items)\n", 109 | "\n", 110 | " TodoListItem(item, on_item_change, on_delete)\n", 111 | " else:\n", 112 | " rv.Alert(type=\"info\", children=[\"No todo items, enter some text above, and hit enter\"])\n", 113 | " return main\n", 114 | "\n", 115 | "\n", 116 | "initial_items = [\n", 117 | " TodoItem(\"Learn reacton\", done=True),\n", 118 | " TodoItem(\"Implement React in Python\", done=False),\n", 119 | " TodoItem(\"Write documentation\", done=False),\n", 120 | "]\n", 121 | "\n", 122 | "TodoApp(initial_items)" 123 | ] 124 | }, 125 | { 126 | "cell_type": "code", 127 | "execution_count": null, 128 | "id": "4e1262da", 129 | "metadata": {}, 130 | "outputs": [], 131 | "source": [] 132 | } 133 | ], 134 | "metadata": { 135 | "kernelspec": { 136 | "display_name": "Python 3 (ipykernel)", 137 | "language": "python", 138 | "name": "python3" 139 | }, 140 | "language_info": { 141 | "codemirror_mode": { 142 | "name": "ipython", 143 | "version": 3 144 | }, 145 | "file_extension": ".py", 146 | "mimetype": "text/x-python", 147 | "name": "python", 148 | "nbconvert_exporter": "python", 149 | "pygments_lexer": "ipython3", 150 | "version": "3.9.10" 151 | } 152 | }, 153 | "nbformat": 4, 154 | "nbformat_minor": 5 155 | } 156 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | 6 | 7 | [project] 8 | name = "reacton" 9 | authors = [{name = "Maarten A. Breddels", email = "maartenbreddels@gmail.com"}] 10 | license = {file = "LICENSE"} 11 | dynamic = ["version", "description"] 12 | dependencies = [ 13 | "ipywidgets", 14 | "typing_extensions >= 4.1.1", 15 | ] 16 | 17 | classifiers = [ 18 | "License :: OSI Approved :: MIT License", 19 | "Programming Language :: Python :: 3.6", 20 | "Programming Language :: Python :: 3.7", 21 | "Programming Language :: Python :: 3.8", 22 | "Programming Language :: Python :: 3.9", 23 | "Programming Language :: Python :: 3.10", 24 | "Programming Language :: Python :: 3.11", 25 | "Programming Language :: Python :: 3.12", 26 | "Programming Language :: Python :: 3.13", 27 | "Framework :: Jupyter", 28 | "Topic :: Software Development :: User Interfaces", 29 | "Environment :: Web Environment", 30 | ] 31 | 32 | [tool.hatch.version] 33 | path = "reacton/_version.py" 34 | 35 | [tool.hatch.build.targets.wheel] 36 | packages = ["react_ipywidgets", "reacton"] 37 | 38 | [tool.hatch.build.targets.sdist] 39 | packages = ["react_ipywidgets", "reacton"] 40 | 41 | 42 | [project.optional-dependencies] 43 | dev = [ 44 | "ruff; python_version > '3.6'", 45 | "mypy", 46 | "pre-commit", 47 | "coverage", 48 | "pytest", 49 | "pytest-cov", 50 | "bqplot", 51 | "numpy<2", 52 | "ipyvuetify", 53 | "bump2version", 54 | "jinja2", 55 | "pandas", 56 | "ipykernel", 57 | ] 58 | 59 | generate = [ 60 | "ruff; python_version > '3.6'", 61 | "black", 62 | "bqplot", 63 | "jinja2", 64 | "mypy", 65 | ] 66 | 67 | [project.urls] 68 | Home = "https://www.github.com/widgetti/reacton" 69 | Documentation = "https://reacton.solara.dev" 70 | "Source code" = "https://www.github.com/widgetti/reacton" 71 | 72 | [tool.ruff] 73 | line-length = 160 74 | target-version = "py37" 75 | -------------------------------------------------------------------------------- /react_ipywidgets/__init__.py: -------------------------------------------------------------------------------- 1 | from reacton import * # noqa 2 | -------------------------------------------------------------------------------- /react_ipywidgets/core.py: -------------------------------------------------------------------------------- 1 | from reacton.core import * # noqa 2 | -------------------------------------------------------------------------------- /reacton/__init__.py: -------------------------------------------------------------------------------- 1 | """Write ipywidgets like React 2 | 3 | React - ipywidgets relation: 4 | * DOM nodes -- Widget 5 | * Element -- Element 6 | * Component -- function 7 | 8 | """ 9 | 10 | from . import _version 11 | 12 | __version__ = _version.__version__ 13 | from .core import ( 14 | Fragment, 15 | component, 16 | component_interactive, 17 | create_context, 18 | display, 19 | get_context, 20 | get_widget, 21 | make, 22 | provide_context, 23 | render, 24 | render_fixed, 25 | use_context, 26 | use_effect, 27 | use_exception, 28 | use_memo, 29 | use_reducer, 30 | use_ref, 31 | use_side_effect, 32 | use_state, 33 | use_state_widget, 34 | value_component, 35 | ) 36 | 37 | __all__ = [ 38 | "Fragment", 39 | "__version__", 40 | "component", 41 | "value_component", 42 | "render", 43 | "render_fixed", 44 | "make", 45 | "display", 46 | "get_widget", 47 | "get_context", 48 | "use_context", 49 | "create_context", 50 | "use_exception", 51 | "use_memo", 52 | "use_ref", 53 | "use_state", 54 | "use_state_widget", 55 | "use_effect", 56 | "use_side_effect", 57 | "use_reducer", 58 | "provide_context", 59 | "component_interactive", 60 | ] 61 | -------------------------------------------------------------------------------- /reacton/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.9.1" 2 | -------------------------------------------------------------------------------- /reacton/deprecated/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/widgetti/reacton/51e6eda379e0570bb94f0c8af1284d2fef0bdc8e/reacton/deprecated/__init__.py -------------------------------------------------------------------------------- /reacton/find.py: -------------------------------------------------------------------------------- 1 | import collections.abc 2 | import time 3 | from typing import Callable, Generic, List, Set, Type, TypeVar, cast 4 | 5 | from ipywidgets import Widget 6 | 7 | from .core import Element, _RenderContext 8 | 9 | W = TypeVar("W") # used for widgets 10 | X = TypeVar("X") # used for widgets 11 | 12 | 13 | def pretty_print(widget: Widget, indent=0, indent_size=2): 14 | def format_arg(value): 15 | if isinstance(value, Widget): 16 | return pretty_print(value, indent + 1, indent_size) 17 | if isinstance(value, list): 18 | return "[" + ", ".join(format_arg(v) for v in value) + "]" 19 | value_repr = repr(value) 20 | if len(value_repr) > 300: 21 | value_repr = value_repr[:50] + "..." + value_repr[-50:] 22 | return value_repr 23 | 24 | def format_kwarg(key, value): 25 | return f"{' ' * ((indent+1) * indent_size)}{key} = {format_arg(value)}" 26 | 27 | name = type(widget).__name__ 28 | keys = list(widget._repr_keys()) 29 | if hasattr(widget, "_react_meta"): 30 | keys += ["_react_meta"] 31 | if "children" in keys: 32 | keys.remove("children") 33 | keys = keys + ["children"] 34 | kwargs = [format_kwarg(key, getattr(widget, key)) for key in keys] 35 | args_formatted = "".join([f"\n{arg}," for arg in kwargs]) 36 | return f"{name}({args_formatted}\n{' ' * (indent * indent_size)})" 37 | 38 | 39 | class Finder(collections.abc.Sequence, Generic[W]): 40 | def __init__(self, widgets: List[W] = [], queries=[], parent=None) -> None: 41 | # for performance reason, we eagerly evaluate the queries and store the widgets in self.widgets 42 | # but if we want to wait for a widget to appear, we need to re-evaluate the queries every time 43 | self.widgets = widgets.copy() 44 | self.queries = queries.copy() 45 | self.parent = parent 46 | 47 | def _root(self): 48 | parent = self 49 | while parent.parent is not None: 50 | parent = parent.parent 51 | return parent 52 | 53 | def __repr__(self): 54 | return "Finder structure:\n%s" % self._current_structure() 55 | 56 | def _reexecute_find(self): 57 | finder = self._root() 58 | for query in self.queries: 59 | type = query["type"] 60 | if type == "find": 61 | cls = query["cls"] 62 | matches = query["matches"] 63 | finder = finder.find(cls, **matches) 64 | elif type == "single": 65 | finder = finder.single 66 | elif type == "getitem": 67 | finder = finder[query["item"]] 68 | else: 69 | raise ValueError(f"Unknown query type {type}") 70 | 71 | return finder 72 | 73 | @property 74 | def widget(self): 75 | return self.single.widgets[0] 76 | 77 | def assert_empty(self): 78 | assert len(self.widgets) == 0, f"Expected no widgets, but got: {self.widgets}, current structure:\n{self._current_structure()}" 79 | 80 | def assert_not_empty(self): 81 | assert len(self.widgets) != 0, f"Expected widgets, but none found, current structure:\n{self._current_structure()}" 82 | 83 | def assert_single(self): 84 | assert len(self.widgets) == 1, f"Expected a single widget, but got: {self.widgets}" 85 | 86 | def assert_single_wait(self): 87 | assert len(self.widgets) == 1, f"Expected a single widget, but got: {self.widgets}" 88 | 89 | def matches(self, **matches): 90 | self.assert_matches(**matches) 91 | 92 | def wait_for(self, state="attached", timeout=1, iteration_delay=0.01): 93 | start = time.time() 94 | finder = self 95 | while time.time() - start < timeout: 96 | if state == "attached": 97 | if len(finder) > 0: 98 | return finder 99 | elif state == "detached": 100 | if len(finder) == 0: 101 | return finder 102 | else: 103 | raise ValueError(f"Unknown state {state}, expected 'attached' or 'detached'") 104 | time.sleep(iteration_delay) 105 | finder = self._reexecute_find() 106 | raise TimeoutError(f"Timeout waiting for {self}, current structure:\n{self._current_structure()}") 107 | 108 | def _current_structure(self): 109 | non_empty = self 110 | while len(non_empty) == 0: 111 | non_empty = non_empty.parent 112 | pp_rendered = "\n".join(pretty_print(widget) for widget in non_empty.widgets) 113 | return pp_rendered 114 | 115 | def assert_matches(self, **matches): 116 | widget = self.single.widget 117 | for name, expected in matches.items(): 118 | if not hasattr(widget, name): 119 | raise AttributeError(f"Widget {widget} has no attribute {name}") 120 | value = getattr(widget, name) 121 | assert value == expected, f"Expected {widget}.{name} == {expected}, got {value}" 122 | 123 | def assert_matches_wait(self, timeout=1, iteration_delay=0.001, **matches): 124 | start = time.time() 125 | last_e = None 126 | while time.time() - start < timeout: 127 | try: 128 | self.assert_matches(**matches) 129 | return 130 | except AssertionError as e: 131 | last_e = e 132 | time.sleep(iteration_delay) 133 | 134 | assert last_e is not None 135 | raise TimeoutError(f"Timeout, waiting for {matches}, current structure:\n{self._current_structure()}") from last_e 136 | 137 | def assert_wait(self, f: Callable[[W], bool], timeout=1, iteration_delay=0.001): 138 | start = time.time() 139 | while time.time() - start < timeout: 140 | result = f(self.widget) 141 | if result: 142 | return 143 | time.sleep(iteration_delay) 144 | raise TimeoutError(f"Timeout, waiting for condition on widget, current structure:\n{self.parent._current_structure()}") 145 | 146 | @property 147 | def single(self) -> "Finder[W]": 148 | if len(self.widgets) != 1: 149 | raise ValueError(f"Expected 1 match, got {self.widgets}, current structure:\n{self._current_structure()}") 150 | queries = self.queries + [{"type": "single"}] 151 | return Finder([self.widgets[0]], queries, parent=self) 152 | 153 | def __len__(self): 154 | return len(self.widgets) 155 | 156 | def __getitem__(self, item): 157 | queries = self.queries + [{"type": "getitem", "item": item}] 158 | return Finder([self.widgets[item]], queries, parent=self) 159 | 160 | def find(self, widget_class: Type[X] = Widget, **matches): 161 | def test(widget: Widget): 162 | if isinstance(widget, widget_class): 163 | for name, expected in matches.items(): 164 | if not hasattr(widget, name) and name.startswith("meta_"): 165 | meta_attr_name = name[5:] 166 | if hasattr(widget, "_react_meta") and meta_attr_name in widget._react_meta: # type: ignore 167 | value = widget._react_meta[meta_attr_name] # type: ignore 168 | else: 169 | return False 170 | elif hasattr(widget, name): 171 | value = getattr(widget, name) 172 | else: 173 | return False 174 | if value != expected: 175 | return False 176 | return True 177 | else: 178 | return False 179 | 180 | queries = self.queries + [{"type": "find", "cls": widget_class, "matches": matches}] 181 | return Finder[X](cast(List[X], self._walk(test)), queries, parent=self) 182 | 183 | def _walk(self, f: Callable[[Element], bool]): 184 | visited: Set[int] = set() 185 | queue: List[Widget] = list() 186 | visited = set([id(k) for k in self.widgets]) 187 | queue.extend(self.widgets) 188 | 189 | found: List[Element] = [] 190 | 191 | while queue: 192 | value = queue.pop(0) 193 | if isinstance(value, Widget) and f(value): 194 | found.append(value) 195 | else: 196 | if isinstance(value, Widget): 197 | widget = value 198 | for name in widget.keys: 199 | value = getattr(widget, name) 200 | if isinstance(value, Widget): 201 | if value not in visited: 202 | visited.add(value) 203 | queue.append(value) 204 | elif isinstance(value, (list, tuple)): 205 | for el in value: 206 | if isinstance(el, (Widget, list, dict)): 207 | if id(el) not in visited: 208 | visited.add(id(el)) 209 | queue.append(el) 210 | elif isinstance(value, dict): 211 | for name, el in value.items(): 212 | if isinstance(el, (Widget, list, dict)): 213 | if id(el) not in visited: 214 | visited.add(id(el)) 215 | queue.append(el) 216 | elif isinstance(value, list): 217 | for el in value: 218 | if isinstance(el, (Widget, list, dict)): 219 | if id(el) not in visited: 220 | visited.add(id(el)) 221 | queue.append(el) 222 | elif isinstance(value, dict): 223 | for name, el in value.items(): 224 | if isinstance(el, (Widget, list, dict)): 225 | if id(el) not in visited: 226 | visited.add(id(el)) 227 | queue.append(el) 228 | 229 | return found 230 | 231 | 232 | def finder(rc: _RenderContext): 233 | if rc.container is None: 234 | return Finder[Widget]([rc.last_root_widget]) 235 | else: 236 | return Finder[Widget](list(rc.container.children)) 237 | -------------------------------------------------------------------------------- /reacton/find_test.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | 4 | import ipywidgets as widgets 5 | import pytest 6 | 7 | import reacton as react 8 | import reacton.ipywidgets as w 9 | 10 | 11 | def test_find_by_class(): 12 | @react.component 13 | def Test(): 14 | with w.VBox() as main: 15 | with w.HBox(): 16 | w.Button(description="test") 17 | return main 18 | 19 | box, rc = react.render(Test()) 20 | assert rc.find(widgets.Button).single.widget.description == "test" 21 | 22 | 23 | def test_find_non_existing_attr(): 24 | el = w.Button(description="test") 25 | box, rc = react.render(el, handle_error=False) 26 | rc.find(widgets.Button, doesnotexist="test").assert_empty() 27 | 28 | 29 | def test_find_by_class_and_attr(): 30 | @react.component 31 | def Test(): 32 | with w.VBox() as main: 33 | with w.HBox(): 34 | w.Button(description="1") 35 | with w.VBox(): 36 | w.Button(description="2") 37 | return main 38 | 39 | box, rc = react.render(Test()) 40 | assert rc.find(widgets.Button, description="1").single.widget.description == "1" 41 | assert rc.find(widgets.Button, description="2").single.widget.description == "2" 42 | 43 | 44 | def test_find_nested(): 45 | @react.component 46 | def Test(): 47 | with w.VBox() as main: 48 | with w.VBox(): 49 | w.Button(description="1") 50 | with w.HBox(): 51 | w.Button(description="2") 52 | return main 53 | 54 | box, rc = react.render(Test()) 55 | assert rc.find(widgets.HBox).single.find(widgets.Button).single.widget.description == "2" 56 | 57 | 58 | def test_assert_matches_wait(): 59 | @react.component 60 | def Test(): 61 | with w.VBox() as main: 62 | with w.VBox(): 63 | w.Button(description="1") 64 | return main 65 | 66 | box, rc = react.render(Test()) 67 | rc.find(widgets.Button).assert_matches_wait(description="1") 68 | with pytest.raises(TimeoutError): 69 | rc.find(widgets.Button).assert_matches_wait(description="3", timeout=0.1) 70 | 71 | 72 | def test_assert_wait(): 73 | @react.component 74 | def Test(): 75 | with w.VBox() as main: 76 | with w.VBox(): 77 | w.Button(description="1") 78 | return main 79 | 80 | box, rc = react.render(Test()) 81 | rc.find(widgets.Button).assert_wait(lambda x: x.description == "1") 82 | with pytest.raises(TimeoutError): 83 | rc.find(widgets.Button).assert_wait(lambda x: x.description == "xx", timeout=0.1) 84 | 85 | 86 | def test_find_by_class_and_attr_nested(): 87 | @react.component 88 | def Test(): 89 | with w.VBox() as main: 90 | with w.HBox(box_style="SUCCESS"): 91 | w.Button(description="1", disabled=True) 92 | with w.VBox(): 93 | w.Button(description="2", disabled=False) 94 | with w.HBox(box_style="info"): 95 | w.Button(description="1", disabled=True) 96 | with w.VBox(): 97 | w.Button(description="2", disabled=False) 98 | return main 99 | 100 | box, rc = react.render(Test()) 101 | rc.find(widgets.HBox, box_style="success").find(widgets.Button, description="1").matches(description="1", disabled=True) 102 | rc.find(widgets.HBox, box_style="info").find(widgets.Button, description="2").matches(description="2", disabled=False) 103 | 104 | 105 | def test_find_by_meta_widget(): 106 | @react.component 107 | def Test(): 108 | with w.VBox() as main: 109 | with w.HBox().meta(name="a"): 110 | w.Button(description="testb").meta(name="b") 111 | w.Button(description="testc").meta(name="c") 112 | w.Button(description="testd") 113 | return main 114 | 115 | box, rc = react.render(Test()) 116 | rc.find(widgets.Widget, meta_name="a").single 117 | assert rc.find(widgets.Button, meta_name="b").widget.description == "testb" 118 | assert rc.find(widgets.Button, meta_not_exist="b").widgets == [] 119 | 120 | 121 | @react.component 122 | def ButtonLevel2(**kwargs): 123 | return w.Button(**kwargs).meta(level2="2") 124 | 125 | 126 | @react.component 127 | def ButtonLevel1(**kwargs): 128 | return ButtonLevel2(**kwargs).meta(level1="1") 129 | 130 | 131 | def test_find_by_meta_component(): 132 | @react.component 133 | def Test(): 134 | with w.VBox() as main: 135 | with w.HBox(): 136 | ButtonLevel1(description="testa").meta(name="a") 137 | ButtonLevel1(description="testb").meta(name="b") 138 | return main 139 | 140 | box, rc = react.render(Test()) 141 | assert rc.find(widgets.Button, meta_name="b").widget.description == "testb" 142 | # make sure the meta dicts get merged 143 | assert len(rc.find(widgets.Button, meta_level1="1").widgets) == 2 144 | assert len(rc.find(widgets.Button, meta_level2="2").widgets) == 2 145 | 146 | 147 | def test_find_count(): 148 | @react.component 149 | def Test(): 150 | with w.VBox() as main: 151 | w.Button(description="1") 152 | w.Button(description="2") 153 | return main 154 | 155 | box, rc = react.render(Test()) 156 | assert len(rc.find(widgets.Button, description="should-not-be-found")) == 0 157 | assert len(rc.find(widgets.Button, description="1")) == 1 158 | assert len(rc.find(widgets.Button)) == 2 159 | 160 | 161 | def test_wait_for(): 162 | set_state = None 163 | 164 | @react.component 165 | def Test(): 166 | nonlocal set_state 167 | state, set_state = react.use_state(0) 168 | with w.VBox() as main: 169 | w.Button(description="1") 170 | w.Button(description="2") 171 | if state == 1: 172 | w.Button(description="3") 173 | return main 174 | 175 | box, rc = react.render(Test(), handle_error=False) 176 | assert set_state is not None 177 | 178 | def run(): 179 | assert set_state is not None 180 | time.sleep(0.3) 181 | set_state(1) 182 | 183 | threading.Thread(target=run).start() 184 | assert len(rc.find(widgets.Button, description="should-not-be-found")) == 0 185 | assert len(rc.find(widgets.Button, description="1")) == 1 186 | assert len(rc.find(widgets.Button)) == 2 187 | finder = rc.find(widgets.Button, description="3") 188 | assert len(finder) == 0 189 | finder = finder.wait_for() 190 | assert finder.widget.description == "3" 191 | -------------------------------------------------------------------------------- /reacton/generate.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | from inspect import isclass 5 | from textwrap import indent 6 | from typing import Any, Dict, Generic, Type, TypeVar 7 | 8 | import bqplot 9 | import ipywidgets 10 | import ipywidgets as widgets # type: ignore 11 | import numpy as np 12 | import traitlets # type: ignore 13 | import traittypes 14 | from jinja2 import Template 15 | 16 | import reacton as react 17 | from reacton.core import Element 18 | 19 | from . import logging as _logging # type: ignore # noqa: F401 20 | 21 | W = TypeVar("W") # used for widgets 22 | MAX_LINE_LENGTH = 160 - 8 23 | logger = logging.getLogger("react") 24 | 25 | type_name_alias = {"ipyvue.Template.Template": "ipyvue.Template", "ipywidgets.widgets.widget.Widget": "ipywidgets.Widget"} 26 | typemap = { 27 | traitlets.traitlets.CaselessStrEnum: "str", 28 | traitlets.traitlets.Enum: "str", 29 | traittypes.traittypes.Array: "ndarray", 30 | ipywidgets.widgets.trait_types.TypedTuple: "Tuple", 31 | traitlets.traitlets.CInt: "int", 32 | traitlets.traitlets.Int: "int", 33 | traitlets.traitlets.Unicode: "str", 34 | traitlets.traitlets.CUnicode: "str", 35 | traitlets.traitlets.Bool: "bool", 36 | traitlets.traitlets.Bytes: "bytes", 37 | traitlets.traitlets.CFloat: "float", 38 | traitlets.traitlets.Float: "float", 39 | ipywidgets.widgets.trait_types.Date: "datetime.date", 40 | ipywidgets.widgets.trait_types.Color: "str", 41 | traitlets.traitlets.Any: "Any", 42 | ipywidgets.widgets.trait_types.NumberFormat: "str", 43 | bqplot.traits.Date: "datetime.datetime", 44 | tuple: "tuple", 45 | # ipywidgets.widgets.widget.Widget: "widgets.Widget",z 46 | list: "List", 47 | dict: "Dict", 48 | } 49 | 50 | 51 | default_alias = {bqplot.Map.map_data: "bqplot.Map.map_data.default"} 52 | 53 | 54 | def fix_class_name(class_name): 55 | if class_name.startswith("ipyvuetify"): 56 | parts = class_name.split(".") 57 | if parts[-1] == parts[-2]: 58 | parts = parts[:-2] + [parts[-1]] 59 | class_name = ".".join(parts) 60 | return class_name 61 | 62 | 63 | def inject_components(module, g): 64 | for cls_name in dir(module): 65 | cls = getattr(module, cls_name) 66 | if isclass(cls) and issubclass(cls, widgets.Widget): 67 | g[cls_name] = react.component(cls) 68 | 69 | 70 | class repr_wrap: 71 | def __init__(self, repr): 72 | self.repr = repr 73 | 74 | def __repr__(self): 75 | return self.repr 76 | 77 | 78 | def camel_to_underscore(name): 79 | s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name) 80 | return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower() 81 | 82 | 83 | class CodeGen(Generic[W]): 84 | element_classes: Dict[Any, Any] = {} 85 | ignore_props = "comm log keys".split() 86 | 87 | def __init__(self, modules): 88 | self.modules = modules 89 | 90 | def get_extra_argument(self, cls): 91 | return [] 92 | 93 | def has_callback(self, cls, name): 94 | return True 95 | 96 | def find_widget_classes(self, module): 97 | for cls_name in dir(module): 98 | cls = getattr(module, cls_name) 99 | if isclass(cls) and issubclass(cls, widgets.Widget): 100 | yield cls 101 | 102 | def get_element_class(self, cls): 103 | return self.element_classes.get(cls, Element) 104 | 105 | def get_ignore_props(self, cls): 106 | return self.ignore_props 107 | 108 | # Replicated from https://gist.github.com/shner-elmo/b2639a4d1e04ceafaad120acfb31213c 109 | def ruff_format(self, code: str) -> str: 110 | import subprocess 111 | from ruff.__main__ import find_ruff_bin 112 | 113 | ruff_args = [ 114 | find_ruff_bin(), 115 | "format", 116 | "--stdin-filename", 117 | "foo.py", # you can pass any random string, it wont use it... 118 | # these two lines are optional, but this is how you can pass the config for ruff 119 | "--line-length", 120 | f"{MAX_LINE_LENGTH}", 121 | ] 122 | proc = subprocess.run(ruff_args, input=code, text=True, capture_output=True) 123 | proc.check_returncode() # raise an Exception if return code is not 0 124 | return proc.stdout 125 | 126 | def generate_component(self, cls: Type[widgets.Widget], blacken=True): 127 | element_class_name = self.get_element_class(cls).__name__ 128 | ignore = self.get_ignore_props(cls) 129 | 130 | traits = {key: value for key, value in cls.class_traits().items() if "output" not in value.metadata and not key.startswith("_") and key not in ignore} 131 | 132 | def has_default(trait): 133 | default = trait.default() 134 | if isinstance(default, np.ndarray): 135 | return True 136 | return default != traitlets.Undefined 137 | 138 | def get_default(trait): 139 | if trait in default_alias: 140 | return repr_wrap(default_alias[trait]) 141 | if isinstance(trait, ipywidgets.widgets.trait_types.InstanceDict): 142 | assert trait.default_args is None 143 | assert trait.default_kwargs is None 144 | return {} 145 | if isinstance(trait, ipywidgets.widgets.trait_types.TypedTuple): 146 | if len(trait.default_args) == 0: 147 | return tuple() 148 | else: 149 | assert len(trait.default_args) == 1 150 | return trait.default_args[0] 151 | if isinstance(trait, traitlets.traitlets.Tuple): 152 | return trait.make_dynamic_default() 153 | if isinstance(trait, traitlets.traitlets.List): 154 | return trait.make_dynamic_default() 155 | if isinstance(trait, traitlets.traitlets.Dict): 156 | return trait.make_dynamic_default() 157 | if isinstance(trait, traittypes.traittypes.Array): 158 | value = trait.make_dynamic_default() 159 | if value is not None: 160 | value = value.tolist() 161 | value = repr_wrap(f"np.array({value!r})") 162 | return value 163 | if hasattr(trait, "make_dynamic_default"): 164 | if trait.default_args is None and trait.default_kwargs is None: 165 | return None 166 | else: 167 | raise ValueError( 168 | f"Cannot have default value for dynamic default, {trait} should have an instance of" 169 | "{trait.klass} with args {trait.default_args} and {trait.default_kwargs}" 170 | ) 171 | return trait.default() 172 | 173 | types = {} 174 | 175 | def get_type(trait): 176 | if isinstance(trait, ipywidgets.widgets.trait_types.TypedTuple): 177 | sub = get_type(trait._trait) 178 | return f"Sequence[{sub}]" 179 | if isinstance(trait, ipywidgets.widgets.trait_types.InstanceDict): 180 | type_name = str(trait.klass.__module__) + "." + trait.klass.__name__ 181 | type_name = type_name_alias.get(type_name, type_name) 182 | if issubclass(trait.klass, widgets.Widget): 183 | type_name = f"Element[{type_name}]" 184 | return f"Union[Dict[str, Any], {type_name}]" 185 | if isinstance(trait, traitlets.traitlets.Union): 186 | if len(trait.trait_types) == 1: 187 | return get_type(trait.trait_types[0]) 188 | subs = ", ".join([get_type(t) for t in trait.trait_types]) 189 | return f"typing.Union[{subs}]" 190 | # if isinstance(trait, traitlets.traitlets.Tuple): 191 | # TODO: we can special case this (Tuple is subclass of Instance) 192 | # but it's fixed length 193 | if isinstance(trait, traitlets.traitlets.Instance): 194 | if trait.klass.__name__ == "array": # type: ignore 195 | import pdb 196 | 197 | pdb.set_trace() 198 | if trait.klass.__module__ == "builtins": 199 | return trait.klass.__name__ # type: ignore 200 | else: 201 | type_name = str(trait.klass.__module__) + "." + trait.klass.__name__ # type: ignore 202 | type_name = type_name_alias.get(type_name, type_name) 203 | if issubclass(trait.klass, widgets.Widget): # type: ignore 204 | element_class_name_type = "Element" # self.get_element_class 205 | type_name = f"{element_class_name_type}[{type_name}]" 206 | return type_name 207 | 208 | return typemap[type(trait)] 209 | 210 | InstanceDict_fixes_list = [] 211 | for name, trait in traits.items(): 212 | if isinstance(trait, ipywidgets.widgets.trait_types.InstanceDict): 213 | component = trait.klass.__name__ 214 | if trait.klass.__module__ == "ipywidgets.widgets.widget_layout": 215 | if cls.__module__.startswith("ipywidgets"): 216 | component = "Layout" 217 | else: 218 | component = "w.Layout" 219 | InstanceDict_fixes_list.append(f"if isinstance(kwargs.get('{name}'), dict): kwargs['{name}'] = {component}(**kwargs['{name}'])") 220 | 221 | try: 222 | types[name] = get_type(trait) 223 | if types[name] == "array": 224 | import pdb 225 | 226 | pdb.set_trace() 227 | except Exception: 228 | logging.exception("Cannot find type for trait %r of %r", name, cls) 229 | raise 230 | InstanceDict_fixes = indent("\n".join(InstanceDict_fixes_list), " ").strip() 231 | 232 | traits_nodefault = {key: value for key, value in traits.items() if not has_default(value)} 233 | traits_default = {key: value for key, value in traits.items() if key not in traits_nodefault} 234 | signature_list = ["{name}".format(name=name) for name, value in traits_nodefault.items()] 235 | signature_list.extend([f"{name}: {types[name]} ={get_default(value)!r}" for name, value in traits_default.items()]) 236 | for name, trait in traits.items(): 237 | typing_type = get_type(trait) 238 | callback_type = f"typing.Callable[[{typing_type}], Any]" 239 | if self.has_callback(cls, name): 240 | signature_list.append(f"on_{name}: {callback_type}=None") 241 | for name, default, arg_type in self.get_extra_argument(cls): 242 | signature_list.append(f"{name}: {arg_type}={default!r}") 243 | too_long = len(signature_list) > 255 244 | if too_long: 245 | print("Too many arguments for %s to support Python 3.6, using **kwargs" % cls) 246 | signature_list = signature_list[:254] 247 | signature_list.append("**kwargs") 248 | signature = ", ".join(signature_list) 249 | method_name = cls.__name__ 250 | module = cls.__module__ 251 | class_name = module + "." + cls.__name__ 252 | class_name = fix_class_name(class_name) 253 | class_docstring = cls.__doc__ or "" 254 | 255 | doctraits = {name: trait for name, trait in traits.items() if "help" in trait.metadata} 256 | docargs = [{"name": name, "help": trait.metadata["help"]} for name, trait in doctraits.items()] 257 | 258 | if "v_model" in traits: 259 | element_type = f'ValueElement[{class_name}, {types["v_model"]}]' 260 | element_class_name = "ValueElement" 261 | create_element = f'{element_class_name}("v_model", comp, kwargs=kwargs)' 262 | elif "value" in traits: 263 | element_type = f'ValueElement[{class_name}, {types["value"]}]' 264 | element_class_name = "ValueElement" 265 | create_element = f'{element_class_name}("value", comp, kwargs=kwargs)' 266 | else: 267 | element_type = f"Element[{class_name}]" 268 | create_element = f"{element_class_name}(comp, kwargs=kwargs)" 269 | 270 | docstring_args_template = Template( 271 | """ 272 | {% for arg in docargs %} 273 | :param {{ arg.name }}: {{ arg.help }}{% endfor %} 274 | """ 275 | ) 276 | docstring_args = docstring_args_template.render(docargs=docargs) 277 | docstring_args = indent(docstring_args, " ").strip() 278 | 279 | code_method = Template( 280 | """ 281 | 282 | def _{{ method_name }}({{ signature }}) -> {{element_type}}: 283 | \"\"\"{{class_docstring}} 284 | {{docstring_args}} 285 | \"\"\" 286 | ... 287 | 288 | @implements(_{{ method_name }}) 289 | def {{ method_name }}(**kwargs): 290 | {{InstanceDict_fixes}} 291 | widget_cls = {{class_name}} 292 | comp = reacton.core.ComponentWidget(widget=widget_cls) 293 | return {{create_element}} 294 | 295 | 296 | del _{{ method_name }} 297 | 298 | """ 299 | ) 300 | 301 | code = code_method.render( 302 | element_class_name=element_class_name, 303 | method_name=method_name, 304 | class_name=class_name, 305 | signature=signature, 306 | docstring_args=docstring_args, 307 | class_docstring=class_docstring, 308 | InstanceDict_fixes=InstanceDict_fixes, 309 | element_type=element_type, 310 | create_element=create_element, 311 | ) 312 | 313 | if blacken: 314 | try: 315 | code = self.ruff_format(code) 316 | except Exception: 317 | print("code:\n", code) 318 | raise 319 | return code 320 | 321 | def generate(self, path, blacken=True): 322 | code_snippets = [] 323 | found = set() 324 | for module in self.modules: 325 | for cls in self.find_widget_classes(module): 326 | if cls not in found: 327 | found.add(cls) 328 | if cls != widgets.Widget: 329 | # extra_arguments = getattr(module_output, "extra_arguments", {}).get(cls, []) 330 | # element_class = getattr(module_output, "element_classes", {}).get(cls, Element) 331 | code = self.generate_component(cls, blacken=blacken) 332 | code_snippets.append(code) 333 | code = ("\n").join(code_snippets) 334 | with open(path) as f: 335 | current_code = f.read() 336 | marker = "# generated code:" 337 | start = current_code.find(marker) 338 | if start == -1: 339 | raise ValueError(f"Could not find marker: {marker!r}") 340 | start = current_code.find("\n", start) 341 | if start == -1: 342 | raise ValueError(f"Could not find new line after marker: {marker!r}") 343 | code_total = current_code[: start + 1] + "\n" + code 344 | if blacken: 345 | code_total = self.ruff_format(code_total) 346 | only_valid = True 347 | if only_valid: 348 | try: 349 | exec(code_total, globals()) 350 | except Exception as exception: 351 | print(code_total) 352 | logger.exception("Did not generate correct code") 353 | print(exception) 354 | return 355 | with open(path, "w") as f: 356 | print(code_total, file=f, end="") 357 | os.system(f"mypy {path}") 358 | 359 | 360 | def generate(path, modules, blacken=True): 361 | CodeGen(modules).generate(path, blacken=blacken) 362 | # print(code) 363 | -------------------------------------------------------------------------------- /reacton/generate_test.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import ipywidgets as widgets 3 | import traitlets 4 | import pytest 5 | 6 | from .generate import CodeGen 7 | 8 | 9 | @pytest.mark.skipif(sys.version_info < (3, 7), reason="Ruff does not support Python < 3.7") 10 | def test_basic(): 11 | class MyTest(traitlets.HasTraits): 12 | a = traitlets.traitlets.Int(1) 13 | b = traitlets.traitlets.Int() 14 | 15 | gen = CodeGen[widgets.Widget]([]) 16 | code = gen.generate_component(MyTest).strip() 17 | 18 | code_expected = ''' 19 | def _MyTest( 20 | a: int = 1, b: int = 0, on_a: typing.Callable[[int], Any] = None, on_b: typing.Callable[[int], Any] = None 21 | ) -> Element[reacton.generate_test.MyTest]: 22 | """ """ 23 | ... 24 | 25 | 26 | @implements(_MyTest) 27 | def MyTest(**kwargs): 28 | widget_cls = reacton.generate_test.MyTest 29 | comp = reacton.core.ComponentWidget(widget=widget_cls) 30 | return Element(comp, kwargs=kwargs) 31 | 32 | 33 | del _MyTest 34 | ''' 35 | assert code.strip() == code_expected.strip() 36 | 37 | 38 | @pytest.mark.skipif(sys.version_info < (3, 7), reason="Ruff does not support Python < 3.7") 39 | def test_value(): 40 | class MyTest(traitlets.HasTraits): 41 | a = traitlets.traitlets.Int(1) 42 | value = traitlets.traitlets.Int() 43 | 44 | gen = CodeGen[widgets.Widget]([]) 45 | code = gen.generate_component(MyTest).strip() 46 | 47 | code_expected = ''' 48 | def _MyTest( 49 | a: int = 1, value: int = 0, on_a: typing.Callable[[int], Any] = None, on_value: typing.Callable[[int], Any] = None 50 | ) -> ValueElement[reacton.generate_test.MyTest, int]: 51 | """ """ 52 | ... 53 | 54 | 55 | @implements(_MyTest) 56 | def MyTest(**kwargs): 57 | widget_cls = reacton.generate_test.MyTest 58 | comp = reacton.core.ComponentWidget(widget=widget_cls) 59 | return ValueElement("value", comp, kwargs=kwargs) 60 | 61 | 62 | del _MyTest 63 | ''' 64 | assert code.strip() == code_expected.strip() 65 | 66 | 67 | @pytest.mark.skipif(sys.version_info < (3, 7), reason="Ruff does not support Python < 3.7") 68 | def test_instance_non_widget(): 69 | class NonWidget: 70 | def __init__(self, *args) -> None: 71 | pass 72 | 73 | class MyTest(traitlets.HasTraits): 74 | a = traitlets.traitlets.Instance(NonWidget) 75 | 76 | _ = MyTest() 77 | 78 | gen = CodeGen[widgets.Widget]([]) 79 | code = gen.generate_component(MyTest).strip() 80 | 81 | code_expected = ''' 82 | def _MyTest( 83 | a: reacton.generate_test.NonWidget = None, on_a: typing.Callable[[reacton.generate_test.NonWidget], Any] = None 84 | ) -> Element[reacton.generate_test.MyTest]: 85 | """ """ 86 | ... 87 | 88 | 89 | @implements(_MyTest) 90 | def MyTest(**kwargs): 91 | widget_cls = reacton.generate_test.MyTest 92 | comp = reacton.core.ComponentWidget(widget=widget_cls) 93 | return Element(comp, kwargs=kwargs) 94 | 95 | 96 | del _MyTest''' 97 | assert code.strip() == code_expected.strip() 98 | 99 | 100 | @pytest.mark.skipif(sys.version_info < (3, 7), reason="Ruff does not support Python < 3.7") 101 | def test_instance_widget(): 102 | class SomeWidget(widgets.Widget): 103 | def __init__(self, *args) -> None: 104 | pass 105 | 106 | class MyTest(traitlets.HasTraits): 107 | a = traitlets.traitlets.Instance(SomeWidget) 108 | 109 | gen = CodeGen[widgets.Widget]([]) 110 | code = gen.generate_component(MyTest).strip() 111 | 112 | code_expected = ''' 113 | def _MyTest( 114 | a: Element[reacton.generate_test.SomeWidget] = None, on_a: typing.Callable[[Element[reacton.generate_test.SomeWidget]], Any] = None 115 | ) -> Element[reacton.generate_test.MyTest]: 116 | """ """ 117 | ... 118 | 119 | 120 | @implements(_MyTest) 121 | def MyTest(**kwargs): 122 | widget_cls = reacton.generate_test.MyTest 123 | comp = reacton.core.ComponentWidget(widget=widget_cls) 124 | return Element(comp, kwargs=kwargs) 125 | 126 | 127 | del _MyTest 128 | ''' 129 | assert code.strip() == code_expected.strip() 130 | 131 | 132 | def test_skip_defaults(): 133 | from .ipywidgets import Accordion, Button 134 | 135 | el = Accordion() 136 | assert el.kwargs == {} 137 | 138 | button = Button() 139 | assert button.kwargs == {} 140 | -------------------------------------------------------------------------------- /reacton/ipycanvas.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from typing import Any, Dict, Union 3 | 4 | import ipycanvas 5 | import ipywidgets 6 | 7 | import reacton 8 | from reacton.core import Element 9 | 10 | from . import ipywidgets as w 11 | from .utils import implements 12 | 13 | if __name__ == "__main__": 14 | from .generate import generate 15 | 16 | generate(__file__, [ipycanvas]) 17 | 18 | 19 | # generated code: 20 | 21 | 22 | def _Canvas( 23 | direction: str = "inherit", 24 | fill_style: typing.Union[str, Element[ipycanvas.canvas._CanvasGradient], Element[ipycanvas.canvas.Pattern]] = "black", 25 | filter: str = "none", 26 | font: str = "12px serif", 27 | global_alpha: float = 1.0, 28 | global_composite_operation: str = "source-over", 29 | height: int = 500, 30 | image_data: bytes = None, 31 | layout: Union[Dict[str, Any], Element[ipywidgets.widgets.widget_layout.Layout]] = {}, 32 | line_cap: str = "butt", 33 | line_dash_offset: float = 0.0, 34 | line_join: str = "miter", 35 | line_width: float = 1.0, 36 | miter_limit: float = 10.0, 37 | shadow_blur: float = 0.0, 38 | shadow_color: str = "rgba(0, 0, 0, 0)", 39 | shadow_offset_x: float = 0.0, 40 | shadow_offset_y: float = 0.0, 41 | stroke_style: typing.Union[str, Element[ipycanvas.canvas._CanvasGradient], Element[ipycanvas.canvas.Pattern]] = "black", 42 | sync_image_data: bool = False, 43 | text_align: str = "start", 44 | text_baseline: str = "alphabetic", 45 | width: int = 700, 46 | on_direction: typing.Callable[[str], Any] = None, 47 | on_fill_style: typing.Callable[[typing.Union[str, Element[ipycanvas.canvas._CanvasGradient], Element[ipycanvas.canvas.Pattern]]], Any] = None, 48 | on_filter: typing.Callable[[str], Any] = None, 49 | on_font: typing.Callable[[str], Any] = None, 50 | on_global_alpha: typing.Callable[[float], Any] = None, 51 | on_global_composite_operation: typing.Callable[[str], Any] = None, 52 | on_height: typing.Callable[[int], Any] = None, 53 | on_image_data: typing.Callable[[bytes], Any] = None, 54 | on_layout: typing.Callable[[Union[Dict[str, Any], Element[ipywidgets.widgets.widget_layout.Layout]]], Any] = None, 55 | on_line_cap: typing.Callable[[str], Any] = None, 56 | on_line_dash_offset: typing.Callable[[float], Any] = None, 57 | on_line_join: typing.Callable[[str], Any] = None, 58 | on_line_width: typing.Callable[[float], Any] = None, 59 | on_miter_limit: typing.Callable[[float], Any] = None, 60 | on_shadow_blur: typing.Callable[[float], Any] = None, 61 | on_shadow_color: typing.Callable[[str], Any] = None, 62 | on_shadow_offset_x: typing.Callable[[float], Any] = None, 63 | on_shadow_offset_y: typing.Callable[[float], Any] = None, 64 | on_stroke_style: typing.Callable[[typing.Union[str, Element[ipycanvas.canvas._CanvasGradient], Element[ipycanvas.canvas.Pattern]]], Any] = None, 65 | on_sync_image_data: typing.Callable[[bool], Any] = None, 66 | on_text_align: typing.Callable[[str], Any] = None, 67 | on_text_baseline: typing.Callable[[str], Any] = None, 68 | on_width: typing.Callable[[int], Any] = None, 69 | ) -> Element[ipycanvas.canvas.Canvas]: 70 | """Create a Canvas widget. 71 | 72 | Args: 73 | width (int): The width (in pixels) of the canvas 74 | height (int): The height (in pixels) of the canvas 75 | 76 | 77 | """ 78 | ... 79 | 80 | 81 | @implements(_Canvas) 82 | def Canvas(**kwargs): 83 | if isinstance(kwargs.get("layout"), dict): 84 | kwargs["layout"] = w.Layout(**kwargs["layout"]) 85 | widget_cls = ipycanvas.canvas.Canvas 86 | comp = reacton.core.ComponentWidget(widget=widget_cls) 87 | return Element(comp, kwargs=kwargs) 88 | 89 | 90 | del _Canvas 91 | 92 | 93 | def _MultiCanvas( 94 | height: int = 500, 95 | image_data: bytes = None, 96 | layout: Union[Dict[str, Any], Element[ipywidgets.widgets.widget_layout.Layout]] = {}, 97 | sync_image_data: bool = False, 98 | width: int = 700, 99 | on_height: typing.Callable[[int], Any] = None, 100 | on_image_data: typing.Callable[[bytes], Any] = None, 101 | on_layout: typing.Callable[[Union[Dict[str, Any], Element[ipywidgets.widgets.widget_layout.Layout]]], Any] = None, 102 | on_sync_image_data: typing.Callable[[bool], Any] = None, 103 | on_width: typing.Callable[[int], Any] = None, 104 | ) -> Element[ipycanvas.canvas.MultiCanvas]: 105 | """Create a MultiCanvas widget with n_canvases Canvas widgets. 106 | 107 | Args: 108 | n_canvases (int): The number of canvases to create 109 | width (int): The width (in pixels) of the canvases 110 | height (int): The height (in pixels) of the canvases 111 | 112 | 113 | """ 114 | ... 115 | 116 | 117 | @implements(_MultiCanvas) 118 | def MultiCanvas(**kwargs): 119 | if isinstance(kwargs.get("layout"), dict): 120 | kwargs["layout"] = w.Layout(**kwargs["layout"]) 121 | widget_cls = ipycanvas.canvas.MultiCanvas 122 | comp = reacton.core.ComponentWidget(widget=widget_cls) 123 | return Element(comp, kwargs=kwargs) 124 | 125 | 126 | del _MultiCanvas 127 | 128 | 129 | def _MultiRoughCanvas( 130 | height: int = 500, 131 | image_data: bytes = None, 132 | layout: Union[Dict[str, Any], Element[ipywidgets.widgets.widget_layout.Layout]] = {}, 133 | sync_image_data: bool = False, 134 | width: int = 700, 135 | on_height: typing.Callable[[int], Any] = None, 136 | on_image_data: typing.Callable[[bytes], Any] = None, 137 | on_layout: typing.Callable[[Union[Dict[str, Any], Element[ipywidgets.widgets.widget_layout.Layout]]], Any] = None, 138 | on_sync_image_data: typing.Callable[[bool], Any] = None, 139 | on_width: typing.Callable[[int], Any] = None, 140 | ) -> Element[ipycanvas.canvas.MultiRoughCanvas]: 141 | """Create a MultiRoughCanvas widget with n_canvases RoughCanvas widgets. 142 | 143 | Args: 144 | n_canvases (int): The number of rough canvases to create 145 | width (int): The width (in pixels) of the canvases 146 | height (int): The height (in pixels) of the canvases 147 | 148 | 149 | """ 150 | ... 151 | 152 | 153 | @implements(_MultiRoughCanvas) 154 | def MultiRoughCanvas(**kwargs): 155 | if isinstance(kwargs.get("layout"), dict): 156 | kwargs["layout"] = w.Layout(**kwargs["layout"]) 157 | widget_cls = ipycanvas.canvas.MultiRoughCanvas 158 | comp = reacton.core.ComponentWidget(widget=widget_cls) 159 | return Element(comp, kwargs=kwargs) 160 | 161 | 162 | del _MultiRoughCanvas 163 | 164 | 165 | def _Path2D(value: str = "", on_value: typing.Callable[[str], Any] = None) -> Element[ipycanvas.canvas.Path2D]: 166 | """Create a Path2D. 167 | 168 | Args: 169 | value (str): The path value, e.g. "M10 10 h 80 v 80 h -80 Z" 170 | 171 | 172 | """ 173 | ... 174 | 175 | 176 | @implements(_Path2D) 177 | def Path2D(**kwargs): 178 | widget_cls = ipycanvas.canvas.Path2D 179 | comp = reacton.core.ComponentWidget(widget=widget_cls) 180 | return Element(comp, kwargs=kwargs) 181 | 182 | 183 | del _Path2D 184 | 185 | 186 | def _RoughCanvas( 187 | bowing: float = 1, 188 | direction: str = "inherit", 189 | fill_style: typing.Union[str, Element[ipycanvas.canvas._CanvasGradient], Element[ipycanvas.canvas.Pattern]] = "black", 190 | filter: str = "none", 191 | font: str = "12px serif", 192 | global_alpha: float = 1.0, 193 | global_composite_operation: str = "source-over", 194 | height: int = 500, 195 | image_data: bytes = None, 196 | layout: Union[Dict[str, Any], Element[ipywidgets.widgets.widget_layout.Layout]] = {}, 197 | line_cap: str = "butt", 198 | line_dash_offset: float = 0.0, 199 | line_join: str = "miter", 200 | line_width: float = 1.0, 201 | miter_limit: float = 10.0, 202 | rough_fill_style: str = "hachure", 203 | roughness: float = 1, 204 | shadow_blur: float = 0.0, 205 | shadow_color: str = "rgba(0, 0, 0, 0)", 206 | shadow_offset_x: float = 0.0, 207 | shadow_offset_y: float = 0.0, 208 | stroke_style: typing.Union[str, Element[ipycanvas.canvas._CanvasGradient], Element[ipycanvas.canvas.Pattern]] = "black", 209 | sync_image_data: bool = False, 210 | text_align: str = "start", 211 | text_baseline: str = "alphabetic", 212 | width: int = 700, 213 | on_bowing: typing.Callable[[float], Any] = None, 214 | on_direction: typing.Callable[[str], Any] = None, 215 | on_fill_style: typing.Callable[[typing.Union[str, Element[ipycanvas.canvas._CanvasGradient], Element[ipycanvas.canvas.Pattern]]], Any] = None, 216 | on_filter: typing.Callable[[str], Any] = None, 217 | on_font: typing.Callable[[str], Any] = None, 218 | on_global_alpha: typing.Callable[[float], Any] = None, 219 | on_global_composite_operation: typing.Callable[[str], Any] = None, 220 | on_height: typing.Callable[[int], Any] = None, 221 | on_image_data: typing.Callable[[bytes], Any] = None, 222 | on_layout: typing.Callable[[Union[Dict[str, Any], Element[ipywidgets.widgets.widget_layout.Layout]]], Any] = None, 223 | on_line_cap: typing.Callable[[str], Any] = None, 224 | on_line_dash_offset: typing.Callable[[float], Any] = None, 225 | on_line_join: typing.Callable[[str], Any] = None, 226 | on_line_width: typing.Callable[[float], Any] = None, 227 | on_miter_limit: typing.Callable[[float], Any] = None, 228 | on_rough_fill_style: typing.Callable[[str], Any] = None, 229 | on_roughness: typing.Callable[[float], Any] = None, 230 | on_shadow_blur: typing.Callable[[float], Any] = None, 231 | on_shadow_color: typing.Callable[[str], Any] = None, 232 | on_shadow_offset_x: typing.Callable[[float], Any] = None, 233 | on_shadow_offset_y: typing.Callable[[float], Any] = None, 234 | on_stroke_style: typing.Callable[[typing.Union[str, Element[ipycanvas.canvas._CanvasGradient], Element[ipycanvas.canvas.Pattern]]], Any] = None, 235 | on_sync_image_data: typing.Callable[[bool], Any] = None, 236 | on_text_align: typing.Callable[[str], Any] = None, 237 | on_text_baseline: typing.Callable[[str], Any] = None, 238 | on_width: typing.Callable[[int], Any] = None, 239 | ) -> Element[ipycanvas.canvas.RoughCanvas]: 240 | """Create a RoughCanvas widget. It gives a hand-drawn-like style to your drawings. 241 | 242 | Args: 243 | width (int): The width (in pixels) of the canvas 244 | height (int): The height (in pixels) of the canvas 245 | 246 | 247 | """ 248 | ... 249 | 250 | 251 | @implements(_RoughCanvas) 252 | def RoughCanvas(**kwargs): 253 | if isinstance(kwargs.get("layout"), dict): 254 | kwargs["layout"] = w.Layout(**kwargs["layout"]) 255 | widget_cls = ipycanvas.canvas.RoughCanvas 256 | comp = reacton.core.ComponentWidget(widget=widget_cls) 257 | return Element(comp, kwargs=kwargs) 258 | 259 | 260 | del _RoughCanvas 261 | -------------------------------------------------------------------------------- /reacton/ipyvue.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, cast 2 | 3 | import ipyvue 4 | 5 | import reacton as react 6 | from reacton.core import get_render_context 7 | 8 | 9 | def use_event(el: react.core.Element, event_and_modifiers, callback: Callable[[Any], Any]): 10 | # to avoid add_event_handler having a stale reference to callback 11 | callback_ref = react.use_ref(callback) 12 | callback_ref.current = callback 13 | 14 | def add_event_handler(): 15 | vue_widget = cast(ipyvue.VueWidget, react.core.get_widget(el)) 16 | # we are basically copying the logic from reacton.core._event_handler_exception_wrapper 17 | rc = get_render_context() 18 | context = rc.context 19 | assert context is not None 20 | 21 | def handler(*args): 22 | try: 23 | callback_ref.current(*args) 24 | except Exception as e: 25 | assert context is not None 26 | # because widgets don't have a context, but are a child of a component 27 | # we add it to exceptions_children, not exception_self 28 | # this allows a component to catch the exception of a direct child 29 | context.exceptions_children.append(e) 30 | rc.force_update() 31 | 32 | vue_widget.on_event(event_and_modifiers, handler) 33 | 34 | def cleanup(): 35 | vue_widget.on_event(event_and_modifiers, handler, remove=True) 36 | 37 | return cleanup 38 | 39 | react.use_effect(add_event_handler, [event_and_modifiers]) 40 | -------------------------------------------------------------------------------- /reacton/ipyvuetify.py: -------------------------------------------------------------------------------- 1 | # for backward compatibility 2 | from .ipyvue import use_event # noqa: F401 3 | 4 | try: 5 | from ipyvuetify.components import * # type: ignore # noqa: F401, F403 6 | except ModuleNotFoundError: 7 | from reacton.deprecated.ipyvuetify import * # type: ignore # noqa: F401, F403 8 | -------------------------------------------------------------------------------- /reacton/logging.py: -------------------------------------------------------------------------------- 1 | """Sets up logging for react. 2 | 3 | See `configuration of logging `_ how to configure logging. 4 | 5 | """ 6 | 7 | import logging 8 | import os 9 | from typing import Optional 10 | 11 | logger = logging.getLogger("reacton") 12 | log_handler: Optional[logging.Handler] = None 13 | 14 | 15 | def set_log_level(loggers=["reacton"], level=logging.DEBUG): 16 | """set log level to debug""" 17 | for logger in loggers: 18 | logging.getLogger(logger).setLevel(level) 19 | 20 | 21 | def remove_handler(): 22 | """Disabled logging, remove default hander and add null handler""" 23 | if log_handler is not None: 24 | logging.getLogger("reacton").removeHandler(log_handler) 25 | logging.getLogger("reacton").addHandler(logging.NullHandler()) 26 | 27 | 28 | def reset(): 29 | """Reset configuration of logging (i.e. remove the default handler)""" 30 | if log_handler is not None: 31 | logging.getLogger("reacton").removeHandler(log_handler) 32 | 33 | 34 | def _set_log_level(conf, level): 35 | if conf: 36 | if conf.startswith("reacton"): 37 | set_log_level(conf.split(","), level=level) 38 | else: 39 | set_log_level(level=level) 40 | 41 | 42 | def setup(): 43 | """Setup logging based on the configuration in ``react.settings`` 44 | 45 | This function is automatically called when importing react. If settings are changed, call :func:`reset` and this function again 46 | to re-apply the settings. 47 | """ 48 | global log_handler 49 | applied_setup = False 50 | 51 | def apply_setup(): 52 | log_handler = logging.StreamHandler() 53 | 54 | # create formatter 55 | formatter = logging.Formatter("%(levelname)s:%(threadName)s:%(name)s:%(message)s") 56 | 57 | # add formatter to console handler 58 | log_handler.setFormatter(formatter) 59 | 60 | # from rich.logging import RichHandler 61 | # log_handler = RichHandler() 62 | 63 | # # add console handler to logger 64 | logger.addHandler(log_handler) 65 | 66 | postfix_to_level = { 67 | "ERROR": logging.ERROR, 68 | "WARNING": logging.WARNING, 69 | "INFO": logging.INFO, 70 | "DEBUG": logging.DEBUG, 71 | } 72 | 73 | for name, level in postfix_to_level.items(): 74 | key = f"REACTON_LOGGING_{name}" 75 | value = os.environ.get(key, "") 76 | if value: 77 | if not applied_setup: 78 | apply_setup() 79 | applied_setup = True 80 | _set_log_level(value, level) 81 | 82 | # logging.getLogger("reacton").setLevel(logging.ERROR) 83 | # _set_log_level(react.settings.main.logging.error, logging.ERROR) 84 | # _set_log_level(react.settings.main.logging.warning, logging.WARNING) 85 | # _set_log_level(react.settings.main.logging.info, logging.INFO) 86 | # _set_log_level(react.settings.main.logging.debug, logging.DEBUG) 87 | # # reactonIVE_DEBUG behaves similar to reactonIVE_LOGGING_DEBUG, but has more effect 88 | DEBUG_MODE = os.environ.get("REACTON_DEBUG", "") 89 | if DEBUG_MODE: 90 | if not applied_setup: 91 | apply_setup() 92 | applied_setup = True 93 | _set_log_level(DEBUG_MODE, logging.DEBUG) 94 | 95 | 96 | setup() 97 | -------------------------------------------------------------------------------- /reacton/patch.py: -------------------------------------------------------------------------------- 1 | import traitlets 2 | 3 | 4 | class Callable(traitlets.traitlets.TraitType): 5 | info_text = "a callable" 6 | 7 | def validate(self, obj, value): 8 | if callable(value): 9 | return value 10 | else: 11 | self.error(obj, value) 12 | 13 | 14 | # for py36 we are stuck with a version that does not have this 15 | if not hasattr(traitlets.traitlets, "Callable"): 16 | traitlets.traitlets.Callable = Callable # type: ignore 17 | traitlets.Callable = Callable # type: ignore 18 | 19 | if not hasattr(traitlets.traitlets.TraitType, "default"): 20 | 21 | def default(self, obj=None): 22 | """The default generator for this trait 23 | Notes 24 | ----- 25 | This method is registered to HasTraits classes during ``class_init`` 26 | in the same way that dynamic defaults defined by ``@default`` are. 27 | """ 28 | if self.default_value is not traitlets.traitlets.Undefined: 29 | return self.default_value 30 | elif hasattr(self, "make_dynamic_default"): 31 | return self.make_dynamic_default() # type:ignore[attr-defined] 32 | else: 33 | # Undefined will raise in TraitType.get 34 | return self.default_value 35 | 36 | traitlets.traitlets.TraitType.default = default # type: ignore 37 | -------------------------------------------------------------------------------- /reacton/patch_display.py: -------------------------------------------------------------------------------- 1 | from IPython.core.formatters import BaseFormatter 2 | from IPython.core.interactiveshell import InteractiveShell 3 | 4 | 5 | def publish(data, metadata=None, *args, **kwargs): 6 | """Will intercept a display call and add the display data to an output widget when in a reacton context/render function.""" 7 | from .core import get_render_context 8 | 9 | assert original_display_publisher_publish is not None 10 | 11 | rc = get_render_context(required=False) 12 | # only during the render phase we want to capture the display calls 13 | # during the reconsolidation phase we want to let the original display publisher do its thing 14 | # such as adding it to a output widget 15 | if rc is not None and not rc.reconsolidating: 16 | from .ipywidgets import Output 17 | 18 | Output(outputs=[{"output_type": "display_data", "data": data, "metadata": metadata}]) 19 | else: 20 | return original_display_publisher_publish(data, metadata, *args, **kwargs) 21 | 22 | 23 | class ReactonDisplayFormatter(BaseFormatter): 24 | """Add direct support for adding elements to a container. 25 | 26 | Example: 27 | 28 | with w.VBox(): 29 | display(button) 30 | 31 | """ 32 | 33 | def __call__(self, obj): 34 | assert ipython_display_formatter_original is not None 35 | from .core import Element, get_render_context # noqa 36 | 37 | rc = get_render_context(required=False) 38 | if rc is not None: 39 | if rc.container_adders: 40 | if isinstance(obj, Element): 41 | # add directly as a child 42 | rc.container_adders[-1].add(obj) 43 | return True # we handled it 44 | return ipython_display_formatter_original(obj) 45 | 46 | 47 | patched = False 48 | ipython_display_formatter_original = None 49 | original_display_publisher_publish = None 50 | 51 | 52 | def patch(): 53 | global patched, ipython_display_formatter_original, original_display_publisher_publish 54 | if patched: 55 | return 56 | patched = True 57 | shell = InteractiveShell.instance() 58 | ipython_display_formatter_original = shell.display_formatter.ipython_display_formatter # type: ignore 59 | original_display_publisher_publish = shell.display_pub.publish 60 | assert shell.display_formatter is not None 61 | shell.display_formatter.ipython_display_formatter = ReactonDisplayFormatter() # type: ignore 62 | shell.display_pub.publish = publish # type: ignore 63 | 64 | 65 | if InteractiveShell.initialized(): 66 | patch() 67 | -------------------------------------------------------------------------------- /reacton/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/widgetti/reacton/51e6eda379e0570bb94f0c8af1284d2fef0bdc8e/reacton/py.typed -------------------------------------------------------------------------------- /reacton/rx.py: -------------------------------------------------------------------------------- 1 | import reactivex 2 | 3 | from . import use_effect, use_state 4 | 5 | 6 | def use_observable_state(obserable: reactivex.Observable, initial_valie): 7 | value, set_value = use_state(initial_valie) 8 | 9 | def init(): 10 | d = obserable.subscribe(on_next=set_value, on_error=print) 11 | # d = obserable.on_next(set_value) 12 | return d.dispose 13 | 14 | use_effect(init, []) 15 | return value 16 | -------------------------------------------------------------------------------- /reacton/test.vue: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/widgetti/reacton/51e6eda379e0570bb94f0c8af1284d2fef0bdc8e/reacton/test.vue -------------------------------------------------------------------------------- /reacton/test_rx.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa: E402 2 | import pytest 3 | 4 | pytest.importorskip("reactivex") 5 | import reactivex.subject 6 | 7 | import reacton as react 8 | import reacton.rx as iprx 9 | 10 | from . import ipywidgets as w 11 | from . import logging # noqa: F401 12 | 13 | 14 | def test_basic(): 15 | source = reactivex.subject.BehaviorSubject("a") 16 | 17 | @react.component 18 | def Test(): 19 | label = iprx.use_observable_state(source, "b") 20 | return w.Button(description=label) 21 | 22 | box = react.make(Test()) 23 | assert box.children[0].description == "a" 24 | source.on_next("Hi") 25 | assert box.children[0].description == "Hi" 26 | source.on_next("Py") 27 | assert box.children[0].description == "Py" 28 | -------------------------------------------------------------------------------- /reacton/utils.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import inspect 3 | import sys 4 | import threading 5 | import types 6 | from typing import Callable, TypeVar, cast 7 | 8 | import ipywidgets as widgets 9 | import typing_extensions 10 | 11 | P = typing_extensions.ParamSpec("P") 12 | T = TypeVar("T") 13 | 14 | 15 | def without_default(func: Callable, kwargs): 16 | sig = inspect.signature(func) 17 | {name: param.default for name, param in sig.parameters.items()} 18 | non_default_kwargs = {name: kwargs[name] for name, param in sig.parameters.items() if kwargs[name] is not param.default} 19 | return non_default_kwargs 20 | 21 | 22 | def implements(f: Callable[P, T]): 23 | def caster(fimpl: Callable) -> Callable[P, T]: 24 | # wraps gives us the right signature at runtime (such as Jupyter) 25 | return cast(Callable[P, T], functools.wraps(f)(fimpl)) 26 | 27 | return caster 28 | 29 | 30 | def wrap(mod, globals): 31 | from .core import component 32 | 33 | for cls_name in dir(mod): 34 | cls = getattr(mod, cls_name) 35 | if inspect.isclass(cls) and issubclass(cls, widgets.Widget): 36 | globals[cls_name] = component(cls) 37 | 38 | 39 | def equals(a, b): 40 | from reacton.core import Element, same_component 41 | 42 | if a is b: 43 | return True 44 | # ignore E721 for now 45 | if type(a) != type(b): # noqa: E721 # is this always true? after a == b failed? 46 | return False 47 | if isinstance(a, Element): 48 | return same_component(a.component, b.component) and equals(a.args, b.args) and equals(a.kwargs, b.kwargs) 49 | elif isinstance(a, types.FunctionType) and isinstance(b, types.FunctionType): 50 | if a.__code__ != b.__code__: 51 | return False 52 | if not equals(a.__defaults__, b.__defaults__): 53 | return False 54 | if not equals(a.__kwdefaults__, b.__kwdefaults__): 55 | return False 56 | # comparing the closure is tricky, because the cells are not comparable 57 | if a.__closure__ is None and b.__closure__ is None: 58 | # easy case, both have no closure 59 | return True 60 | elif a.__closure__ is None or b.__closure__ is None: 61 | # one has a closure, the other not 62 | return False 63 | else: 64 | # both have a closure 65 | for cell_a, cell_b in zip(a.__closure__, b.__closure__): 66 | if not (equals(cell_a, cell_b) or equals(cell_a.cell_contents, cell_b.cell_contents)): 67 | return False 68 | return True 69 | elif isinstance(a, dict) and isinstance(b, dict): 70 | if len(a) != len(b): 71 | return False 72 | for key in a: 73 | if key not in b: 74 | return False 75 | if not equals(a[key], b[key]): 76 | return False 77 | return True 78 | elif isinstance(a, (list, tuple)) and isinstance(b, (list, tuple)): 79 | if len(a) != len(b): 80 | return False 81 | for i in range(len(a)): 82 | if not equals(a[i], b[i]): 83 | return False 84 | return True 85 | try: 86 | return bool(a == b) 87 | except Exception: 88 | pass 89 | return False 90 | 91 | 92 | def import_item(name: str): 93 | """Import an object by name like pandas.DataFrame if the module is imported, else return None""" 94 | parts = name.rsplit(".", 1) 95 | if len(parts) == 1: 96 | return sys.modules.get(name) 97 | else: 98 | module = sys.modules.get(parts[0]) 99 | if module is None: 100 | return None 101 | return getattr(module, parts[-1]) 102 | 103 | 104 | def isinstance_lazy(value, types): 105 | if not isinstance(types, (list, tuple)): 106 | types = [types] 107 | types = [import_item(t) if isinstance(t, str) else t for t in types] 108 | for type in types: 109 | if type is not None and isinstance(value, type): 110 | return True 111 | return False 112 | 113 | 114 | def dataframe_fingerprint(df): 115 | return {"index": id(df.index), **{column: id(df[column]) for column in df.columns}} 116 | 117 | 118 | def not_equals(a, b): 119 | return a != b 120 | if a is None and b is not None: 121 | return True 122 | if a is not None and b is None: 123 | return True 124 | if a is b: 125 | return False 126 | 127 | def numpyish(obj): 128 | import sys 129 | 130 | if "pandas" in sys.modules: 131 | import pandas as pd 132 | 133 | if isinstance(obj, pd.Series): 134 | return True 135 | if "numpy" in sys.modules: 136 | import numpy as np 137 | 138 | if isinstance(obj, np.ndarray): 139 | return True 140 | return False 141 | 142 | if numpyish(a) or numpyish(b): 143 | return (a != b).any() 144 | else: 145 | return a != b 146 | 147 | 148 | def environment() -> str: 149 | try: 150 | module = get_ipython().__module__ # type: ignore 151 | shell = get_ipython().__class__.__name__ # type: ignore 152 | except NameError: 153 | return "python" # Probably standard Python interpreter 154 | else: 155 | if module == "google.colab._shell": 156 | return "colab" 157 | elif shell == "ZMQInteractiveShell": 158 | return "jupyter" # Jupyter notebook, lab or qtconsole 159 | elif shell == "TerminalInteractiveShell": 160 | return "ipython" # Terminal running IPython 161 | else: 162 | return "unknown" # Other type 163 | 164 | 165 | class ThreadSafeCounter: 166 | def __init__(self): 167 | self._value = 0 168 | self._lock = threading.Lock() 169 | 170 | def current(self): 171 | return self._value 172 | 173 | def increment(self): 174 | with self._lock: 175 | self._value += 1 176 | return self._value 177 | 178 | def decrement(self): 179 | with self._lock: 180 | self._value -= 1 181 | return self._value 182 | -------------------------------------------------------------------------------- /reacton/utils_test.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | 5 | import reacton.ipywidgets as w 6 | 7 | from .utils import equals, import_item, isinstance_lazy 8 | 9 | 10 | class NeverEquals: 11 | def __eq__(self, other): 12 | return False 13 | 14 | 15 | class NonTruty: 16 | def __bool__(self): 17 | raise Exception("is not a truthy") 18 | 19 | 20 | class ArrayLike: 21 | def __eq__(self, other): 22 | return NonTruty() 23 | 24 | 25 | def test_equals(): 26 | assert equals(1, 1) 27 | assert equals("a", "a") 28 | assert not equals(1, 2) 29 | 30 | assert not equals(NeverEquals(), NeverEquals()) 31 | never_equals = NeverEquals() 32 | assert equals(never_equals, never_equals) 33 | 34 | with pytest.raises(Exception): 35 | assert NonTruty() 36 | ar1 = ArrayLike() 37 | ar2 = ArrayLike() 38 | with pytest.raises(Exception): 39 | assert ar1 != ar2 40 | assert not equals(ar1, ar2) 41 | 42 | assert equals(ar1, ar1) 43 | 44 | d1 = {"a": 1, "b": 2} 45 | d2 = {"a": 1, "b": 2} 46 | d3 = {"a": 1, "b": 3} 47 | assert equals(d1, d2) 48 | assert not equals(d1, d3) 49 | 50 | dar1 = {"a": 1, "b": {"ar": ar1}} 51 | dar2 = {"a": 1, "b": {"ar": ar1}} 52 | dar3 = {"a": 1, "b": {"ar": ar2}} 53 | assert equals(dar1, dar2) 54 | assert not equals(dar1, dar3) 55 | 56 | def make_function(a, ar1, ar2): 57 | def func(x): 58 | return x**a + (ar1 == ar2) 59 | 60 | return func 61 | 62 | f1 = make_function(1, ar1, ar2) 63 | f2 = make_function(1, ar1, ar2) 64 | f3 = make_function(2, ar2, ar2) 65 | assert f1 == f1 66 | assert f1 != f2 67 | assert equals(f1, f1) 68 | assert equals(f1, f2) 69 | assert not equals(f1, f3) 70 | 71 | def make_function_empty_cell(a, ar1, ar2): 72 | def func(x): 73 | return x**a + (ar1 == ar2) # noqa: F821 74 | 75 | del a 76 | return func 77 | 78 | f1 = make_function_empty_cell(1, ar1, ar2) 79 | f2 = make_function_empty_cell(1, ar1, ar2) 80 | f3 = make_function_empty_cell(2, ar2, ar2) 81 | assert f1 == f1 82 | assert f1 != f2 83 | assert equals(f1, f1) 84 | assert equals(f1, f2) 85 | assert not equals(f1, f3) 86 | 87 | def make_function_default_arg(a): 88 | def func(x, c=a): 89 | return x + c 90 | 91 | return func 92 | 93 | f1 = make_function_default_arg(1) 94 | f2 = make_function_default_arg(1) 95 | f3 = make_function_default_arg(2) 96 | assert f1 == f1 97 | assert f1 != f2 98 | assert equals(f1, f1) 99 | assert equals(f1, f2) 100 | assert not equals(f1, f3) 101 | 102 | def make_function_default_kwarg(a): 103 | def func(x, *, c=a): 104 | return x + c 105 | 106 | return func 107 | 108 | f1 = make_function_default_kwarg(1) 109 | f2 = make_function_default_kwarg(1) 110 | f3 = make_function_default_kwarg(2) 111 | assert f1 == f1 112 | assert f1 != f2 113 | assert equals(f1, f1) 114 | assert equals(f1, f2) 115 | assert not equals(f1, f3) 116 | 117 | def make_el(a): 118 | def on_click(): 119 | pass 120 | 121 | return w.Button(on_click=on_click, label=f"{a}") 122 | 123 | el1 = make_el(1) 124 | el2 = make_el(1) 125 | el3 = make_el(2) 126 | assert el1 == el1 127 | assert el1 != el2 128 | assert equals(el1, el1) 129 | # breakpoint() 130 | assert equals(el1.kwargs, el2.kwargs) 131 | assert equals(el1, el2) 132 | assert not equals(el1, el3) 133 | 134 | 135 | def test_import_item(): 136 | assert import_item("sys.modules") is sys.modules 137 | assert import_item("sys") is sys 138 | assert import_item("doesnotexist") is None 139 | assert import_item("doesnotexist.a") is None 140 | assert import_item("doesnotexist.a.b") is None 141 | 142 | 143 | @pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher") 144 | def test_isinstance_lazy(): 145 | assert isinstance_lazy(1, int) 146 | assert isinstance_lazy(1, (int, "does.not.exist")) 147 | assert not isinstance_lazy(1, "does.not.exist") 148 | 149 | sys.modules.pop("pandas", None) 150 | assert not isinstance_lazy(1, "pandas.DataFrame") 151 | import pandas as pd 152 | 153 | df = pd.DataFrame() 154 | assert isinstance_lazy(df, "pandas.DataFrame") 155 | -------------------------------------------------------------------------------- /reacton/work.py: -------------------------------------------------------------------------------- 1 | import threading 2 | 3 | import reacton as react 4 | 5 | 6 | def use_work_threaded(callback, dependencies=[]): 7 | def run(): 8 | def runner(): 9 | callback() 10 | 11 | threading.Thread(target=runner).start() 12 | 13 | react.use_effect(run, dependencies) 14 | -------------------------------------------------------------------------------- /release.md: -------------------------------------------------------------------------------- 1 | 2 | # fully automated 3 | 4 | $ ./release.sh patch 5 | 6 | # semi automated 7 | To make a new release 8 | ``` 9 | # update reacton/_version.py 10 | $ git add -u && git commit -m 'Release v1.9.1' && git tag v1.9.1 && git push upstream master v1.9.1 11 | ``` 12 | 13 | 14 | If a problem happens, and you want to keep the history clean 15 | ``` 16 | # do fix 17 | $ git rebase -i HEAD~3 18 | $ git tag v1.9.1 -f && git push upstream master v1.9.1 -f 19 | ``` 20 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e -o pipefail 3 | # usage: ./release minor -n 4 | version=$(bump2version --dry-run --list $* | grep new_version | sed -r s,"^.*=",,) 5 | echo Version tag v$version 6 | bumpversion $* --verbose && git push upstream master v$version 7 | --------------------------------------------------------------------------------