├── .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 | [](https://reacton.solara.dev/)
2 | [](https://reacton.solara.dev/en/latest/_output/lab/index.html)
3 | [](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 | 
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 | 
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 | 
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 | 
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 | 
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 | [](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 | * [](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 | 
185 | * [Calculator](./_output/lab/index.html?path=calculator.ipynb)
186 | 
187 | * [Todo-app](./_output/lab/index.html?path=todo-app.ipynb)
188 | 
189 | * [Markdown](./_output/lab/index.html?path=markdown.ipynb)
190 | 
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 | [](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 | * [](./_output/lab/index.html)
14 |
15 | Direct link to examples:
16 |
17 | * [ButtonClick](./_output/lab/index.html?path=click-button.ipynb)
18 | 
19 | * [Calculator](./_output/lab/index.html?path=calculator.ipynb)
20 | 
21 | * [Todo-app](./_output/lab/index.html?path=todo-app.ipynb)
22 | 
23 | * [Markdown](./_output/lab/index.html?path=markdown.ipynb)
24 | 
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 | 
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 |
--------------------------------------------------------------------------------