Repository: reactive-python/reactpy
Branch: main
Commit: 613b256abc1b
Files: 339
Total size: 987.3 KB
Directory structure:
gitextract_qt3yaq80/
├── .editorconfig
├── .github/
│ ├── CODEOWNERS
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE/
│ │ ├── config.yml
│ │ └── issue-form.yml
│ ├── copilot-instructions.md
│ ├── pull_request_template.md
│ └── workflows/
│ ├── .hatch-run.yml
│ ├── check.yml
│ ├── codeql-analysis.yml
│ └── publish.yml
├── .gitignore
├── .pre-commit-config.yaml
├── .prettierrc
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── LICENSE
├── README.md
├── docs/
│ ├── .gitignore
│ ├── Dockerfile
│ ├── README.md
│ ├── docs_app/
│ │ ├── __init__.py
│ │ ├── app.py
│ │ ├── dev.py
│ │ ├── examples.py
│ │ └── prod.py
│ ├── main.py
│ ├── pyproject.toml
│ └── source/
│ ├── _custom_js/
│ │ ├── README.md
│ │ ├── bun.lockb
│ │ ├── package.json
│ │ ├── rollup.config.js
│ │ └── src/
│ │ └── index.js
│ ├── _exts/
│ │ ├── async_doctest.py
│ │ ├── autogen_api_docs.py
│ │ ├── build_custom_js.py
│ │ ├── copy_vdom_json_schema.py
│ │ ├── custom_autosectionlabel.py
│ │ ├── patched_html_translator.py
│ │ ├── reactpy_example.py
│ │ └── reactpy_view.py
│ ├── _static/
│ │ └── css/
│ │ ├── furo-theme-overrides.css
│ │ ├── larger-api-margins.css
│ │ ├── larger-headings.css
│ │ ├── reactpy-view.css
│ │ ├── sphinx-design-overrides.css
│ │ └── widget-output-css-overrides.css
│ ├── about/
│ │ ├── changelog.rst
│ │ ├── contributor-guide.rst
│ │ └── credits-and-licenses.rst
│ ├── conf.py
│ ├── guides/
│ │ ├── adding-interactivity/
│ │ │ ├── components-with-state/
│ │ │ │ ├── _examples/
│ │ │ │ │ ├── adding_state_variable/
│ │ │ │ │ │ ├── data.json
│ │ │ │ │ │ └── main.py
│ │ │ │ │ ├── isolated_state/
│ │ │ │ │ │ ├── data.json
│ │ │ │ │ │ └── main.py
│ │ │ │ │ ├── multiple_state_variables/
│ │ │ │ │ │ ├── data.json
│ │ │ │ │ │ └── main.py
│ │ │ │ │ └── when_variables_are_not_enough/
│ │ │ │ │ ├── data.json
│ │ │ │ │ └── main.py
│ │ │ │ └── index.rst
│ │ │ ├── dangers-of-mutability/
│ │ │ │ ├── _examples/
│ │ │ │ │ ├── dict_remove.py
│ │ │ │ │ ├── dict_update.py
│ │ │ │ │ ├── list_insert.py
│ │ │ │ │ ├── list_re_order.py
│ │ │ │ │ ├── list_remove.py
│ │ │ │ │ ├── list_replace.py
│ │ │ │ │ ├── moving_dot.py
│ │ │ │ │ ├── moving_dot_broken.py
│ │ │ │ │ ├── set_remove.py
│ │ │ │ │ └── set_update.py
│ │ │ │ └── index.rst
│ │ │ ├── index.rst
│ │ │ ├── multiple-state-updates/
│ │ │ │ ├── _examples/
│ │ │ │ │ ├── delay_before_count_updater.py
│ │ │ │ │ ├── delay_before_set_count.py
│ │ │ │ │ ├── set_color_3_times.py
│ │ │ │ │ └── set_state_function.py
│ │ │ │ └── index.rst
│ │ │ ├── responding-to-events/
│ │ │ │ ├── _examples/
│ │ │ │ │ ├── audio_player.py
│ │ │ │ │ ├── button_async_handlers.py
│ │ │ │ │ ├── button_does_nothing.py
│ │ │ │ │ ├── button_handler_as_arg.py
│ │ │ │ │ ├── button_prints_event.py
│ │ │ │ │ ├── button_prints_message.py
│ │ │ │ │ ├── prevent_default_event_actions.py
│ │ │ │ │ └── stop_event_propagation.py
│ │ │ │ └── index.rst
│ │ │ └── state-as-a-snapshot/
│ │ │ ├── _examples/
│ │ │ │ ├── delayed_print_after_set.py
│ │ │ │ ├── print_chat_message.py
│ │ │ │ ├── print_count_after_set.py
│ │ │ │ ├── send_message.py
│ │ │ │ └── set_counter_3_times.py
│ │ │ └── index.rst
│ │ ├── creating-interfaces/
│ │ │ ├── html-with-reactpy/
│ │ │ │ └── index.rst
│ │ │ ├── index.rst
│ │ │ ├── rendering-data/
│ │ │ │ ├── _examples/
│ │ │ │ │ ├── sorted_and_filtered_todo_list.py
│ │ │ │ │ ├── todo_from_list.py
│ │ │ │ │ └── todo_list_with_keys.py
│ │ │ │ └── index.rst
│ │ │ └── your-first-components/
│ │ │ ├── _examples/
│ │ │ │ ├── bad_conditional_todo_list.py
│ │ │ │ ├── good_conditional_todo_list.py
│ │ │ │ ├── nested_photos.py
│ │ │ │ ├── parametrized_photos.py
│ │ │ │ ├── simple_photo.py
│ │ │ │ ├── todo_list.py
│ │ │ │ ├── wrap_in_div.py
│ │ │ │ └── wrap_in_fragment.py
│ │ │ └── index.rst
│ │ ├── escape-hatches/
│ │ │ ├── _examples/
│ │ │ │ ├── material_ui_button_no_action.py
│ │ │ │ ├── material_ui_button_on_click.py
│ │ │ │ └── super_simple_chart/
│ │ │ │ ├── main.py
│ │ │ │ └── super-simple-chart.js
│ │ │ ├── distributing-javascript.rst
│ │ │ ├── index.rst
│ │ │ ├── javascript-components.rst
│ │ │ ├── using-a-custom-backend.rst
│ │ │ └── using-a-custom-client.rst
│ │ ├── getting-started/
│ │ │ ├── _examples/
│ │ │ │ ├── debug_error_example.py
│ │ │ │ ├── hello_world.py
│ │ │ │ ├── run_fastapi.py
│ │ │ │ ├── run_flask.py
│ │ │ │ ├── run_sanic.py
│ │ │ │ ├── run_starlette.py
│ │ │ │ ├── run_tornado.py
│ │ │ │ └── sample_app.py
│ │ │ ├── _static/
│ │ │ │ ├── embed-doc-ex.html
│ │ │ │ └── embed-reactpy-view/
│ │ │ │ ├── index.html
│ │ │ │ └── main.py
│ │ │ ├── index.rst
│ │ │ ├── installing-reactpy.rst
│ │ │ └── running-reactpy.rst
│ │ ├── managing-state/
│ │ │ ├── combining-contexts-and-reducers/
│ │ │ │ └── index.rst
│ │ │ ├── deeply-sharing-state-with-contexts/
│ │ │ │ └── index.rst
│ │ │ ├── how-to-structure-state/
│ │ │ │ └── index.rst
│ │ │ ├── index.rst
│ │ │ ├── sharing-component-state/
│ │ │ │ ├── _examples/
│ │ │ │ │ ├── filterable_list/
│ │ │ │ │ │ ├── data.json
│ │ │ │ │ │ └── main.py
│ │ │ │ │ └── synced_inputs/
│ │ │ │ │ └── main.py
│ │ │ │ └── index.rst
│ │ │ ├── simplifying-updates-with-reducers/
│ │ │ │ └── index.rst
│ │ │ └── when-and-how-to-reset-state/
│ │ │ └── index.rst
│ │ └── understanding-reactpy/
│ │ ├── index.rst
│ │ ├── layout-render-servers.rst
│ │ ├── representing-html.rst
│ │ ├── the-rendering-pipeline.rst
│ │ ├── the-rendering-process.rst
│ │ ├── what-are-components.rst
│ │ ├── why-reactpy-needs-keys.rst
│ │ └── writing-tests.rst
│ ├── index.rst
│ └── reference/
│ ├── _examples/
│ │ ├── character_movement/
│ │ │ └── main.py
│ │ ├── click_count.py
│ │ ├── material_ui_switch.py
│ │ ├── matplotlib_plot.py
│ │ ├── network_graph.py
│ │ ├── pigeon_maps.py
│ │ ├── simple_dashboard.py
│ │ ├── slideshow.py
│ │ ├── snake_game.py
│ │ ├── todo.py
│ │ ├── use_reducer_counter.py
│ │ ├── use_state_counter.py
│ │ └── victory_chart.py
│ ├── _static/
│ │ └── vdom-json-schema.json
│ ├── browser-events.rst
│ ├── hooks-api.rst
│ ├── html-attributes.rst
│ ├── javascript-api.rst
│ └── specifications.rst
├── pyproject.toml
├── src/
│ ├── build_scripts/
│ │ ├── build_js_app.py
│ │ ├── build_js_client.py
│ │ ├── build_js_event_to_object.py
│ │ ├── clean_js_dir.py
│ │ ├── copy_dir.py
│ │ ├── delete_old_coverage.py
│ │ └── install_playwright.py
│ ├── js/
│ │ ├── .gitignore
│ │ ├── bun.lockb
│ │ ├── eslint.config.mjs
│ │ ├── package.json
│ │ ├── packages/
│ │ │ ├── @reactpy/
│ │ │ │ ├── app/
│ │ │ │ │ ├── bun.lockb
│ │ │ │ │ ├── package.json
│ │ │ │ │ ├── src/
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── preact-dom.ts
│ │ │ │ │ │ ├── preact-jsx-runtime.ts
│ │ │ │ │ │ └── preact.ts
│ │ │ │ │ └── tsconfig.json
│ │ │ │ └── client/
│ │ │ │ ├── README.md
│ │ │ │ ├── bun.lockb
│ │ │ │ ├── package.json
│ │ │ │ ├── src/
│ │ │ │ │ ├── bind.tsx
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── components.tsx
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── logger.ts
│ │ │ │ │ ├── mount.tsx
│ │ │ │ │ ├── types.ts
│ │ │ │ │ ├── vdom.tsx
│ │ │ │ │ └── websocket.ts
│ │ │ │ └── tsconfig.json
│ │ │ └── event-to-object/
│ │ │ ├── README.md
│ │ │ ├── bun.lockb
│ │ │ ├── package.json
│ │ │ ├── src/
│ │ │ │ └── index.ts
│ │ │ ├── tests/
│ │ │ │ ├── event-to-object.test.ts
│ │ │ │ └── tooling/
│ │ │ │ ├── check.ts
│ │ │ │ ├── mock.ts
│ │ │ │ └── setup.js
│ │ │ ├── tsconfig.json
│ │ │ └── vitest.config.ts
│ │ └── tsconfig.json
│ └── reactpy/
│ ├── __init__.py
│ ├── _console/
│ │ ├── __init__.py
│ │ ├── ast_utils.py
│ │ ├── cli.py
│ │ ├── rewrite_keys.py
│ │ └── rewrite_props.py
│ ├── _html.py
│ ├── _option.py
│ ├── _warnings.py
│ ├── config.py
│ ├── core/
│ │ ├── __init__.py
│ │ ├── _f_back.py
│ │ ├── _life_cycle_hook.py
│ │ ├── _thread_local.py
│ │ ├── component.py
│ │ ├── events.py
│ │ ├── hooks.py
│ │ ├── layout.py
│ │ ├── serve.py
│ │ └── vdom.py
│ ├── executors/
│ │ ├── __init__.py
│ │ ├── asgi/
│ │ │ ├── __init__.py
│ │ │ ├── middleware.py
│ │ │ ├── pyscript.py
│ │ │ ├── standalone.py
│ │ │ └── types.py
│ │ ├── pyscript/
│ │ │ ├── __init__.py
│ │ │ ├── component_template.py
│ │ │ ├── components.py
│ │ │ ├── layout_handler.py
│ │ │ └── utils.py
│ │ └── utils.py
│ ├── logging.py
│ ├── py.typed
│ ├── reactjs/
│ │ ├── __init__.py
│ │ ├── module.py
│ │ ├── types.py
│ │ └── utils.py
│ ├── static/
│ │ └── pyscript-hide-debug.css
│ ├── templatetags/
│ │ ├── __init__.py
│ │ └── jinja.py
│ ├── testing/
│ │ ├── __init__.py
│ │ ├── backend.py
│ │ ├── common.py
│ │ ├── display.py
│ │ └── logs.py
│ ├── transforms.py
│ ├── types.py
│ ├── utils.py
│ ├── web/
│ │ ├── __init__.py
│ │ ├── module.py
│ │ └── utils.py
│ └── widgets.py
└── tests/
├── __init__.py
├── conftest.py
├── sample.py
├── templates/
│ ├── index.html
│ ├── jinja_bad_kwargs.html
│ └── pyscript.html
├── test_asgi/
│ ├── __init__.py
│ ├── pyscript_components/
│ │ ├── load_first.py
│ │ ├── load_second.py
│ │ └── root.py
│ ├── test_init.py
│ ├── test_middleware.py
│ ├── test_pyscript.py
│ ├── test_standalone.py
│ └── test_utils.py
├── test_client.py
├── test_config.py
├── test_console/
│ ├── __init__.py
│ ├── test_rewrite_keys.py
│ └── test_rewrite_props.py
├── test_core/
│ ├── __init__.py
│ ├── test_component.py
│ ├── test_events.py
│ ├── test_hooks.py
│ ├── test_layout.py
│ ├── test_serve.py
│ └── test_vdom.py
├── test_html.py
├── test_option.py
├── test_pyscript/
│ ├── __init__.py
│ ├── pyscript_components/
│ │ ├── custom_root_name.py
│ │ └── root.py
│ ├── test_components.py
│ └── test_utils.py
├── test_reactjs/
│ ├── __init__.py
│ ├── js_fixtures/
│ │ ├── callable-prop.js
│ │ ├── component-can-have-child.js
│ │ ├── export-resolution/
│ │ │ ├── index.js
│ │ │ ├── one.js
│ │ │ └── two.js
│ │ ├── exports-syntax.js
│ │ ├── exports-two-components.js
│ │ ├── generic-module.js
│ │ ├── keys-properly-propagated.js
│ │ ├── nest-custom-under-web.js
│ │ ├── set-flag-when-unmount-is-called.js
│ │ ├── simple-button.js
│ │ └── subcomponent-notation.js
│ ├── test_modules.py
│ ├── test_modules_from_npm.py
│ └── test_utils.py
├── test_sample.py
├── test_testing.py
├── test_utils.py
├── test_web/
│ ├── __init__.py
│ ├── js_fixtures/
│ │ ├── callable-prop.js
│ │ ├── component-can-have-child.js
│ │ ├── export-resolution/
│ │ │ ├── index.js
│ │ │ ├── one.js
│ │ │ └── two.js
│ │ ├── exports-syntax.js
│ │ ├── exports-two-components.js
│ │ ├── generic-module.js
│ │ ├── keys-properly-propagated.js
│ │ ├── set-flag-when-unmount-is-called.js
│ │ ├── simple-button.js
│ │ └── subcomponent-notation.js
│ └── test_module.py
├── test_widgets.py
└── tooling/
├── __init__.py
├── aio.py
├── common.py
├── hooks.py
├── layout.py
└── select.py
================================================
FILE CONTENTS
================================================
================================================
FILE: .editorconfig
================================================
# http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
end_of_line = lf
[*.py]
indent_size = 4
max_line_length = 120
[*.md]
indent_size = 4
[*.yml]
indent_size = 4
[*.html]
max_line_length = off
[*.js]
max_line_length = off
[*.css]
indent_size = 4
max_line_length = off
# Tests can violate line width restrictions in the interest of clarity.
[**/test_*.py]
max_line_length = off
================================================
FILE: .github/CODEOWNERS
================================================
@maintainers
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
github: [archmonger, rmorshea]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
blank_issues_enabled: false
contact_links:
- name: Start a Discussion
url: https://github.com/reactive-python/reactpy/discussions
about: Report issues, request features, ask questions, and share ideas
================================================
FILE: .github/ISSUE_TEMPLATE/issue-form.yml
================================================
name: Plan a Task
description: Create a detailed plan of action (ONLY START AFTER DISCUSSION PLEASE 🙏).
labels: ["flag-triage"]
body:
- type: textarea
attributes:
label: Current Situation
description: Discuss how things currently are, why they require action, and any relevant prior discussion/context.
validations:
required: false
- type: textarea
attributes:
label: Proposed Actions
description: Describe what ought to be done, and why that will address the reasons for action mentioned above.
validations:
required: false
================================================
FILE: .github/copilot-instructions.md
================================================
# ReactPy Development Instructions
ReactPy is a Python library for building user interfaces without JavaScript. It creates React-like components that render to web pages using a Python-to-JavaScript bridge.
Always reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here.
**IMPORTANT**: This package uses modern Python tooling with Hatch for all development workflows. Always use Hatch commands for development tasks.
**BUG INVESTIGATION**: When investigating whether a bug was already resolved in a previous version, always prioritize searching through `docs/source/about/changelog.rst` first before using Git history. Only search through Git history when no relevant changelog entries are found.
## Working Effectively
### Bootstrap, Build, and Test the Repository
**Prerequisites:**
- Install Python 3.9+ from https://www.python.org/downloads/
- Install Hatch: `pip install hatch`
- Install Bun JavaScript runtime: `curl -fsSL https://bun.sh/install | bash && source ~/.bashrc`
- Install Git
**Initial Setup:**
```bash
git clone https://github.com/reactive-python/reactpy.git
cd reactpy
```
**Install Dependencies for Development:**
```bash
# Install core ReactPy dependencies
pip install fastjsonschema requests lxml anyio typing-extensions
# Install ASGI dependencies for server functionality
pip install orjson asgiref asgi-tools servestatic uvicorn fastapi
# Optional: Install additional servers
pip install flask sanic tornado
```
**Build JavaScript Packages:**
- `hatch run javascript:build` -- takes 15 seconds. NEVER CANCEL. Set timeout to 60+ minutes for safety.
- This builds three packages: event-to-object, @reactpy/client, and @reactpy/app
**Build Python Package:**
- `hatch build --clean` -- takes 10 seconds. NEVER CANCEL. Set timeout to 60+ minutes for safety.
**Run Python Tests:**
- `hatch test --parallel` -- takes 10-30 seconds for basic tests. NEVER CANCEL. Set timeout to 2 minutes for full test suite. **All tests must always pass - failures are never expected or allowed.**
- `hatch test --parallel --cover` -- run tests with coverage reporting (used in CI)
- `hatch test --parallel -k test_name` -- run specific tests
- `hatch test --parallel tests/test_config.py` -- run specific test files
**Run Python Linting and Formatting:**
- `hatch fmt` -- Run all linters and formatters (~1 second)
- `hatch fmt --check` -- Check formatting without making changes (~1 second)
- `hatch fmt --linter` -- Run only linters
- `hatch fmt --formatter` -- Run only formatters
- `hatch run python:type_check` -- Run Python type checker (~10 seconds)
**Run JavaScript Tasks:**
- `hatch run javascript:check` -- Lint and type-check JavaScript (10 seconds). NEVER CANCEL. Set timeout to 30+ minutes.
- `hatch run javascript:fix` -- Format JavaScript code
- `hatch run javascript:test` -- Run JavaScript tests
**Interactive Development Shell:**
- `hatch shell` -- Enter an interactive shell environment with all dependencies installed
- `hatch shell default` -- Enter the default development environment
- Use the shell for interactive debugging and development tasks
## Validation
Always manually validate any new code changes through these steps:
**Basic Functionality Test:**
```python
# Add src to path if not installed
import sys, os
sys.path.insert(0, os.path.join("/path/to/reactpy", "src"))
# Test that imports and basic components work
import reactpy
from reactpy import component, html, use_state
@component
def test_component():
return html.div([
html.h1("Test"),
html.p("ReactPy is working")
])
# Verify component renders
vdom = test_component()
print(f"Component rendered: {type(vdom)}")
```
**Server Functionality Test:**
```python
# Test ASGI server creation (most common deployment)
from reactpy import component, html
from reactpy.executors.asgi.standalone import ReactPy
import uvicorn
@component
def hello_world():
return html.div([
html.h1("Hello, ReactPy!"),
html.p("Server is working!")
])
# Create ASGI app (don't run to avoid hanging)
app = ReactPy(hello_world)
print("✓ ASGI server created successfully")
# To actually run: uvicorn.run(app, host="127.0.0.1", port=8000)
```
**Hooks and State Test:**
```python
from reactpy import component, html, use_state
@component
def counter_component(initial=0):
count, set_count = use_state(initial)
return html.div([
html.h1(f"Count: {count}"),
html.button({
"onClick": lambda event: set_count(count + 1)
}, "Increment")
])
# Test component with hooks
counter = counter_component(5)
print(f"✓ Hook-based component: {type(counter)}")
```
**Always run these validation steps before completing work:**
- `hatch fmt --check` -- Ensure code is properly formatted (never expected to fail)
- `hatch run python:type_check` -- Ensure no type errors (never expected to fail)
- `hatch run javascript:check` -- Ensure JavaScript passes linting (never expected to fail)
- Test basic component creation and rendering as shown above
- Test server creation if working on server-related features
- Run relevant tests with `hatch test --parallel` -- **All tests must always pass - failures are never expected or allowed**
**Integration Testing:**
- ReactPy can be deployed with FastAPI, Flask, Sanic, Tornado via ASGI
- For browser testing, Playwright is used but requires additional setup
- Test component VDOM rendering directly when browser testing isn't available
- Validate that JavaScript builds are included in Python package after changes
## Repository Structure and Navigation
### Key Directories:
- `src/reactpy/` -- Main Python package source code
- `core/` -- Core ReactPy functionality (components, hooks, VDOM)
- `web/` -- Web module management and exports
- `executors/` -- Server integration modules (ASGI, etc.)
- `testing/` -- Testing utilities and fixtures
- `pyscript/` -- PyScript integration
- `static/` -- Bundled JavaScript files
- `_html.py` -- HTML element factory functions
- `src/js/` -- JavaScript packages that get bundled with Python
- `packages/event-to-object/` -- Event serialization package
- `packages/@reactpy/client/` -- Client-side React integration
- `packages/@reactpy/app/` -- Application framework
- `src/build_scripts/` -- Build automation scripts
- `tests/` -- Python test suite with comprehensive coverage
- `docs/` -- Documentation source (MkDocs-based, transitioning setup)
### Important Files:
- `pyproject.toml` -- Python project configuration and Hatch environments
- `src/js/package.json` -- JavaScript development dependencies
- `tests/conftest.py` -- Test configuration and fixtures
- `docs/source/about/changelog.rst` -- Version history and changes
- `.github/workflows/check.yml` -- CI/CD pipeline configuration
## Common Tasks
### Build Time Expectations:
- JavaScript build: 15 seconds
- Python package build: 10 seconds
- Python linting: 1 second
- JavaScript linting: 10 seconds
- Type checking: 10 seconds
- Full CI pipeline: 5-10 minutes
### Running ReactPy Applications:
**ASGI Standalone (Recommended):**
```python
from reactpy import component, html
from reactpy.executors.asgi.standalone import ReactPy
import uvicorn
@component
def my_app():
return html.h1("Hello World")
app = ReactPy(my_app)
uvicorn.run(app, host="127.0.0.1", port=8000)
```
**With FastAPI:**
```python
from fastapi import FastAPI
from reactpy import component, html
from reactpy.executors.asgi.middleware import ReactPyMiddleware
@component
def my_component():
return html.h1("Hello from ReactPy!")
app = FastAPI()
app.add_middleware(ReactPyMiddleware, component=my_component)
```
### Creating Components:
```python
from reactpy import component, html, use_state
@component
def my_component(initial_value=0):
count, set_count = use_state(initial_value)
return html.div([
html.h1(f"Count: {count}"),
html.button({
"onClick": lambda event: set_count(count + 1)
}, "Increment")
])
```
### Working with JavaScript:
- JavaScript packages are in `src/js/packages/`
- Three main packages: event-to-object, @reactpy/client, @reactpy/app
- Built JavaScript gets bundled into `src/reactpy/static/`
- Always rebuild JavaScript after changes: `hatch run javascript:build`
## Common Hatch Commands
The following are key commands for daily development:
### Development Commands
```bash
hatch test --parallel # Run all tests (**All tests must always pass**)
hatch test --parallel --cover # Run tests with coverage (used in CI)
hatch test --parallel -k test_name # Run specific tests
hatch fmt # Format code with all formatters
hatch fmt --check # Check formatting without changes
hatch run python:type_check # Run Python type checker
hatch run javascript:build # Build JavaScript packages (15 seconds)
hatch run javascript:check # Lint JavaScript code (10 seconds)
hatch run javascript:fix # Format JavaScript code
hatch build --clean # Build Python package (10 seconds)
```
### Environment Management
```bash
hatch env show # Show all environments
hatch shell # Enter default shell
hatch shell default # Enter development shell
```
### Build Timing Expectations
- **NEVER CANCEL**: All commands complete within 60 seconds in normal operation
- **JavaScript build**: 15 seconds (hatch run javascript:build)
- **Python package build**: 10 seconds (hatch build --clean)
- **Python linting**: 1 second (hatch fmt)
- **JavaScript linting**: 10 seconds (hatch run javascript:check)
- **Type checking**: 10 seconds (hatch run python:type_check)
- **Unit tests**: 10-30 seconds (varies by test selection)
- **Full CI pipeline**: 5-10 minutes
## Development Workflow
Follow this step-by-step process for effective development:
1. **Bootstrap environment**: Ensure you have Python 3.9+ and run `pip install hatch`
2. **Make your changes** to the codebase
3. **Run formatting**: `hatch fmt` to format code (~1 second)
4. **Run type checking**: `hatch run python:type_check` for type checking (~10 seconds)
5. **Run JavaScript linting** (if JavaScript was modified): `hatch run javascript:check` (~10 seconds)
6. **Run relevant tests**: `hatch test --parallel` with specific test selection if needed. **All tests must always pass - failures are never expected or allowed.**
7. **Validate component functionality** manually using validation tests above
8. **Build JavaScript** (if modified): `hatch run javascript:build` (~15 seconds)
9. **Update documentation** when making changes to Python source code (required)
10. **Add changelog entry** for all significant changes to `docs/source/about/changelog.rst`
**IMPORTANT**: Documentation must be updated whenever changes are made to Python source code. This is enforced as part of the development workflow.
**IMPORTANT**: Significant changes must always include a changelog entry in `docs/source/about/changelog.rst` under the appropriate version section.
## Troubleshooting
### Build Issues:
- If JavaScript build fails, try: `hatch run "src/build_scripts/clean_js_dir.py"` then rebuild
- If Python build fails, ensure all dependencies in pyproject.toml are available
- Network timeouts during pip install are common in CI environments
- Missing dependencies error: Install ASGI dependencies with `pip install orjson asgiref asgi-tools servestatic`
### Import Issues:
- ReactPy must be installed or src/ must be in Python path
- Main imports: `from reactpy import component, html, use_state`
- Server imports: `from reactpy.executors.asgi.standalone import ReactPy`
- Web functionality: `from reactpy.web import export, module_from_url`
### Server Issues:
- Missing ASGI dependencies: Install with `pip install orjson asgiref asgi-tools servestatic uvicorn`
- For FastAPI integration: `pip install fastapi uvicorn`
- For Flask integration: `pip install flask` (requires additional backend package)
- For development servers, use ReactPy ASGI standalone for simplest setup
## Package Dependencies
Modern dependency management via pyproject.toml:
**Core Runtime Dependencies:**
- `fastjsonschema >=2.14.5` -- JSON schema validation
- `requests >=2` -- HTTP client library
- `lxml >=4` -- XML/HTML processing
- `anyio >=3` -- Async I/O abstraction
- `typing-extensions >=3.10` -- Type hints backport
**Optional Dependencies (install via extras):**
- `asgi` -- ASGI server support: `orjson`, `asgiref`, `asgi-tools`, `servestatic`, `pip`
- `jinja` -- Template integration: `jinja2-simple-tags`, `jinja2 >=3`
- `uvicorn` -- ASGI server: `uvicorn[standard]`
- `testing` -- Browser automation: `playwright`
- `all` -- All optional dependencies combined
**Development Dependencies (managed by Hatch):**
- **JavaScript tooling**: Bun runtime for building packages
- **Python tooling**: Hatch environments handle all dev dependencies automatically
## CI/CD Information
The repository uses GitHub Actions with these key jobs:
- `test-python-coverage` -- Python test coverage with `hatch test --parallel --cover`
- `lint-python` -- Python linting and type checking via `hatch fmt --check` and `hatch run python:type_check`
- `test-python` -- Cross-platform Python testing across Python 3.10-3.13 and Ubuntu/macOS/Windows
- `lint-javascript` -- JavaScript linting and type checking
The CI workflow is defined in `.github/workflows/check.yml` and uses the reusable workflow in `.github/workflows/.hatch-run.yml`.
**Build Matrix:**
- **Python versions**: 3.10, 3.11, 3.12, 3.13
- **Operating systems**: Ubuntu, macOS, Windows
- **Test execution**: Hatch-managed environments ensure consistency across platforms
Always ensure your changes pass local validation before pushing, as the CI pipeline will run the same checks.
## Important Notes
- **This is a Python-to-JavaScript bridge library**, not a traditional web framework - it enables React-like components in Python
- **Component rendering uses VDOM** - components return virtual DOM objects that get serialized to JavaScript
- **All builds and tests run quickly** - if something takes more than 60 seconds, investigate the issue
- **Hatch environments provide full isolation** - no need to manage virtual environments manually
- **JavaScript packages are bundled into Python** - the build process combines JS and Python into a single distribution
- **Documentation updates are required** when making changes to Python source code
- **Always update this file** when making changes to the development workflow, build process, or repository structure
- **All tests must always pass** - failures are never expected or allowed in a healthy development environment
================================================
FILE: .github/pull_request_template.md
================================================
## Description
## Checklist
Please update this checklist as you complete each item:
- [ ] Tests have been developed for bug fixes or new functionality.
- [ ] The changelog has been updated, if necessary.
- [ ] Documentation has been updated, if necessary.
- [ ] GitHub Issues closed by this PR have been linked.
By submitting this pull request I agree that all contributions comply with this project's open source license(s).
================================================
FILE: .github/workflows/.hatch-run.yml
================================================
name: hatch-run
on:
workflow_call:
inputs:
job-name:
required: true
type: string
run-cmd:
required: true
type: string
runs-on:
required: false
type: string
default: '["ubuntu-latest"]'
python-version:
required: false
type: string
default: '["3.x"]'
secrets:
node-auth-token:
required: false
pypi-username:
required: false
pypi-password:
required: false
jobs:
hatch:
name: ${{ format(inputs.job-name, matrix.python-version, matrix.runs-on) }}
strategy:
matrix:
python-version: ${{ fromJson(inputs.python-version) }}
runs-on: ${{ fromJson(inputs.runs-on) }}
runs-on: ${{ matrix.runs-on }}
steps:
- uses: actions/checkout@v4
- if: runner.os == 'Windows'
name: Cache Playwright Install
uses: actions/cache@v5
with:
path: C:\Users\runneradmin\AppData\Local\ms-playwright\
key: ${{ runner.os }}-playwright
# FIXME: Temporarily added setup-node to fix lack of "Trusted Publishing" in Bun
# Ref: https://github.com/oven-sh/bun/issues/15601
- uses: actions/setup-node@v6
with:
node-version: 24
registry-url: https://registry.npmjs.org/
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Use Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "pip"
- name: Install Python Dependencies
run: pip install hatch
- name: Run Scripts
env:
NPM_CONFIG_TOKEN: ${{ secrets.node-auth-token }}
HATCH_INDEX_USER: ${{ secrets.pypi-username }}
HATCH_INDEX_AUTH: ${{ secrets.pypi-password }}
run: ${{ inputs.run-cmd }}
================================================
FILE: .github/workflows/check.yml
================================================
name: check
on:
push:
branches:
- main
pull_request:
branches:
- "*"
schedule:
- cron: "0 0 * * 0"
jobs:
test-python-coverage:
uses: ./.github/workflows/.hatch-run.yml
with:
job-name: "python-{0}"
# Retries needed because GitHub workers sometimes lag enough to crash parallel workers
run-cmd: "hatch test --parallel --cover --retries 10"
lint-python:
uses: ./.github/workflows/.hatch-run.yml
with:
job-name: "python-{0}"
run-cmd: "hatch fmt src/reactpy --check && hatch run python:type_check"
test-python:
uses: ./.github/workflows/.hatch-run.yml
with:
job-name: "python-{0} {1}"
run-cmd: "hatch test --parallel --retries 10"
runs-on: '["ubuntu-latest", "macos-latest", "windows-latest"]'
python-version: '["3.11", "3.12", "3.13", "3.14"]'
test-documentation:
# Temporarily disabled while we transition from Sphinx to MkDocs
# https://github.com/reactive-python/reactpy/pull/1052
if: 0
uses: ./.github/workflows/.hatch-run.yml
with:
job-name: "python-{0}"
run-cmd: "hatch run docs:check"
python-version: '["3.11"]'
test-javascript:
uses: ./.github/workflows/.hatch-run.yml
with:
job-name: "{1}"
run-cmd: "hatch run javascript:test"
lint-javascript:
uses: ./.github/workflows/.hatch-run.yml
with:
job-name: "{1}"
run-cmd: "hatch run javascript:check"
================================================
FILE: .github/workflows/codeql-analysis.yml
================================================
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: codeql
on:
push:
branches: [main]
pull_request:
# The branches below must be a subset of the branches above
branches: [main]
schedule:
- cron: "43 3 * * 3"
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: ["javascript", "python"]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
================================================
FILE: .github/workflows/publish.yml
================================================
name: publish
on:
release:
types: [published]
permissions:
contents: read # Required to checkout the code
id-token: write # Required to sign the NPM publishing statements
jobs:
publish-reactpy:
if: startsWith(github.event.release.name, 'reactpy ') || startsWith(github.event.release.tag_name, 'reactpy-')
uses: ./.github/workflows/.hatch-run.yml
with:
job-name: "Publish to PyPI"
run-cmd: "hatch run javascript:build && hatch build --clean && hatch publish --yes"
secrets:
pypi-username: ${{ secrets.PYPI_USERNAME }}
pypi-password: ${{ secrets.PYPI_PASSWORD }}
publish-reactpy-client:
if: startsWith(github.event.release.name, '@reactpy/client ') || startsWith(github.event.release.tag_name, '@reactpy/client-')
uses: ./.github/workflows/.hatch-run.yml
with:
job-name: "Publish to NPM"
run-cmd: "hatch run javascript:publish_client"
publish-event-to-object:
if: startsWith(github.event.release.name, 'event-to-object ') || startsWith(github.event.release.tag_name, 'event-to-object-')
uses: ./.github/workflows/.hatch-run.yml
with:
job-name: "Publish to NPM"
run-cmd: "hatch run javascript:publish_event_to_object"
================================================
FILE: .gitignore
================================================
# --- Build Artifacts ---
src/reactpy/static/*.js*
src/reactpy/static/morphdom/
src/reactpy/static/pyscript/
src/js/**/*.tgz
src/js/**/LICENSE
# --- Jupyter ---
*.ipynb_checkpoints
*Untitled*.ipynb
# --- Jupyter Repo 2 Docker ---
.local
.ipython
.cache
.bash_history
.python_history
.jupyter
# --- Python ---
.hatch
.venv*
venv*
MANIFEST
build
dist
.eggs
*.egg-info
__pycache__/
*.py[cod]
.tox
.nox
pip-wheel-metadata
# --- PyEnv ---
.python-version
# -- Python Tests ---
.coverage.*
*.coverage
*.pytest_cache
*.mypy_cache
# --- IDE ---
.idea
.vscode
# --- JS ---
node_modules
================================================
FILE: .pre-commit-config.yaml
================================================
repos:
- repo: local
hooks:
- id: lint-py-fix
name: Fix Python Lint
entry: hatch run lint-py
language: system
args: [--fix]
pass_filenames: false
files: \.py$
- repo: local
hooks:
- id: lint-js-fix
name: Fix JS Lint
entry: hatch run lint-js --fix
language: system
pass_filenames: false
files: \.(js|jsx|ts|tsx)$
- repo: local
hooks:
- id: lint-py-check
name: Check Python Lint
entry: hatch run lint-py
language: system
pass_filenames: false
files: \.py$
- repo: local
hooks:
- id: lint-js-check
name: Check JS Lint
entry: hatch run lint-py
language: system
pass_filenames: false
files: \.(js|jsx|ts|tsx)$
================================================
FILE: .prettierrc
================================================
{
"proseWrap": "never",
"trailingComma": "all",
"endOfLine": "auto"
}
================================================
FILE: CHANGELOG.md
================================================
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Added support for Python 3.12, 3.13, and 3.14.
- Added type hints to `reactpy.html` attributes.
- Added support for nested components in web modules
- Added support for inline JavaScript as event handlers or other attributes that expect a callable via `reactpy.types.InlineJavaScript`
- Event functions can now call `event.preventDefault()` and `event.stopPropagation()` methods directly on the event data object, rather than using the `@event` decorator.
- Event data now supports accessing properties via dot notation (ex. `event.target.value`).
- Added support for partial functions in EventHandler
- Added `reactpy.types.Event` to provide type hints for the standard `data` function argument (for example `def on_click(event: Event): ...`).
- Added `asgi` and `jinja` installation extras (for example `pip install reactpy[asgi, jinja]`).
- Added `reactpy.executors.asgi.ReactPy` that can be used to run ReactPy in standalone mode via ASGI.
- Added `reactpy.executors.asgi.ReactPyCsr` that can be used to run ReactPy in standalone mode via ASGI, but rendered entirely client-sided.
- Added `reactpy.executors.asgi.ReactPyMiddleware` that can be used to utilize ReactPy within any ASGI compatible framework.
- Added `reactpy.templatetags.ReactPyJinja` that can be used alongside `ReactPyMiddleware` to embed several ReactPy components into your existing application. This includes the following template tags: `{% component %}`, `{% pyscript_component %}`, and `{% pyscript_setup %}`.
- Added `reactpy.pyscript_component` that can be used to embed ReactPy components into your existing application.
- Added `reactpy.use_async_effect` hook.
- Added `reactpy.Vdom` primitive interface for creating VDOM dictionaries.
- Added `reactpy.reactjs.component_from_file` to import ReactJS components from a file.
- Added `reactpy.reactjs.component_from_url` to import ReactJS components from a URL.
- Added `reactpy.reactjs.component_from_string` to import ReactJS components from a string.
- Added `reactpy.reactjs.component_from_npm` to import ReactJS components from NPM.
- Added `reactpy.h` as a shorthand alias for `reactpy.html`.
### Changed
- The `key` attribute is now stored within `attributes` in the VDOM spec.
- Substitute client-side usage of `react` with `preact`.
- Script elements no longer support behaving like effects. They now strictly behave like plain HTML scripts.
- The `reactpy.html` module has been modified to allow for auto-creation of any HTML nodes. For example, you can create a `` element by calling `html.data_table()`.
- Change `set_state` comparison method to check equality with `==` more consistently.
- Add support for rendering `@component` children within `vdom_to_html`.
- Renamed the `use_location` hook's `search` attribute to `query_string`.
- Renamed the `use_location` hook's `pathname` attribute to `path`.
- Renamed `reactpy.config.REACTPY_DEBUG_MODE` to `reactpy.config.REACTPY_DEBUG`.
- ReactPy no longer auto-converts `snake_case` props to `camelCase`. It is now the responsibility of the user to ensure that props are in the correct format.
- Rewrite the `event-to-object` package to be more robust at handling properties on events.
- Custom JS components will now automatically assume you are using ReactJS in the absence of a `bind` function.
- Refactor layout rendering logic to improve readability and maintainability.
- The JavaScript package `@reactpy/client` now exports `React` and `ReactDOM`, which allows third-party components to re-use the same React instance as ReactPy.
- `reactpy.html` will now automatically flatten lists recursively (ex. `reactpy.html(["child1", ["child2"]])`)
- `reactpy.utils.reactpy_to_string` will now retain the user's original casing for `data-*` and `aria-*` attributes.
- `reactpy.utils.string_to_reactpy` has been upgraded to handle more complex scenarios without causing ReactJS rendering errors.
- `reactpy.core.vdom._CustomVdomDictConstructor` has been moved to `reactpy.types.CustomVdomConstructor`.
- `reactpy.core.vdom._EllipsisRepr` has been moved to `reactpy.types.EllipsisRepr`.
- `reactpy.types.VdomDictConstructor` has been renamed to `reactpy.types.VdomConstructor`.
- `REACTPY_ASYNC_RENDERING` can now de-duplicate and cascade renders where necessary.
- `REACTPY_ASYNC_RENDERING` is now defaulted to `True` for up to 40x performance improvements in environments with high concurrency.
### Deprecated
- `reactpy.web.module_from_file` is deprecated. Use `reactpy.reactjs.component_from_file` instead.
- `reactpy.web.module_from_url` is deprecated. Use `reactpy.reactjs.component_from_url` instead.
- `reactpy.web.module_from_string` is deprecated. Use `reactpy.reactjs.component_from_string` instead.
- `reactpy.web.export` is deprecated. Use `reactpy.reactjs.component_from_*` instead.
- `reactpy.web.*` is deprecated. Use `reactpy.reactjs.*` instead.
### Removed
- Removed support for Python 3.9 and 3.10.
- Removed the ability to import `reactpy.html.*` elements directly. You must now call `html.*` to access the elements.
- Removed backend specific installation extras (such as `pip install reactpy[starlette]`).
- Removed support for async functions within `reactpy.use_effect` hook. Use `reactpy.use_async_effect` instead.
- Removed deprecated function `module_from_template`.
- Removed deprecated exception type `reactpy.core.serve.Stop`.
- Removed deprecated component `reactpy.widgets.hotswap`.
- Removed `reactpy.sample` module.
- Removed `reactpy.svg` module. Contents previously within `reactpy.svg.*` can now be accessed via `reactpy.html.svg.*`.
- Removed `reactpy.html._` function. Use `reactpy.html(...)` or `reactpy.html.fragment(...)` instead.
- Removed `reactpy.run`. See the documentation for the new method to run ReactPy applications.
- Removed `reactpy.backend.*`. See the documentation for the new method to run ReactPy applications.
- Removed `reactpy.core.types` module. Use `reactpy.types` instead.
- Removed `reactpy.utils.html_to_vdom`. Use `reactpy.utils.string_to_reactpy` instead.
- Removed `reactpy.utils.vdom_to_html`. Use `reactpy.utils.reactpy_to_string` instead.
- Removed `reactpy.vdom`. Use `reactpy.Vdom` instead.
- Removed `reactpy.core.make_vdom_constructor`. Use `reactpy.Vdom` instead.
- Removed `reactpy.core.custom_vdom_constructor`. Use `reactpy.Vdom` instead.
- Removed `reactpy.Layout` top-level re-export. Use `reactpy.core.layout.Layout` instead.
- Removed `reactpy.types.LayoutType`. Use `reactpy.types.BaseLayout` instead.
- Removed `reactpy.types.ContextProviderType`. Use `reactpy.types.ContextProvider` instead.
- Removed `reactpy.core.hooks._ContextProvider`. Use `reactpy.types.ContextProvider` instead.
- Removed `reactpy.web.utils`. Use `reactpy.reactjs.utils` instead.
### Fixed
- Fixed a bug where script elements would not render to the DOM as plain text.
- Fixed a bug where the `key` property provided within server-side ReactPy code was failing to propagate to the front-end JavaScript components.
- Fixed a bug where `RuntimeError("Hook stack is in an invalid state")` errors could be generated when using a webserver that reuses threads.
- Allow for ReactPy and ReactJS components to be arbitrarily inserted onto the page with any possible hierarchy.
## [1.1.0] - 2024-11-24
### Fixed
- Fixed broken `module_from_template` due to a recent release of `requests`.
- Fixed `module_from_template` not working when using Flask backend.
- Fixed `UnicodeDecodeError` when using `reactpy.web.export`.
- Fixed needless unmounting of JavaScript components during each ReactPy render.
- Fixed missing `event["target"]["checked"]` on checkbox inputs.
- Fixed missing static files on `sdist` Python distribution.
### Added
- Allow concurrently rendering discrete component trees - enable this experimental feature by setting `REACTPY_ASYNC_RENDERING=true`. This improves the overall responsiveness of your app in situations where larger renders would otherwise block smaller renders from executing.
### Changed
- Previously `None`, when present in an HTML element, would render as the string `"None"`. Now `None` will not render at all. This is now equivalent to how `None` is handled when returned from components.
- Move hooks from `reactpy.backend.hooks` into `reactpy.core.hooks`.
### Deprecated
- The `Stop` exception. Recent releases of `anyio` have made this exception difficult to use since it now raises an `ExceptionGroup`. This exception was primarily used for internal testing purposes and so is now deprecated.
- Deprecate `reactpy.backend.hooks` since the hooks have been moved into `reactpy.core.hooks`.
## [1.0.2] - 2023-07-03
### Fixed
- Fix rendering bug when children change positions.
## [1.0.1] - 2023-06-16
### Changed
- Warn and attempt to fix missing mime types, which can result in `reactpy.run` not working as expected.
- Rename `reactpy.backend.BackendImplementation` to `reactpy.backend.BackendType`.
- Allow `reactpy.run` to fail in more predictable ways.
### Fixed
- Better traceback for JSON serialization errors.
- Explain that JS component attributes must be JSON.
- Fix `reactpy.run` port assignment sometimes attaching to in-use ports on Windows.
- Fix `reactpy.run` not recognizing `fastapi`.
## [1.0.0] - 2023-03-14
### Changed
- Reverts PR 841 as per the conclusion in discussion 916, but preserves the ability to declare attributes with snake_case.
- Reverts PR 886 due to issue 896.
- Revamped element constructor interface. Now instead of passing a dictionary of attributes to element constructors, attributes are declared using keyword arguments. For example, instead of writing:
### Deprecated
- Declaration of keys via keyword arguments in standard elements. A script has been added to automatically convert old usages where possible.
### Removed
- Accidental import of reactpy.testing.
### Fixed
- Minor issues with camelCase rewrite CLI utility.
- Minor type hint issue with `VdomDictConstructor`.
- Stale event handlers after disconnect/reconnect cycle.
- Fixed CLI not registered as entry point.
- Unification of component and VDOM constructor interfaces.
[Unreleased]: https://github.com/reactive-python/reactpy/compare/reactpy-v1.1.0...HEAD
[1.1.0]: https://github.com/reactive-python/reactpy/compare/reactpy-v1.0.2...reactpy-v1.1.0
[1.0.2]: https://github.com/reactive-python/reactpy/compare/reactpy-v1.0.1...reactpy-v1.0.2
[1.0.1]: https://github.com/reactive-python/reactpy/compare/reactpy-v1.0.0...reactpy-v1.0.1
[1.0.0]: https://github.com/reactive-python/reactpy/compare/0.44.0...reactpy-v1.0.0
[0.44.0]: https://github.com/reactive-python/reactpy/compare/0.43.0...0.44.0
[0.43.0]: https://github.com/reactive-python/reactpy/compare/0.42.0...0.43.0
[0.42.0]: https://github.com/reactive-python/reactpy/compare/0.41.0...0.42.0
[0.41.0]: https://github.com/reactive-python/reactpy/compare/0.40.2...0.41.0
[0.40.2]: https://github.com/reactive-python/reactpy/compare/0.40.1...0.40.2
[0.40.1]: https://github.com/reactive-python/reactpy/compare/0.40.0...0.40.1
[0.40.0]: https://github.com/reactive-python/reactpy/compare/0.39.0...0.40.0
[0.39.0]: https://github.com/reactive-python/reactpy/compare/0.38.1...0.39.0
[0.38.1]: https://github.com/reactive-python/reactpy/compare/0.38.0...0.38.1
[0.38.0]: https://github.com/reactive-python/reactpy/compare/0.37.2...0.38.0
[0.37.2]: https://github.com/reactive-python/reactpy/compare/0.37.1...0.37.2
[0.37.1]: https://github.com/reactive-python/reactpy/compare/0.37.0...0.37.1
[0.37.0]: https://github.com/reactive-python/reactpy/compare/0.36.3...0.37.0
[0.36.3]: https://github.com/reactive-python/reactpy/compare/0.36.2...0.36.3
[0.36.2]: https://github.com/reactive-python/reactpy/compare/0.36.1...0.36.2
[0.36.1]: https://github.com/reactive-python/reactpy/compare/0.36.0...0.36.1
[0.36.0]: https://github.com/reactive-python/reactpy/compare/0.35.4...0.36.0
[0.35.4]: https://github.com/reactive-python/reactpy/compare/0.35.3...0.35.4
[0.35.3]: https://github.com/reactive-python/reactpy/compare/0.35.2...0.35.3
[0.35.2]: https://github.com/reactive-python/reactpy/compare/0.35.1...0.35.2
[0.35.1]: https://github.com/reactive-python/reactpy/compare/0.35.0...0.35.1
[0.35.0]: https://github.com/reactive-python/reactpy/compare/0.34.0...0.35.0
[0.34.0]: https://github.com/reactive-python/reactpy/compare/0.33.3...0.34.0
[0.33.3]: https://github.com/reactive-python/reactpy/compare/0.33.2...0.33.3
[0.33.2]: https://github.com/reactive-python/reactpy/compare/0.33.1...0.33.2
[0.33.1]: https://github.com/reactive-python/reactpy/compare/0.33.0...0.33.1
[0.33.0]: https://github.com/reactive-python/reactpy/compare/0.32.0...0.33.0
[0.32.0]: https://github.com/reactive-python/reactpy/compare/0.31.0...0.32.0
[0.31.0]: https://github.com/reactive-python/reactpy/compare/0.30.1...0.31.0
[0.30.1]: https://github.com/reactive-python/reactpy/compare/0.30.0...0.30.1
[0.30.0]: https://github.com/reactive-python/reactpy/compare/0.29.0...0.30.0
[0.29.0]: https://github.com/reactive-python/reactpy/compare/0.28.0...0.29.0
[0.28.0]: https://github.com/reactive-python/reactpy/compare/0.27.0...0.28.0
[0.27.0]: https://github.com/reactive-python/reactpy/compare/0.26.0...0.27.0
[0.26.0]: https://github.com/reactive-python/reactpy/compare/0.25.0...0.26.0
[0.25.0]: https://github.com/reactive-python/reactpy/compare/0.24.0...0.25.0
[0.24.0]: https://github.com/reactive-python/reactpy/compare/0.23.1...0.24.0
[0.23.1]: https://github.com/reactive-python/reactpy/compare/0.23.0...0.23.1
[0.23.0]: https://github.com/reactive-python/reactpy/releases/tag/0.23.0
================================================
FILE: CODE_OF_CONDUCT.md
================================================
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socio-economic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at ryan.morshead@gmail.com. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq
================================================
FILE: LICENSE
================================================
The MIT License (MIT)
Copyright (c) Reactive Python and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================
FILE: README.md
================================================
# ReactPy
[ReactPy](https://reactpy.dev/) is a library for building user interfaces in Python without Javascript. ReactPy interfaces are made from components that look and behave similar to those found in [ReactJS](https://reactjs.org/). Designed with simplicity in mind, ReactPy can be used by those without web development experience while also being powerful enough to grow with your ambitions.
# At a Glance
To get a rough idea of how to write apps in ReactPy, take a look at this tiny _Hello World_ application.
```python
from reactpy import component, html, run
@component
def hello_world():
return html.h1("Hello, World!")
run(hello_world)
```
# Resources
Follow the links below to find out more about this project.
- [Try ReactPy (Jupyter Notebook)](https://mybinder.org/v2/gh/reactive-python/reactpy-jupyter/main?urlpath=lab/tree/notebooks/introduction.ipynb)
- [Documentation](https://reactpy.dev/)
- [GitHub Discussions](https://github.com/reactive-python/reactpy/discussions)
- [Discord](https://discord.gg/uNb5P4hA9X)
- [Contributor Guide](https://reactpy.dev/docs/about/contributor-guide.html)
- [Code of Conduct](https://github.com/reactive-python/reactpy/blob/main/CODE_OF_CONDUCT.md)
================================================
FILE: docs/.gitignore
================================================
build
source/_auto
source/_static/custom.js
source/vdom-json-schema.json
================================================
FILE: docs/Dockerfile
================================================
FROM python:3.11
WORKDIR /app/
RUN apt-get update
# Create/Activate Python Venv
# ---------------------------
ENV VIRTUAL_ENV=/opt/venv
RUN python3 -m venv $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
# Install Python Build Dependencies
# ---------------------------------
RUN pip install --upgrade pip poetry hatch uv
RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:$PATH"
# Copy Files
# ----------
COPY LICENSE ./
COPY README.md ./
COPY pyproject.toml ./
COPY src ./src
COPY docs ./docs
COPY branding ./branding
# Install and Build Docs
# ----------------------
WORKDIR /app/docs/
RUN poetry install -v
RUN sphinx-build -v -W -b html source build
# Define Entrypoint
# -----------------
ENV PORT=5000
ENV REACTPY_DEBUG=1
ENV REACTPY_CHECK_VDOM_SPEC=0
CMD ["python", "main.py"]
================================================
FILE: docs/README.md
================================================
# ReactPy's Documentation
...
================================================
FILE: docs/docs_app/__init__.py
================================================
================================================
FILE: docs/docs_app/app.py
================================================
from logging import getLogger
from pathlib import Path
from sanic import Sanic, response
from docs_app.examples import get_normalized_example_name, load_examples
from reactpy import component
from reactpy.backend.sanic import Options, configure, use_request
from reactpy.types import ComponentConstructor
THIS_DIR = Path(__file__).parent
DOCS_DIR = THIS_DIR.parent
DOCS_BUILD_DIR = DOCS_DIR / "build"
REACTPY_MODEL_SERVER_URL_PREFIX = "/_reactpy"
logger = getLogger(__name__)
REACTPY_MODEL_SERVER_URL_PREFIX = "/_reactpy"
@component
def Example():
raw_view_id = use_request().get_args().get("view_id")
view_id = get_normalized_example_name(raw_view_id)
return _get_examples()[view_id]()
def _get_examples():
if not _EXAMPLES:
_EXAMPLES.update(load_examples())
return _EXAMPLES
def reload_examples():
_EXAMPLES.clear()
_EXAMPLES.update(load_examples())
_EXAMPLES: dict[str, ComponentConstructor] = {}
def make_app(name: str):
app = Sanic(name)
app.static("/docs", str(DOCS_BUILD_DIR))
@app.route("/")
async def forward_to_index(_):
return response.redirect("/docs/index.html")
configure(
app,
Example,
Options(url_prefix=REACTPY_MODEL_SERVER_URL_PREFIX),
)
return app
================================================
FILE: docs/docs_app/dev.py
================================================
import asyncio
import os
import threading
import time
import webbrowser
from sphinx_autobuild.cli import (
Server,
_get_build_args,
_get_ignore_handler,
find_free_port,
get_builder,
get_parser,
)
from docs_app.app import make_app, reload_examples
from reactpy.backend.sanic import serve_development_app
from reactpy.testing import clear_reactpy_web_modules_dir
# these environment variable are used in custom Sphinx extensions
os.environ["REACTPY_DOC_EXAMPLE_SERVER_HOST"] = "127.0.0.1:5555"
os.environ["REACTPY_DOC_STATIC_SERVER_HOST"] = ""
def wrap_builder(old_builder):
# This is the bit that we're injecting to get the example components to reload too
app = make_app("docs_dev_app")
thread_started = threading.Event()
def run_in_thread():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
server_started = asyncio.Event()
async def set_thread_event_when_started():
await server_started.wait()
thread_started.set()
loop.run_until_complete(
asyncio.gather(
serve_development_app(app, "127.0.0.1", 5555, server_started),
set_thread_event_when_started(),
)
)
threading.Thread(target=run_in_thread, daemon=True).start()
thread_started.wait()
def new_builder():
clear_reactpy_web_modules_dir()
reload_examples()
old_builder()
return new_builder
def main():
# Mostly copied from https://github.com/executablebooks/sphinx-autobuild/blob/b54fb08afc5112bfcda1d844a700c5a20cd6ba5e/src/sphinx_autobuild/cli.py
parser = get_parser()
args = parser.parse_args()
srcdir = os.path.realpath(args.sourcedir)
outdir = os.path.realpath(args.outdir)
if not os.path.exists(outdir):
os.makedirs(outdir)
server = Server()
build_args, pre_build_commands = _get_build_args(args)
builder = wrap_builder(
get_builder(
server.watcher,
build_args,
host=args.host,
port=args.port,
pre_build_commands=pre_build_commands,
)
)
ignore_handler = _get_ignore_handler(args)
server.watch(srcdir, builder, ignore=ignore_handler)
for dirpath in args.additional_watched_dirs:
real_dirpath = os.path.realpath(dirpath)
server.watch(real_dirpath, builder, ignore=ignore_handler)
server.watch(outdir, ignore=ignore_handler)
if not args.no_initial_build:
builder()
# Find the free port
portn = args.port or find_free_port()
if args.openbrowser is True:
def opener():
time.sleep(args.delay)
webbrowser.open(f"http://{args.host}:{args.port}/index.html")
threading.Thread(target=opener, daemon=True).start()
server.serve(port=portn, host=args.host, root=outdir)
================================================
FILE: docs/docs_app/examples.py
================================================
from __future__ import annotations
from collections.abc import Callable, Iterator
from io import StringIO
from pathlib import Path
from traceback import format_exc
import reactpy
from reactpy.types import ComponentType
HERE = Path(__file__)
SOURCE_DIR = HERE.parent.parent / "source"
CONF_FILE = SOURCE_DIR / "conf.py"
RUN_ReactPy = reactpy.run
def load_examples() -> Iterator[tuple[str, Callable[[], ComponentType]]]:
for name in all_example_names():
yield name, load_one_example(name)
def all_example_names() -> set[str]:
names = set()
for file in _iter_example_files(SOURCE_DIR):
path = file.parent if file.name == "main.py" else file
names.add("/".join(path.relative_to(SOURCE_DIR).with_suffix("").parts))
return names
def load_one_example(file_or_name: Path | str) -> Callable[[], ComponentType]:
return lambda: (
# we use a lambda to ensure each instance is fresh
_load_one_example(file_or_name)
)
def get_normalized_example_name(
name: str, relative_to: str | Path | None = SOURCE_DIR
) -> str:
return "/".join(
_get_root_example_path_by_name(name, relative_to).relative_to(SOURCE_DIR).parts
)
def get_main_example_file_by_name(
name: str, relative_to: str | Path | None = SOURCE_DIR
) -> Path:
path = _get_root_example_path_by_name(name, relative_to)
if path.is_dir():
return path / "main.py"
else:
return path.with_suffix(".py")
def get_example_files_by_name(
name: str, relative_to: str | Path | None = SOURCE_DIR
) -> list[Path]:
path = _get_root_example_path_by_name(name, relative_to)
if path.is_dir():
return [p for p in path.glob("*") if not p.is_dir()]
else:
path = path.with_suffix(".py")
return [path] if path.exists() else []
def _iter_example_files(root: Path) -> Iterator[Path]:
for path in root.iterdir():
if path.is_dir():
if not path.name.startswith("_") or path.name == "_examples":
yield from _iter_example_files(path)
elif path.suffix == ".py" and path != CONF_FILE:
yield path
def _load_one_example(file_or_name: Path | str) -> ComponentType:
if isinstance(file_or_name, str):
file = get_main_example_file_by_name(file_or_name)
else:
file = file_or_name
if not file.exists():
raise FileNotFoundError(str(file))
print_buffer = _PrintBuffer()
def capture_print(*args, **kwargs):
buffer = StringIO()
print(*args, file=buffer, **kwargs)
print_buffer.write(buffer.getvalue())
captured_component_constructor = None
def capture_component(component_constructor):
nonlocal captured_component_constructor
captured_component_constructor = component_constructor
reactpy.run = capture_component
try:
code = compile(file.read_text(), str(file), "exec")
exec(
code,
{
"print": capture_print,
"__file__": str(file),
"__name__": file.stem,
},
)
except Exception:
return _make_error_display(format_exc())
finally:
reactpy.run = RUN_ReactPy
if captured_component_constructor is None:
return _make_example_did_not_run(str(file))
@reactpy.component
def Wrapper():
return reactpy.html.div(captured_component_constructor(), PrintView())
@reactpy.component
def PrintView():
text, set_text = reactpy.hooks.use_state(print_buffer.getvalue())
print_buffer.set_callback(set_text)
return (
reactpy.html.pre({"class_name": "printout"}, text)
if text
else reactpy.html.div()
)
return Wrapper()
def _get_root_example_path_by_name(name: str, relative_to: str | Path | None) -> Path:
if not name.startswith("/") and relative_to is not None:
rel_path = Path(relative_to)
rel_path = rel_path.parent if rel_path.is_file() else rel_path
else:
rel_path = SOURCE_DIR
return rel_path.joinpath(*name.split("/")).resolve()
class _PrintBuffer:
def __init__(self, max_lines: int = 10):
self._callback = None
self._lines = ()
self._max_lines = max_lines
def set_callback(self, function: Callable[[str], None]) -> None:
self._callback = function
def getvalue(self) -> str:
return "".join(self._lines)
def write(self, text: str) -> None:
if len(self._lines) == self._max_lines:
self._lines = (*self._lines[1:], text)
else:
self._lines += (text,)
if self._callback is not None:
self._callback(self.getvalue())
def _make_example_did_not_run(example_name):
@reactpy.component
def ExampleDidNotRun():
return reactpy.html.code(f"Example {example_name} did not run")
return ExampleDidNotRun()
def _make_error_display(message):
@reactpy.component
def ShowError():
return reactpy.html.pre(message)
return ShowError()
================================================
FILE: docs/docs_app/prod.py
================================================
import os
from docs_app.app import make_app
app = make_app("docs_prod_app")
def main() -> None:
app.run(
host="0.0.0.0", # noqa: S104
port=int(os.environ.get("PORT", "5000")),
workers=int(os.environ.get("WEB_CONCURRENCY", "1")),
debug=bool(int(os.environ.get("DEBUG", "0"))),
)
================================================
FILE: docs/main.py
================================================
import sys
from docs_app import dev, prod
if __name__ == "__main__":
if len(sys.argv) == 1:
prod.main()
else:
dev.main()
================================================
FILE: docs/pyproject.toml
================================================
[tool.poetry]
name = "docs_app"
version = "0.0.0"
description = "docs"
authors = ["rmorshea "]
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.9"
furo = "2022.04.07"
reactpy = { path = "..", extras = ["all"], develop = false }
sphinx = "*"
sphinx-autodoc-typehints = "*"
sphinx-copybutton = "*"
sphinx-autobuild = "*"
sphinx-reredirects = "*"
sphinx-design = "*"
sphinx-resolve-py-references = "*"
sphinxext-opengraph = "*"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
================================================
FILE: docs/source/_custom_js/README.md
================================================
# Custom Javascript for ReactPy's Docs
Build the javascript with
```
bun run build
```
This will drop a javascript bundle into `../_static/custom.js`
================================================
FILE: docs/source/_custom_js/package.json
================================================
{
"name": "reactpy-docs-example-loader",
"version": "1.0.0",
"description": "simple javascript client for ReactPy's documentation",
"main": "index.js",
"scripts": {
"build": "rollup --config",
"format": "prettier --ignore-path .gitignore --write ."
},
"devDependencies": {
"@rollup/plugin-commonjs": "^21.0.1",
"@rollup/plugin-node-resolve": "^13.1.1",
"@rollup/plugin-replace": "^3.0.0",
"prettier": "^2.2.1",
"rollup": "^2.35.1"
},
"dependencies": {
"@reactpy/client": "file:../../../src/js/packages/@reactpy/client"
}
}
================================================
FILE: docs/source/_custom_js/rollup.config.js
================================================
import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import replace from "@rollup/plugin-replace";
export default {
input: "src/index.js",
output: {
file: "../_static/custom.js",
format: "esm",
},
plugins: [
resolve(),
commonjs(),
replace({
"process.env.NODE_ENV": JSON.stringify("production"),
preventAssignment: true,
}),
],
onwarn: function (warning) {
if (warning.code === "THIS_IS_UNDEFINED") {
// skip warning where `this` is undefined at the top level of a module
return;
}
console.warn(warning.message);
},
};
================================================
FILE: docs/source/_custom_js/src/index.js
================================================
import { SimpleReactPyClient, mount } from "@reactpy/client";
let didMountDebug = false;
export function mountWidgetExample(
mountID,
viewID,
reactpyServerHost,
useActivateButton,
) {
let reactpyHost, reactpyPort;
if (reactpyServerHost) {
[reactpyHost, reactpyPort] = reactpyServerHost.split(":", 2);
} else {
reactpyHost = window.location.hostname;
reactpyPort = window.location.port;
}
const client = new SimpleReactPyClient({
serverLocation: {
url: `${window.location.protocol}//${reactpyHost}:${reactpyPort}`,
route: "/",
query: `?view_id=${viewID}`,
},
});
const mountEl = document.getElementById(mountID);
let isMounted = false;
triggerIfInViewport(mountEl, () => {
if (!isMounted) {
activateView(mountEl, client, useActivateButton);
isMounted = true;
}
});
}
function activateView(mountEl, client, useActivateButton) {
if (!useActivateButton) {
mount(mountEl, client);
return;
}
const enableWidgetButton = document.createElement("button");
enableWidgetButton.appendChild(document.createTextNode("Activate"));
enableWidgetButton.setAttribute("class", "enable-widget-button");
enableWidgetButton.addEventListener("click", () =>
fadeOutElementThenCallback(enableWidgetButton, () => {
{
mountEl.removeChild(enableWidgetButton);
mountEl.setAttribute("class", "interactive widget-container");
mountWithLayoutServer(mountEl, serverInfo);
}
}),
);
function fadeOutElementThenCallback(element, callback) {
{
var op = 1; // initial opacity
var timer = setInterval(function () {
{
if (op < 0.001) {
{
clearInterval(timer);
element.style.display = "none";
callback();
}
}
element.style.opacity = op;
element.style.filter = "alpha(opacity=" + op * 100 + ")";
op -= op * 0.5;
}
}, 50);
}
}
mountEl.appendChild(enableWidgetButton);
}
function triggerIfInViewport(element, callback) {
const observer = new window.IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
callback();
}
},
{
root: null,
threshold: 0.1, // set offset 0.1 means trigger if at least 10% of element in viewport
},
);
observer.observe(element);
}
================================================
FILE: docs/source/_exts/async_doctest.py
================================================
from doctest import DocTest, DocTestRunner
from textwrap import indent
from typing import Any
from sphinx.application import Sphinx
from sphinx.ext.doctest import DocTestBuilder
from sphinx.ext.doctest import setup as doctest_setup
test_template = """
import asyncio as __test_template_asyncio
async def __test_template__main():
{test}
globals().update(locals())
__test_template_asyncio.run(__test_template__main())
"""
class TestRunnerWrapper:
def __init__(self, runner: DocTestRunner):
self._runner = runner
def __getattr__(self, name: str) -> Any:
return getattr(self._runner, name)
def run(self, test: DocTest, *args: Any, **kwargs: Any) -> Any:
for ex in test.examples:
ex.source = test_template.format(test=indent(ex.source, " ").strip())
return self._runner.run(test, *args, **kwargs)
class AsyncDoctestBuilder(DocTestBuilder):
@property
def test_runner(self) -> DocTestRunner:
return self._test_runner
@test_runner.setter
def test_runner(self, value: DocTestRunner) -> None:
self._test_runner = TestRunnerWrapper(value)
def setup(app: Sphinx) -> None:
doctest_setup(app)
app.add_builder(AsyncDoctestBuilder, override=True)
================================================
FILE: docs/source/_exts/autogen_api_docs.py
================================================
from __future__ import annotations
import sys
from collections.abc import Collection, Iterator
from pathlib import Path
from sphinx.application import Sphinx
HERE = Path(__file__).parent
SRC = HERE.parent.parent.parent / "src"
PYTHON_PACKAGE = SRC / "reactpy"
AUTO_DIR = HERE.parent / "_auto"
AUTO_DIR.mkdir(exist_ok=True)
API_FILE = AUTO_DIR / "apis.rst"
# All valid RST section symbols - it shouldn't be realistically possible to exhaust them
SECTION_SYMBOLS = r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"""
AUTODOC_TEMPLATE_WITH_MEMBERS = """\
.. automodule:: {module}
:members:
:ignore-module-all:
"""
AUTODOC_TEMPLATE_WITHOUT_MEMBERS = """\
.. automodule:: {module}
:ignore-module-all:
"""
TITLE = """\
==========
Python API
==========
"""
def generate_api_docs():
content = [TITLE]
for file in walk_python_files(PYTHON_PACKAGE, ignore_dirs={"__pycache__"}):
if file.name == "__init__.py":
if file.parent != PYTHON_PACKAGE:
content.append(make_package_section(file))
elif not file.name.startswith("_"):
content.append(make_module_section(file))
API_FILE.write_text("\n".join(content))
def make_package_section(file: Path) -> str:
parent_dir = file.parent
symbol = get_section_symbol(parent_dir)
section_name = f"``{parent_dir.name}``"
module_name = get_module_name(parent_dir)
return (
section_name
+ "\n"
+ (symbol * len(section_name))
+ "\n"
+ AUTODOC_TEMPLATE_WITHOUT_MEMBERS.format(module=module_name)
)
def make_module_section(file: Path) -> str:
symbol = get_section_symbol(file)
section_name = f"``{file.stem}``"
module_name = get_module_name(file)
return (
section_name
+ "\n"
+ (symbol * len(section_name))
+ "\n"
+ AUTODOC_TEMPLATE_WITH_MEMBERS.format(module=module_name)
)
def get_module_name(path: Path) -> str:
return ".".join(path.with_suffix("").relative_to(PYTHON_PACKAGE.parent).parts)
def get_section_symbol(path: Path) -> str:
rel_path = path.relative_to(PYTHON_PACKAGE)
rel_path_parts = rel_path.parts
if len(rel_path_parts) > len(SECTION_SYMBOLS):
msg = f"package structure is too deep - ran out of section symbols: {rel_path}"
raise RuntimeError(msg)
return SECTION_SYMBOLS[len(rel_path_parts) - 1]
def walk_python_files(root: Path, ignore_dirs: Collection[str]) -> Iterator[Path]:
"""Iterate over Python files
We yield in a particular order to get the correction title section structure. Given
a directory structure of the form::
project/
__init__.py
/package
__init__.py
module_a.py
module_b.py
We yield the files in this order::
project / __init__.py
project / package / __init__.py
project / package / module_a.py
project / module_b.py
In this way we generate the section titles in the appropriate order::
project
=======
project.package
---------------
project.package.module_a
------------------------
"""
for path in sorted(
root.iterdir(),
key=lambda path: (
# __init__.py files first
int(not path.name == "__init__.py"),
# then directories
int(not path.is_dir()),
# sort by file name last
path.name,
),
):
if path.is_dir():
if (path / "__init__.py").exists() and path.name not in ignore_dirs:
yield from walk_python_files(path, ignore_dirs)
elif path.suffix == ".py":
yield path
def setup(app: Sphinx) -> None:
if sys.platform == "win32" and sys.version_info[:2] == (3, 7):
return None
generate_api_docs()
return None
================================================
FILE: docs/source/_exts/build_custom_js.py
================================================
import subprocess
from pathlib import Path
from sphinx.application import Sphinx
SOURCE_DIR = Path(__file__).parent.parent
CUSTOM_JS_DIR = SOURCE_DIR / "_custom_js"
def setup(app: Sphinx) -> None:
subprocess.run("bun install", cwd=CUSTOM_JS_DIR, shell=True) # noqa S607
subprocess.run("bun run build", cwd=CUSTOM_JS_DIR, shell=True) # noqa S607
================================================
FILE: docs/source/_exts/copy_vdom_json_schema.py
================================================
import json
from pathlib import Path
from sphinx.application import Sphinx
from reactpy.core.vdom import VDOM_JSON_SCHEMA
def setup(app: Sphinx) -> None:
schema_file = Path(__file__).parent.parent / "vdom-json-schema.json"
current_schema = json.dumps(VDOM_JSON_SCHEMA, indent=2, sort_keys=True)
# We need to make this check because the autoreload system for the docs checks
# to see if the file has changed to determine whether to re-build. Thus we should
# only write to the file if its contents will be different.
if not schema_file.exists() or schema_file.read_text() != current_schema:
schema_file.write_text(current_schema)
================================================
FILE: docs/source/_exts/custom_autosectionlabel.py
================================================
"""Mostly copied from sphinx.ext.autosectionlabel
See Sphinx BSD license:
https://github.com/sphinx-doc/sphinx/blob/f9968594206e538f13fa1c27c065027f10d4ea27/LICENSE
"""
from __future__ import annotations
from fnmatch import fnmatch
from typing import Any, cast
from docutils import nodes
from docutils.nodes import Node
from sphinx.application import Sphinx
from sphinx.domains.std import StandardDomain
from sphinx.locale import __
from sphinx.util import logging
from sphinx.util.nodes import clean_astext
logger = logging.getLogger(__name__)
def get_node_depth(node: Node) -> int:
i = 0
cur_node = node
while cur_node.parent != node.document:
cur_node = cur_node.parent
i += 1
return i
def register_sections_as_label(app: Sphinx, document: Node) -> None:
docname = app.env.docname
for pattern in app.config.autosectionlabel_skip_docs:
if fnmatch(docname, pattern):
return None
domain = cast(StandardDomain, app.env.get_domain("std"))
for node in document.traverse(nodes.section):
if (
app.config.autosectionlabel_maxdepth
and get_node_depth(node) >= app.config.autosectionlabel_maxdepth
):
continue
labelid = node["ids"][0]
title = cast(nodes.title, node[0])
ref_name = getattr(title, "rawsource", title.astext())
if app.config.autosectionlabel_prefix_document:
name = nodes.fully_normalize_name(docname + ":" + ref_name)
else:
name = nodes.fully_normalize_name(ref_name)
sectname = clean_astext(title)
if name in domain.labels:
logger.warning(
__("duplicate label %s, other instance in %s"),
name,
app.env.doc2path(domain.labels[name][0]),
location=node,
type="autosectionlabel",
subtype=docname,
)
domain.anonlabels[name] = docname, labelid
domain.labels[name] = docname, labelid, sectname
def setup(app: Sphinx) -> dict[str, Any]:
app.add_config_value("autosectionlabel_prefix_document", False, "env")
app.add_config_value("autosectionlabel_maxdepth", None, "env")
app.add_config_value("autosectionlabel_skip_docs", [], "env")
app.connect("doctree-read", register_sections_as_label)
return {
"version": "builtin",
"parallel_read_safe": True,
"parallel_write_safe": True,
}
================================================
FILE: docs/source/_exts/patched_html_translator.py
================================================
from sphinx.util.docutils import is_html5_writer_available
from sphinx.writers.html import HTMLTranslator
from sphinx.writers.html5 import HTML5Translator
class PatchedHTMLTranslator(
HTML5Translator if is_html5_writer_available() else HTMLTranslator
):
def starttag(self, node, tagname, *args, **attrs):
if (
tagname == "a"
and "target" not in attrs
and (
"external" in attrs.get("class", "")
or "external" in attrs.get("classes", [])
)
):
attrs["target"] = "_blank"
attrs["ref"] = "noopener noreferrer"
return super().starttag(node, tagname, *args, **attrs)
def setup(app):
app.set_translator("html", PatchedHTMLTranslator)
================================================
FILE: docs/source/_exts/reactpy_example.py
================================================
from __future__ import annotations
import re
from pathlib import Path
from typing import Any, ClassVar
from docs_app.examples import (
SOURCE_DIR,
get_example_files_by_name,
get_normalized_example_name,
)
from docutils.parsers.rst import directives
from docutils.statemachine import StringList
from sphinx.application import Sphinx
from sphinx.util.docutils import SphinxDirective
from sphinx_design.tabs import TabSetDirective
class WidgetExample(SphinxDirective):
has_content = False
required_arguments = 1
_next_id = 0
option_spec: ClassVar[dict[str, Any]] = {
"result-is-default-tab": directives.flag,
"activate-button": directives.flag,
}
def run(self):
example_name = get_normalized_example_name(
self.arguments[0],
# only used if example name starts with "/"
self.get_source_info()[0],
)
show_linenos = "linenos" in self.options
live_example_is_default_tab = "result-is-default-tab" in self.options
activate_result = "activate-button" not in self.options
ex_files = get_example_files_by_name(example_name)
if not ex_files:
src_file, line_num = self.get_source_info()
msg = f"Missing example named {example_name!r} referenced by document {src_file}:{line_num}"
raise ValueError(msg)
labeled_tab_items: list[tuple[str, Any]] = []
if len(ex_files) == 1:
labeled_tab_items.append(
(
"main.py",
_literal_include(
path=ex_files[0],
linenos=show_linenos,
),
)
)
else:
for path in sorted(
ex_files, key=lambda p: "" if p.name == "main.py" else p.name
):
labeled_tab_items.append(
(
path.name,
_literal_include(
path=path,
linenos=show_linenos,
),
)
)
result_tab_item = (
"🚀 result",
_interactive_widget(
name=example_name,
with_activate_button=not activate_result,
),
)
if live_example_is_default_tab:
labeled_tab_items.insert(0, result_tab_item)
else:
labeled_tab_items.append(result_tab_item)
return TabSetDirective(
"WidgetExample",
[],
{},
_make_tab_items(labeled_tab_items),
self.lineno - 2,
self.content_offset,
"",
self.state,
self.state_machine,
).run()
def _make_tab_items(labeled_content_tuples):
tab_items = ""
for label, content in labeled_content_tuples:
tab_items += _tab_item_template.format(
label=label,
content=content.replace("\n", "\n "),
)
return _string_to_nested_lines(tab_items)
def _literal_include(path: Path, linenos: bool):
try:
language = {
".py": "python",
".js": "javascript",
".json": "json",
}[path.suffix]
except KeyError:
msg = f"Unknown extension type {path.suffix!r}"
raise ValueError(msg) from None
return _literal_include_template.format(
name=str(path.relative_to(SOURCE_DIR)),
language=language,
options=_join_options(_get_file_options(path)),
)
def _join_options(option_strings: list[str]) -> str:
return "\n ".join(option_strings)
OPTION_PATTERN = re.compile(r"#\s:[\w-]+:.*")
def _get_file_options(file: Path) -> list[str]:
options = []
for line in file.read_text().split("\n"):
if not line.strip():
continue
if not line.startswith("#"):
break
if not OPTION_PATTERN.match(line):
continue
option_string = line[1:].strip()
if option_string:
options.append(option_string)
return options
def _interactive_widget(name, with_activate_button):
return _interactive_widget_template.format(
name=name,
activate_button_opt=":activate-button:" if with_activate_button else "",
)
_tab_item_template = """
.. tab-item:: {label}
{content}
"""
_interactive_widget_template = """
.. reactpy-view:: {name}
{activate_button_opt}
"""
_literal_include_template = """
.. literalinclude:: /{name}
:language: {language}
{options}
"""
def _string_to_nested_lines(content):
return StringList(content.split("\n"))
def setup(app: Sphinx) -> None:
app.add_directive("reactpy", WidgetExample)
================================================
FILE: docs/source/_exts/reactpy_view.py
================================================
import os
from typing import Any, ClassVar
from docs_app.examples import get_normalized_example_name
from docutils.nodes import raw
from docutils.parsers.rst import directives
from sphinx.application import Sphinx
from sphinx.util.docutils import SphinxDirective
_REACTPY_EXAMPLE_HOST = os.environ.get("REACTPY_DOC_EXAMPLE_SERVER_HOST", "")
_REACTPY_STATIC_HOST = os.environ.get("REACTPY_DOC_STATIC_SERVER_HOST", "/docs").rstrip(
"/"
)
class IteractiveWidget(SphinxDirective):
has_content = False
required_arguments = 1
_next_id = 0
option_spec: ClassVar[dict[str, Any]] = {
"activate-button": directives.flag,
"margin": float,
}
def run(self):
IteractiveWidget._next_id += 1
container_id = f"reactpy-widget-{IteractiveWidget._next_id}"
view_id = get_normalized_example_name(
self.arguments[0],
# only used if example name starts with "/"
self.get_source_info()[0],
)
return [
raw(
"",
f"""
""",
format="html",
)
]
def setup(app: Sphinx) -> None:
app.add_directive("reactpy-view", IteractiveWidget)
================================================
FILE: docs/source/_static/css/furo-theme-overrides.css
================================================
.sidebar-container {
width: 18em;
}
.sidebar-brand-text {
display: none;
}
================================================
FILE: docs/source/_static/css/larger-api-margins.css
================================================
:is(.data, .function, .class, .exception).py {
margin-top: 3em;
}
:is(.attribute, .method).py {
margin-top: 1.8em;
}
================================================
FILE: docs/source/_static/css/larger-headings.css
================================================
h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 1.5em !important;
font-weight: 900 !important;
}
================================================
FILE: docs/source/_static/css/reactpy-view.css
================================================
.interactive {
-webkit-transition: 0.1s ease-out;
-moz-transition: 0.1s ease-out;
-o-transition: 0.1s ease-out;
transition: 0.1s ease-out;
}
.widget-container {
padding: 15px;
overflow: auto;
background-color: var(--color-code-background);
min-height: 75px;
}
.widget-container .printout {
margin-top: 20px;
border-top: solid 2px var(--color-foreground-border);
padding-top: 20px;
}
.widget-container > div {
width: 100%;
}
.enable-widget-button {
padding: 10px;
color: #ffffff !important;
text-transform: uppercase;
text-decoration: none;
background: #526cfe;
border: 2px solid #526cfe !important;
transition: all 0.1s ease 0s;
box-shadow: 0 5px 10px var(--color-foreground-border);
}
.enable-widget-button:hover {
color: #526cfe !important;
background: #ffffff;
transition: all 0.1s ease 0s;
}
.enable-widget-button:focus {
outline: 0 !important;
transform: scale(0.98);
transition: all 0.1s ease 0s;
}
================================================
FILE: docs/source/_static/css/sphinx-design-overrides.css
================================================
.sd-card-body {
display: flex;
flex-direction: column;
align-items: stretch;
}
.sd-tab-content .highlight pre {
max-height: 700px;
overflow: auto;
}
.sd-card-title .sd-badge {
font-size: 1em;
}
================================================
FILE: docs/source/_static/css/widget-output-css-overrides.css
================================================
.widget-container h1,
.widget-container h2,
.widget-container h3,
.widget-container h4,
.widget-container h5,
.widget-container h6 {
margin: 0 !important;
}
================================================
FILE: docs/source/about/changelog.rst
================================================
.. THIS CHANGELOG HAS BEEN DEPRECATED. SEE TOP LEVEL CHANGELOG.md FILE INSTEAD. ---
Changelog
=========
.. note::
All notable changes to this project will be recorded in this document. The style of
which is based on `Keep a Changelog `__. The versioning
scheme for the project adheres to `Semantic Versioning `__.
.. Using the following categories, list your changes in this order:
.. [Added, Changed, Deprecated, Removed, Fixed, Security]
.. Don't forget to remove deprecated code on each major release!
Unreleased
----------
**Added**
- :pull:`1113` - Added support for Python 3.12, 3.13, and 3.14.
- :pull:`1281` - Added type hints to ``reactpy.html`` attributes.
- :pull:`1285` - Added support for nested components in web modules
- :pull:`1289` - Added support for inline JavaScript as event handlers or other attributes that expect a callable via ``reactpy.types.InlineJavaScript``
- :pull:`1308` - Event functions can now call ``event.preventDefault()`` and ``event.stopPropagation()`` methods directly on the event data object, rather than using the ``@event`` decorator.
- :pull:`1308` - Event data now supports accessing properties via dot notation (ex. ``event.target.value``).
- :pull:`1308` - Added ``reactpy.types.Event`` to provide type hints for the standard ``data`` function argument (for example ``def on_click(event: Event): ...``).
- :pull:`1113` - Added ``asgi`` and ``jinja`` installation extras (for example ``pip install reactpy[asgi, jinja]``).
- :pull:`1113` - Added ``reactpy.executors.asgi.ReactPy`` that can be used to run ReactPy in standalone mode via ASGI.
- :pull:`1269` - Added ``reactpy.executors.asgi.ReactPyCsr`` that can be used to run ReactPy in standalone mode via ASGI, but rendered entirely client-sided.
- :pull:`1113` - Added ``reactpy.executors.asgi.ReactPyMiddleware`` that can be used to utilize ReactPy within any ASGI compatible framework.
- :pull:`1269` - Added ``reactpy.templatetags.ReactPyJinja`` that can be used alongside ``ReactPyMiddleware`` to embed several ReactPy components into your existing application. This includes the following template tags: ``{% component %}``, ``{% pyscript_component %}``, and ``{% pyscript_setup %}``.
- :pull:`1269` - Added ``reactpy.pyscript_component`` that can be used to embed ReactPy components into your existing application.
- :pull:`1264` - Added ``reactpy.use_async_effect`` hook.
- :pull:`1281` - Added ``reactpy.Vdom`` primitive interface for creating VDOM dictionaries.
- :pull:`1307` - Added ``reactpy.reactjs.component_from_file`` to import ReactJS components from a file.
- :pull:`1307` - Added ``reactpy.reactjs.component_from_url`` to import ReactJS components from a URL.
- :pull:`1307` - Added ``reactpy.reactjs.component_from_string`` to import ReactJS components from a string.
- :pull:`1314` - Added ``reactpy.reactjs.component_from_npm`` to import ReactJS components from NPM.
- :pull:`1314` - Added ``reactpy.h`` as a shorthand alias for ``reactpy.html``.
**Changed**
- :pull:`1314` - The ``key`` attribute is now stored within ``attributes`` in the VDOM spec.
- :pull:`1251` - Substitute client-side usage of ``react`` with ``preact``.
- :pull:`1239` - Script elements no longer support behaving like effects. They now strictly behave like plain HTML scripts.
- :pull:`1255` - The ``reactpy.html`` module has been modified to allow for auto-creation of any HTML nodes. For example, you can create a ```` element by calling ``html.data_table()``.
- :pull:`1256` - Change ``set_state`` comparison method to check equality with ``==`` more consistently.
- :pull:`1257` - Add support for rendering ``@component`` children within ``vdom_to_html``.
- :pull:`1113` - Renamed the ``use_location`` hook's ``search`` attribute to ``query_string``.
- :pull:`1113` - Renamed the ``use_location`` hook's ``pathname`` attribute to ``path``.
- :pull:`1113` - Renamed ``reactpy.config.REACTPY_DEBUG_MODE`` to ``reactpy.config.REACTPY_DEBUG``.
- :pull:`1263` - ReactPy no longer auto-converts ``snake_case`` props to ``camelCase``. It is now the responsibility of the user to ensure that props are in the correct format.
- :pull:`1196` - Rewrite the ``event-to-object`` package to be more robust at handling properties on events.
- :pull:`1312` - Custom JS components will now automatically assume you are using ReactJS in the absence of a ``bind`` function.
- :pull:`1312` - Refactor layout rendering logic to improve readability and maintainability.
- :pull:`1113` - ``@reactpy/client`` now exports ``React`` and ``ReactDOM``.
- :pull:`1281` - ``reactpy.html`` will now automatically flatten lists recursively (ex. ``reactpy.html(["child1", ["child2"]])``)
- :pull:`1278` - ``reactpy.utils.reactpy_to_string`` will now retain the user's original casing for ``data-*`` and ``aria-*`` attributes.
- :pull:`1278` - ``reactpy.utils.string_to_reactpy`` has been upgraded to handle more complex scenarios without causing ReactJS rendering errors.
- :pull:`1281` - ``reactpy.core.vdom._CustomVdomDictConstructor`` has been moved to ``reactpy.types.CustomVdomConstructor``.
- :pull:`1281` - ``reactpy.core.vdom._EllipsisRepr`` has been moved to ``reactpy.types.EllipsisRepr``.
- :pull:`1281` - ``reactpy.types.VdomDictConstructor`` has been renamed to ``reactpy.types.VdomConstructor``.
- :pull:`1312` - ``REACTPY_ASYNC_RENDERING`` can now de-duplicate and cascade renders where necessary.
- :pull:`1312` - ``REACTPY_ASYNC_RENDERING`` is now defaulted to ``True`` for up to 40x performance improvements in environments with high concurrency.
**Deprecated**
-:pull:`1307` - ``reactpy.web.module_from_file`` is deprecated. Use ``reactpy.reactjs.component_from_file`` instead.
-:pull:`1307` - ``reactpy.web.module_from_url`` is deprecated. Use ``reactpy.reactjs.component_from_url`` instead.
-:pull:`1307` - ``reactpy.web.module_from_string`` is deprecated. Use ``reactpy.reactjs.component_from_string`` instead.
-:pull:`1307` - ``reactpy.web.export`` is deprecated. Use ``reactpy.reactjs.component_from_*`` instead.
-:pull:`1314` - ``reactpy.web.*`` is deprecated. Use ``reactpy.reactjs.*`` instead.
**Removed**
- :pull:`1113` - Removed support for Python 3.9 and 3.10.
- :pull:`1255` - Removed the ability to import ``reactpy.html.*`` elements directly. You must now call ``html.*`` to access the elements.
- :pull:`1113` - Removed backend specific installation extras (such as ``pip install reactpy[starlette]``).
- :pull:`1264` - Removed support for async functions within ``reactpy.use_effect`` hook. Use ``reactpy.use_async_effect`` instead.
- :pull:`1113` - Removed deprecated function ``module_from_template``.
- :pull:`1311` - Removed deprecated exception type ``reactpy.core.serve.Stop``.
- :pull:`1311` - Removed deprecated component ``reactpy.widgets.hotswap``.
- :pull:`1255` - Removed ``reactpy.sample`` module.
- :pull:`1255` - Removed ``reactpy.svg`` module. Contents previously within ``reactpy.svg.*`` can now be accessed via ``reactpy.html.svg.*``.
- :pull:`1255` - Removed ``reactpy.html._`` function. Use ``reactpy.html(...)`` or ``reactpy.html.fragment(...)`` instead.
- :pull:`1113` - Removed ``reactpy.run``. See the documentation for the new method to run ReactPy applications.
- :pull:`1113` - Removed ``reactpy.backend.*``. See the documentation for the new method to run ReactPy applications.
- :pull:`1113` - Removed ``reactpy.core.types`` module. Use ``reactpy.types`` instead.
- :pull:`1278` - Removed ``reactpy.utils.html_to_vdom``. Use ``reactpy.utils.string_to_reactpy`` instead.
- :pull:`1278` - Removed ``reactpy.utils.vdom_to_html``. Use ``reactpy.utils.reactpy_to_string`` instead.
- :pull:`1281` - Removed ``reactpy.vdom``. Use ``reactpy.Vdom`` instead.
- :pull:`1281` - Removed ``reactpy.core.make_vdom_constructor``. Use ``reactpy.Vdom`` instead.
- :pull:`1281` - Removed ``reactpy.core.custom_vdom_constructor``. Use ``reactpy.Vdom`` instead.
- :pull:`1311` - Removed ``reactpy.Layout`` top-level re-export. Use ``reactpy.core.layout.Layout`` instead.
- :pull:`1312` - Removed ``reactpy.types.LayoutType``. Use ``reactpy.types.BaseLayout`` instead.
- :pull:`1312` - Removed ``reactpy.types.ContextProviderType``. Use ``reactpy.types.ContextProvider`` instead.
- :pull:`1312` - Removed ``reactpy.core.hooks._ContextProvider``. Use ``reactpy.types.ContextProvider`` instead.
- :pull:`1314` - Removed ``reactpy.web.utils``. Use ``reactpy.reactjs.utils`` instead.
**Fixed**
- :pull:`1239` - Fixed a bug where script elements would not render to the DOM as plain text.
- :pull:`1271` - Fixed a bug where the ``key`` property provided within server-side ReactPy code was failing to propagate to the front-end JavaScript components.
- :pull:`1254` - Fixed a bug where ``RuntimeError("Hook stack is in an invalid state")`` errors could be generated when using a webserver that reuses threads.
- :pull:`1314` - Allow for ReactPy and ReactJS components to be arbitrarily inserted onto the page with any possible hierarchy.
v1.1.0
------
:octicon:`milestone` *released on 2024-11-24*
**Fixed**
- :pull:`1118` - ``module_from_template`` is broken with a recent release of ``requests``
- :pull:`1131` - ``module_from_template`` did not work when using Flask backend
- :pull:`1200` - Fixed ``UnicodeDecodeError`` when using ``reactpy.web.export``
- :pull:`1224` - Fixed needless unmounting of JavaScript components during each ReactPy render.
- :pull:`1126` - Fixed missing ``event["target"]["checked"]`` on checkbox inputs
- :pull:`1191` - Fixed missing static files on `sdist` Python distribution
**Added**
- :pull:`1165` - Allow concurrently rendering discrete component trees - enable this
experimental feature by setting ``REACTPY_ASYNC_RENDERING=true``. This improves
the overall responsiveness of your app in situations where larger renders would
otherwise block smaller renders from executing.
**Changed**
- :pull:`1171` - Previously ``None``, when present in an HTML element, would render as
the string ``"None"``. Now ``None`` will not render at all. This is now equivalent to
how ``None`` is handled when returned from components.
- :pull:`1210` - Move hooks from ``reactpy.backend.hooks`` into ``reactpy.core.hooks``.
**Deprecated**
- :pull:`1171` - The ``Stop`` exception. Recent releases of ``anyio`` have made this
exception difficult to use since it now raises an ``ExceptionGroup``. This exception
was primarily used for internal testing purposes and so is now deprecated.
- :pull:`1210` - Deprecate ``reactpy.backend.hooks`` since the hooks have been moved into
``reactpy.core.hooks``.
v1.0.2
------
:octicon:`milestone` *released on 2023-07-03*
**Fixed**
- :issue:`1086` - fix rendering bug when children change positions (via :pull:`1085`)
v1.0.1
------
:octicon:`milestone` *released on 2023-06-16*
**Changed**
- :pull:`1050` - Warn and attempt to fix missing mime types, which can result in ``reactpy.run`` not working as expected.
- :pull:`1051` - Rename ``reactpy.backend.BackendImplementation`` to ``reactpy.backend.BackendType``
- :pull:`1051` - Allow ``reactpy.run`` to fail in more predictable ways
**Fixed**
- :issue:`930` - better traceback for JSON serialization errors (via :pull:`1008`)
- :issue:`437` - explain that JS component attributes must be JSON (via :pull:`1008`)
- :pull:`1051` - Fix ``reactpy.run`` port assignment sometimes attaching to in-use ports on Windows
- :pull:`1051` - Fix ``reactpy.run`` not recognizing ``fastapi``
v1.0.0
------
:octicon:`milestone` *released on 2023-03-14*
No changes.
v1.0.0-a6
---------
:octicon:`milestone` *released on 2023-02-23*
**Fixed**
- :pull:`936` - remaining issues from :pull:`934`
v1.0.0-a5
---------
:octicon:`milestone` *released on 2023-02-21*
**Fixed**
- :pull:`934` - minor issues with camelCase rewrite CLI utility
v1.0.0-a4
---------
:octicon:`milestone` *released on 2023-02-21*
**Changed**
- :pull:`919` - Reverts :pull:`841` as per the conclusion in :discussion:`916`. but
preserves the ability to declare attributes with snake_case.
**Deprecated**
- :pull:`919` - Declaration of keys via keyword arguments in standard elements. A script
has been added to automatically convert old usages where possible.
v1.0.0-a3
---------
:octicon:`milestone` *released on 2023-02-02*
**Fixed**
- :pull:`908` - minor type hint issue with ``VdomDictConstructor``
**Removed**
- :pull:`907` - accidental import of reactpy.testing
v1.0.0-a2
---------
:octicon:`milestone` *released on 2023-01-31*
**Reverted**
- :pull:`901` - reverts :pull:`886` due to :issue:`896`
**Fixed**
- :issue:`896` - Stale event handlers after disconnect/reconnect cycle
- :issue:`898` - Fixed CLI not registered as entry point
v1.0.0-a1
---------
:octicon:`milestone` *released on 2023-01-28*
**Changed**
- :pull:`841` - Revamped element constructor interface. Now instead of passing a
dictionary of attributes to element constructors, attributes are declared using
keyword arguments. For example, instead of writing:
.. code-block::
html.div({"className": "some-class"}, "some", "text")
You now should write:
.. code-block::
html.div("some", "text", class_name="some-class")
.. note::
All attributes are written using ``snake_case``.
In conjunction, with these changes, ReactPy now supplies a command line utility that
makes a "best effort" attempt to automatically convert code to the new API. Usage of
this utility is as follows:
.. code-block:: bash
reactpy update-html-usages [PATHS]
Where ``[PATHS]`` is any number of directories or files that should be rewritten.
.. warning::
After running this utility, code comments and formatting may have been altered. It's
recommended that you run a code formatting tool like `Black
`__ and manually review and replace any comments that
may have been moved.
**Fixed**
- :issue:`755` - unification of component and VDOM constructor interfaces. See above.
v0.44.0
-------
:octicon:`milestone` *released on 2023-01-27*
**Deprecated**
- :pull:`876` - ``reactpy.widgets.hotswap``. The function has no clear uses outside of some
internal applications. For this reason it has been deprecated.
**Removed**
- :pull:`886` - Ability to access element value from events via `event['value']` key.
Instead element value should be accessed via `event['target']['value']`. Originally
deprecated in :ref:`v0.34.0`.
- :pull:`886` - old misspelled option ``reactpy.config.REACTPY_WED_MODULES_DIR``. Originally
deprecated in :ref:`v0.36.1`.
v0.43.0
-------
:octicon:`milestone` *released on 2023-01-09*
**Deprecated**
- :pull:`870` - ``ComponentType.()``. This method was implemented based on
reading the React/Preact source code. As it turns out though it seems like it's mostly
a vestige from the fact that both these libraries still support class-based
components. The ability for components to not render also caused several bugs.
**Fixed**
- :issue:`846` - Nested context does no update value if outer context should not render.
- :issue:`847` - Detached model state on render of context consumer if unmounted and
context value does not change.
v0.42.0
-------
:octicon:`milestone` *released on 2022-12-02*
**Added**
- :pull:`835` - Ability to customize the ```` element of ReactPy's built-in client.
- :pull:`835` - ``vdom_to_html`` utility function.
- :pull:`843` - Ability to subscribe to changes that are made to mutable options.
- :pull:`832` - ``del_html_head_body_transform`` to remove ````, ````, and ```` while preserving children.
- :pull:`699` - Support for form element serialization
**Fixed**
- :issue:`582` - ``REACTPY_DEBUG_MODE`` is now mutable and can be changed at runtime
- :pull:`832` - Fix ``html_to_vdom`` improperly removing ````, ````, and ```` nodes.
**Removed**
- :pull:`832` - Removed ``reactpy.html.body`` as it is currently unusable due to technological limitations, and thus not needed.
- :pull:`840` - remove ``REACTPY_FEATURE_INDEX_AS_DEFAULT_KEY`` option
- :pull:`835` - ``serve_static_files`` option from backend configuration
**Deprecated**
- :commit:`8f3785b` - Deprecated ``module_from_template``
v0.41.0
-------
:octicon:`milestone` *released on 2022-11-01*
**Changed**
- :pull:`823` - The hooks ``use_location`` and ``use_scope`` are no longer
implementation specific and are now available as top-level imports. Instead of each
backend defining these hooks, backends establish a ``ConnectionContext`` with this
information.
- :pull:`824` - ReactPy's built-in backend server now expose the following routes:
- ``/_reactpy/assets/``
- ``/_reactpy/stream/``
- ``/_reactpy/modules/``
- ``//``
This should allow the browser to cache static resources. Even if your ``url_prefix``
is ``/_reactpy``, your app should still work as expected. Though if you're using
``reactpy-router``, ReactPy's server routes will always take priority.
- :pull:`824` - Backend implementations now strip any URL prefix in the pathname for
``use_location``.
- :pull:`827` - ``use_state`` now returns a named tuple with ``value`` and ``set_value``
fields. This is convenient for adding type annotations if the initial state value is
not the same as the values you might pass to the state setter. Where previously you
might have to do something like:
.. code-block::
value: int | None = None
value, set_value = use_state(value)
Now you can annotate your state using the ``State`` class:
.. code-block::
state: State[int | None] = use_state(None)
# access value and setter
state.value
state.set_value
# can still destructure if you need to
value, set_value = state
**Added**
- :pull:`823` - There is a new ``use_connection`` hook which returns a ``Connection``
object. This ``Connection`` object contains a ``location`` and ``scope``, along with
a ``carrier`` which is unique to each backend implementation.
v0.40.2
-------
:octicon:`milestone` *released on 2022-09-13*
**Changed**
- :pull:`809` - Avoid the use of JSON patch for diffing models.
v0.40.1
-------
:octicon:`milestone` *released on 2022-09-11*
**Fixed**
- :issue:`806` - Child models after a component fail to render
v0.40.0 (yanked)
----------------
:octicon:`milestone` *released on 2022-08-13*
**Fixed**
- :issue:`777` - Fix edge cases where ``html_to_vdom`` can fail to convert HTML
- :issue:`789` - Conditionally rendered components cannot use contexts
- :issue:`773` - Use strict equality check for text, numeric, and binary types in hooks
- :issue:`801` - Accidental mutation of old model causes invalid JSON Patch
**Changed**
- :pull:`123` - set default timeout on playwright page for testing
- :pull:`787` - Track contexts in hooks as state
- :pull:`787` - remove non-standard ``name`` argument from ``create_context``
**Added**
- :pull:`123` - ``asgiref`` as a dependency
- :pull:`795` - ``lxml`` as a dependency
v0.39.0
-------
:octicon:`milestone` *released on 2022-06-20*
**Fixed**
- :pull:`763` - ``No module named 'reactpy.server'`` from ``reactpy.run``
- :pull:`749` - Setting appropriate MIME type for web modules in `sanic` server implementation
**Changed**
- :pull:`763` - renamed various:
- ``reactpy.testing.server -> reactpy.testing.backend``
- ``ServerFixture -> BackendFixture``
- ``DisplayFixture.server -> DisplayFixture.backend``
- :pull:`765` - ``exports_default`` parameter is removed from ``module_from_template``.
**Added**
- :pull:`765` - ability to specify versions with module templates (e.g.
``module_from_template("react@^17.0.0", ...)``).
v0.38.1
-------
:octicon:`milestone` *released on 2022-04-15*
**Fixed**
- `reactive-python/reactpy-jupyter#22 `__ -
a missing file extension was causing a problem with WebPack.
v0.38.0
-------
:octicon:`milestone` *released on 2022-04-15*
No changes.
v0.38.0-a4
----------
:octicon:`milestone` *released on 2022-04-15*
**Added**
- :pull:`733` - ``use_debug_value`` hook
**Changed**
- :pull:`733` - renamed ``assert_reactpy_logged`` testing util to ``assert_reactpy_did_log``
v0.38.0-a3
----------
:octicon:`milestone` *released on 2022-04-15*
**Changed**
- :pull:`730` - Layout context management is not async
v0.38.0-a2
----------
:octicon:`milestone` *released on 2022-04-14*
**Added**
- :pull:`721` - Implement ``use_location()`` hook. Navigating to any route below the
root of the application will be reflected in the ``location.pathname``. This operates
in concert with how ReactPy's configured routes have changed. This will ultimately work
towards resolving :issue:`569`.
**Changed**
- :pull:`721` - The routes ReactPy configures on apps have changed
.. code-block:: text
prefix/_api/modules/* web modules
prefix/_api/stream websocket endpoint
prefix/* client react app
This means that ReactPy's client app is available at any route below the configured
``url_prefix`` besides ``prefix/_api``. The ``_api`` route will likely remain a route
which is reserved by ReactPy. The route navigated to below the ``prefix`` will be shown
in ``use_location``.
- :pull:`721` - ReactPy's client now uses Preact instead of React
- :pull:`726` - Renamed ``reactpy.server`` to ``reactpy.backend``. Other references to "server
implementations" have been renamed to "backend implementations" throughout the
documentation and code.
**Removed**
- :pull:`721` - ``redirect_root`` server option
v0.38.0-a1
----------
:octicon:`milestone` *released on 2022-03-27*
**Changed**
- :pull:`703` - How ReactPy integrates with servers. ``reactpy.run`` no longer accepts an app
instance to discourage use outside of testing. ReactPy's server implementations now
provide ``configure()`` functions instead. ``reactpy.testing`` has been completely
reworked in order to support async web drivers
- :pull:`703` - ``PerClientStateServer`` has been functionally replaced by ``configure``
**Added**
- :issue:`669` - Access to underlying server requests via contexts
**Removed**
- :issue:`669` - Removed ``reactpy.widgets.multiview`` since basic routing view ``use_scope`` is
now possible as well as all ``SharedClientStateServer`` implementations.
**Fixed**
- :issue:`591` - ReactPy's test suite no longer uses sync web drivers
- :issue:`678` - Updated Sanic requirement to ``>=21``
- :issue:`657` - How we advertise ``reactpy.run``
v0.37.2
-------
:octicon:`milestone` *released on 2022-03-27*
**Changed**
- :pull:`701` - The name of ``proto`` modules to ``types`` and added a top level
``reactpy.types`` module
**Fixed**
- :pull:`716` - A typo caused ReactPy to use the insecure ``ws`` web-socket protocol on
pages loaded with ``https`` instead of the secure ``wss`` protocol
v0.37.1
-------
:octicon:`milestone` *released on 2022-03-05*
No changes.
v0.37.1-a2
----------
:octicon:`milestone` *released on 2022-03-02*
**Fixed:**
- :issue:`684` - Revert :pull:`694` and by making ``value`` uncontrolled client-side
v0.37.1-a1
----------
:octicon:`milestone` *released on 2022-02-28*
**Fixed:**
- :issue:`684` - ``onChange`` event for inputs missing key strokes
v0.37.0
-------
:octicon:`milestone` *released on 2022-02-27*
**Added:**
- :issue:`682` - Support for keys in HTML fragments
- :pull:`585` - Use Context Hook
**Fixed:**
- :issue:`690` - React warning about set state in unmounted component
- :pull:`688` - Missing reset of schedule_render_later flag
----
Releases below do not use the "Keep a Changelog" style guidelines.
----
v0.36.3
-------
:octicon:`milestone` *released on 2022-02-18*
Misc bug fixes along with a minor improvement that allows components to return ``None``
to render nothing.
**Closed Issues**
- All child states wiped upon any child key change - :issue:`652`
- Allow NoneType returns within components - :issue:`538`
**Merged Pull Requests**
- fix #652 - :pull:`672`
- Fix 663 - :pull:`667`
v0.36.2
-------
:octicon:`milestone` *released on 2022-02-02*
Hot fix for newly introduced ``DeprecatedOption``:
- :commit:`c146dfb264cbc3d2256a62efdfe9ccf62c795b01`
v0.36.1
-------
:octicon:`milestone` *released on 2022-02-02*
Includes bug fixes and renames the configuration option ``REACTPY_WED_MODULES_DIR`` to
``REACTPY_WEB_MODULES_DIR`` with a corresponding deprecation warning.
**Closed Issues**
- Fix Key Error When Cleaning Up Event Handlers - :issue:`640`
- Update Script Tag Behavior - :issue:`628`
**Merged Pull Requests**
- mark old state as None if unmounting - :pull:`641`
- rename REACTPY_WED_MODULES_DIR to REACTPY_WEB_MODULES_DIR - :pull:`638`
v0.36.0
-------
:octicon:`milestone` *released on 2022-01-30*
This release includes an important fix for errors produced after :pull:`623` was merged.
In addition there is not a new ``http.script`` element which can behave similarly to a
standard HTML ``
================================================
FILE: docs/source/guides/getting-started/_static/embed-reactpy-view/index.html
================================================
Example App
This is an Example App
Just below is an embedded ReactPy view...
================================================
FILE: docs/source/guides/getting-started/_static/embed-reactpy-view/main.py
================================================
from sanic import Sanic
from sanic.response import file
from reactpy import component, html
from reactpy.backend.sanic import Options, configure
app = Sanic("MyApp")
@app.route("/")
async def index(request):
return await file("index.html")
@component
def ReactPyView():
return html.code("This text came from an ReactPy App")
configure(app, ReactPyView, Options(url_prefix="/_reactpy"))
app.run(host="127.0.0.1", port=5000)
================================================
FILE: docs/source/guides/getting-started/index.rst
================================================
Getting Started
===============
.. toctree::
:hidden:
installing-reactpy
running-reactpy
.. dropdown:: :octicon:`bookmark-fill;2em` What You'll Learn
:color: info
:animate: fade-in
:open:
.. grid:: 1 2 2 2
.. grid-item-card:: :octicon:`tools` Installing ReactPy
:link: installing-reactpy
:link-type: doc
Learn how ReactPy can be installed in a variety of different ways - with
different web servers and even in different frameworks.
.. grid-item-card:: :octicon:`play` Running ReactPy
:link: running-reactpy
:link-type: doc
See how ReactPy can be run with a variety of different production servers or be
added to existing applications.
The fastest way to get started with ReactPy is to try it out in a `Juptyer Notebook
`__.
If you want to use a Notebook to work through the examples shown in this documentation,
you'll need to replace calls to ``reactpy.run(App)`` with a line at the end of each cell
that constructs the ``App()`` in question. If that doesn't make sense, the introductory
notebook linked below will demonstrate how to do this:
.. card::
:link: https://mybinder.org/v2/gh/reactive-python/reactpy-jupyter/main?urlpath=lab/tree/notebooks/introduction.ipynb
.. image:: _static/reactpy-in-jupyterlab.gif
:scale: 72%
:align: center
Section 1: Installing ReactPy
-----------------------------
The next fastest option is to install ReactPy along with a supported server (like
``starlette``) with ``pip``:
.. code-block:: bash
pip install "reactpy[starlette]"
To check that everything is working you can run the sample application:
.. code-block:: bash
python -c "import reactpy; reactpy.run(reactpy.sample.SampleApp)"
.. note::
This launches a simple development server which is good enough for testing, but
probably not what you want to use in production. When deploying in production,
there's a number of different ways of :ref:`running ReactPy `.
You should then see a few log messages:
.. code-block:: text
2022-03-27T11:58:59-0700 | WARNING | You are running a development server. Change this before deploying in production!
2022-03-27T11:58:59-0700 | INFO | Running with 'Starlette' at http://127.0.0.1:8000
The second log message includes a URL indicating where you should go to view the app.
That will usually be http://127.0.0.1:8000. Once you go to that URL you should see
something like this:
.. card::
.. reactpy-view:: _examples/sample_app
If you get a ``RuntimeError`` similar to the following:
.. code-block:: text
Found none of the following builtin server implementations...
Then be sure you run ``pip install "reactpy[starlette]"`` instead of just ``reactpy``. For
anything else, report your issue in ReactPy's :discussion-type:`discussion forum
`.
.. card::
:link: installing-reactpy
:link-type: doc
:octicon:`book` Read More
^^^^^^^^^^^^^^^^^^^^^^^^^
Learn how ReactPy can be installed in a variety of different ways - with different web
servers and even in different frameworks.
Section 2: Running ReactPy
--------------------------
Once you've :ref:`installed ReactPy `, you'll want to learn how to run an
application. Throughout most of the examples in this documentation, you'll see the
:func:`~reactpy.backend.utils.run` function used. While it's convenient tool for
development it shouldn't be used in production settings - it's slow, and could leak
secrets through debug log messages.
.. reactpy:: _examples/hello_world
.. card::
:link: running-reactpy
:link-type: doc
:octicon:`book` Read More
^^^^^^^^^^^^^^^^^^^^^^^^^
See how ReactPy can be run with a variety of different production servers or be
added to existing applications.
================================================
FILE: docs/source/guides/getting-started/installing-reactpy.rst
================================================
Installing ReactPy
==================
You will typically ``pip`` install ReactPy to alongside one of it's natively supported
backends. For example, if we want to run ReactPy using the `Starlette
`__ backend you would run
.. code-block:: bash
pip install "reactpy[starlette]"
If you want to install a "pure" version of ReactPy **without a backend implementation**
you can do so without any installation extras. You might do this if you wanted to
:ref:`use a custom backend ` or if you wanted to manually pin
the dependencies for your chosen backend:
.. code-block:: bash
pip install reactpy
Native Backends
---------------
ReactPy includes built-in support for a variety backend implementations. To install the
required dependencies for each you should substitute ``starlette`` from the ``pip
install`` command above with one of the options below:
- ``fastapi`` - https://fastapi.tiangolo.com
- ``flask`` - https://palletsprojects.com/p/flask/
- ``sanic`` - https://sanicframework.org
- ``starlette`` - https://www.starlette.io/
- ``tornado`` - https://www.tornadoweb.org/en/stable/
If you need to, you can install more than one option by separating them with commas:
.. code-block:: bash
pip install "reactpy[fastapi,flask,sanic,starlette,tornado]"
Once this is complete you should be able to :ref:`run ReactPy ` with your
chosen implementation.
Other Backends
--------------
While ReactPy can run in a variety of contexts, sometimes frameworks require extra work in
order to integrate with them. In these cases, the ReactPy team distributes bindings for
those frameworks as separate Python packages. For documentation on how to install and
run ReactPy in these supported frameworks, follow the links below:
.. raw:: html
.. role:: transparent-text-color
.. We add transparent-text-color to the text so it's not visible, but it's still
.. searchable.
.. grid:: 3
.. grid-item-card::
:link: https://github.com/reactive-python/reactpy-django
:img-background: _static/logo-django.svg
:class-card: card-logo-image
:transparent-text-color:`Django`
.. grid-item-card::
:link: https://github.com/reactive-python/reactpy-jupyter
:img-background: _static/logo-jupyter.svg
:class-card: card-logo-image
:transparent-text-color:`Jupyter`
.. grid-item-card::
:link: https://github.com/reactive-python/reactpy-dash
:img-background: _static/logo-plotly.svg
:class-card: card-logo-image
:transparent-text-color:`Plotly Dash`
For Development
---------------
If you want to contribute to the development of ReactPy or modify it, you'll want to
install a development version of ReactPy. This involves cloning the repository where ReactPy's
source is maintained, and setting up a :ref:`Contributor Guide`. From there you'll
be able to modifying ReactPy's source code and :ref:`run its tests ` to
ensure the modifications you've made are backwards compatible. If you want to add a new
feature to ReactPy you should write your own test that validates its behavior.
If you have questions about how to modify ReactPy or help with its development, be sure to
:discussion:`start a discussion `. The ReactPy team are always
excited to welcome new contributions and contributors
of all kinds
.. card::
:link: /about/contributor-guide
:link-type: doc
:octicon:`book` Read More
^^^^^^^^^^^^^^^^^^^^^^^^^
Learn more about how to contribute to the development of ReactPy.
================================================
FILE: docs/source/guides/getting-started/running-reactpy.rst
================================================
Running ReactPy
===============
The simplest way to run ReactPy is with the :func:`~reactpy.backend.utils.run` function. This
is the method you'll see used throughout this documentation. However, this executes your
application using a development server which is great for testing, but probably not what
if you're :ref:`deploying in production `. Below are some
more robust and performant ways of running ReactPy with various supported servers.
Running ReactPy in Production
-----------------------------
The first thing you'll need to do if you want to run ReactPy in production is choose a
backend implementation and follow its documentation on how to create and run an
application. This is the backend :ref:`you probably chose ` when
installing ReactPy. Then you'll need to configure that application with an ReactPy view. We
show the basics of how to set up, and then run, each supported backend below, but all
implementations will follow a pattern similar to the following:
.. code-block::
from my_chosen_backend import Application
from reactpy import component, html
from reactpy.backend.my_chosen_backend import configure
@component
def HelloWorld():
return html.h1("Hello, world!")
app = Application()
configure(app, HelloWorld)
You'll then run this ``app`` using an `ASGI `__
or `WSGI `__ server from the command line.
Running with `FastAPI `__
.......................................................
.. reactpy:: _examples/run_fastapi
Then assuming you put this in ``main.py``, you can run the ``app`` using the `Uvicorn
`__ ASGI server:
.. code-block:: bash
uvicorn main:app
Running with `Flask `__
.............................................................
.. reactpy:: _examples/run_flask
Then assuming you put this in ``main.py``, you can run the ``app`` using the `Gunicorn
`__ WSGI server:
.. code-block:: bash
gunicorn main:app
Running with `Sanic `__
...................................................
.. reactpy:: _examples/run_sanic
Then assuming you put this in ``main.py``, you can run the ``app`` using Sanic's builtin
server:
.. code-block:: bash
sanic main.app
Running with `Starlette `__
......................................................
.. reactpy:: _examples/run_starlette
Then assuming you put this in ``main.py``, you can run the application using the
`Uvicorn `__ ASGI server:
.. code-block:: bash
uvicorn main:app
Running with `Tornado `__
................................................................
.. reactpy:: _examples/run_tornado
Tornado is run using it's own builtin server rather than an external WSGI or ASGI
server.
Running ReactPy in Debug Mode
-----------------------------
ReactPy provides a debug mode that is turned off by default. This can be enabled when you
run your application by setting the ``REACTPY_DEBUG`` environment variable.
.. tab-set::
.. tab-item:: Unix Shell
.. code-block::
export REACTPY_DEBUG=1
python my_reactpy_app.py
.. tab-item:: Command Prompt
.. code-block:: text
set REACTPY_DEBUG=1
python my_reactpy_app.py
.. tab-item:: PowerShell
.. code-block:: powershell
$env:REACTPY_DEBUG = "1"
python my_reactpy_app.py
.. danger::
Leave debug mode off in production!
Among other things, running in this mode:
- Turns on debug log messages
- Adds checks to ensure the :ref:`VDOM` spec is adhered to
- Displays error messages that occur within your app
Errors will be displayed where the uppermost component is located in the view:
.. reactpy:: _examples/debug_error_example
Backend Configuration Options
-----------------------------
ReactPy's various backend implementations come with ``Options`` that can be passed to their
respective ``configure()`` functions in the following way:
.. code-block::
from reactpy.backend. import configure, Options
configure(app, MyComponent, Options(...))
To learn more read about the options for your chosen backend ````:
- :class:`reactpy.backend.fastapi.Options`
- :class:`reactpy.backend.flask.Options`
- :class:`reactpy.backend.sanic.Options`
- :class:`reactpy.backend.starlette.Options`
- :class:`reactpy.backend.tornado.Options`
Embed in an Existing Webpage
----------------------------
ReactPy provides a Javascript client called ``@reactpy/client`` that can be used to embed
ReactPy views within an existing applications. This is actually how the interactive
examples throughout this documentation have been created. You can try this out by
embedding one the examples from this documentation into your own webpage:
.. tab-set::
.. tab-item:: HTML
.. literalinclude:: _static/embed-doc-ex.html
:language: html
.. tab-item:: ▶️ Result
.. raw:: html
:file: _static/embed-doc-ex.html
.. note::
For more information on how to use the client see the :ref:`Javascript API`
reference. Or if you need to, your can :ref:`write your own backend implementation
`.
As mentioned though, this is connecting to the server that is hosting this
documentation. If you want to connect to a view from your own server, you'll need to
change the URL above to one you provide. One way to do this might be to add to an
existing application. Another would be to run ReactPy in an adjacent web server instance
that you coordinate with something like `NGINX `__. For the sake
of simplicity, we'll assume you do something similar to the following in an existing
Python app:
.. tab-set::
.. tab-item:: main.py
.. literalinclude:: _static/embed-reactpy-view/main.py
:language: python
.. tab-item:: index.html
.. literalinclude:: _static/embed-reactpy-view/index.html
:language: html
After running ``python main.py``, you should be able to navigate to
``http://127.0.0.1:8000/index.html`` and see:
.. card::
:text-align: center
.. image:: _static/embed-reactpy-view/screenshot.png
:width: 500px
================================================
FILE: docs/source/guides/managing-state/combining-contexts-and-reducers/index.rst
================================================
Combining Contexts and Reducers 🚧
==================================
.. note::
Under construction 🚧
================================================
FILE: docs/source/guides/managing-state/deeply-sharing-state-with-contexts/index.rst
================================================
Deeply Sharing State with Contexts 🚧
=====================================
.. note::
Under construction 🚧
================================================
FILE: docs/source/guides/managing-state/how-to-structure-state/index.rst
================================================
.. _Structuring Your State:
How to Structure State 🚧
=========================
.. note::
Under construction 🚧
================================================
FILE: docs/source/guides/managing-state/index.rst
================================================
Managing State
==============
.. toctree::
:hidden:
how-to-structure-state/index
sharing-component-state/index
when-and-how-to-reset-state/index
simplifying-updates-with-reducers/index
deeply-sharing-state-with-contexts/index
combining-contexts-and-reducers/index
.. dropdown:: :octicon:`bookmark-fill;2em` What You'll Learn
:color: info
:animate: fade-in
:open:
.. grid:: 1 2 2 2
.. grid-item-card:: :octicon:`organization` How to Structure State
:link: how-to-structure-state/index
:link-type: doc
Make it easy to reason about your application with strategies for organizing
state.
.. grid-item-card:: :octicon:`link` Sharing Component State
:link: sharing-component-state/index
:link-type: doc
Allow components to vary vary together, by lifting state into common
parents.
.. grid-item-card:: :octicon:`light-bulb` When and How to Reset State
:link: when-and-how-to-reset-state/index
:link-type: doc
Control if and how state is preserved by understanding it's relationship to
the "UI tree".
.. grid-item-card:: :octicon:`plug` Simplifying Updates with Reducers
:link: simplifying-updates-with-reducers/index
:link-type: doc
Consolidate state update logic outside your component in a single function,
called a “reducer".
.. grid-item-card:: :octicon:`broadcast` Deeply Sharing State with Contexts
:link: deeply-sharing-state-with-contexts/index
:link-type: doc
Instead of passing shared state down deep component trees, bring state into
"contexts" instead.
.. grid-item-card:: :octicon:`rocket` Combining Contexts and Reducers
:link: combining-contexts-and-reducers/index
:link-type: doc
You can combine reducers and context together to manage state of a complex
screen.
Section 1: How to Structure State
---------------------------------
.. note::
Under construction 🚧
Section 2: Shared Component State
---------------------------------
Sometimes, you want the state of two components to always change together. To do it,
remove state from both of them, move it to their closest common parent, and then pass it
down to them via props. This is known as “lifting state up”, and it’s one of the most
common things you will do writing code with ReactPy.
In the example below the search input and the list of elements below share the same
state, the state represents the food name. Note how the component ``Table`` gets called
at each change of state. The component is observing the state and reacting to state
changes automatically, just like it would do in React.
.. reactpy:: sharing-component-state/_examples/synced_inputs
.. card::
:link: sharing-component-state/index
:link-type: doc
:octicon:`book` Read More
^^^^^^^^^^^^^^^^^^^^^^^^^
Allow components to vary vary together, by lifting state into common parents.
Section 3: When and How to Reset State
--------------------------------------
.. note::
Under construction 🚧
Section 4: Simplifying Updates with Reducers
--------------------------------------------
.. note::
Under construction 🚧
Section 5: Deeply Sharing State with Contexts
---------------------------------------------
.. note::
Under construction 🚧
Section 6: Combining Contexts and Reducers
------------------------------------------
.. note::
Under construction 🚧
================================================
FILE: docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/data.json
================================================
[
{
"name": "Sushi",
"description": "Sushi is a traditional Japanese dish of prepared vinegared rice"
},
{
"name": "Dal",
"description": "The most common way of preparing dal is in the form of a soup to which onions, tomatoes and various spices may be added"
},
{
"name": "Pierogi",
"description": "Pierogi are filled dumplings made by wrapping unleavened dough around a savoury or sweet filling and cooking in boiling water"
},
{
"name": "Shish Kebab",
"description": "Shish kebab is a popular meal of skewered and grilled cubes of meat"
},
{
"name": "Dim sum",
"description": "Dim sum is a large range of small dishes that Cantonese people traditionally enjoy in restaurants for breakfast and lunch"
}
]
================================================
FILE: docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/main.py
================================================
import json
from pathlib import Path
from reactpy import component, hooks, html, run
HERE = Path(__file__)
DATA_PATH = HERE.parent / "data.json"
food_data = json.loads(DATA_PATH.read_text())
@component
def FilterableList():
value, set_value = hooks.use_state("")
return html.p(Search(value, set_value), html.hr(), Table(value, set_value))
@component
def Search(value, set_value):
def handle_change(event):
set_value(event["target"]["value"])
return html.label(
"Search by Food Name: ",
html.input({"value": value, "on_change": handle_change}),
)
@component
def Table(value, set_value):
rows = []
for row in food_data:
name = html.td(row["name"])
descr = html.td(row["description"])
tr = html.tr(name, descr, value)
if not value:
rows.append(tr)
elif value.lower() in row["name"].lower():
rows.append(tr)
headers = html.tr(html.td(html.b("name")), html.td(html.b("description")))
table = html.table(html.thead(headers), html.tbody(rows))
return table
run(FilterableList)
================================================
FILE: docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/main.py
================================================
from reactpy import component, hooks, html, run
@component
def SyncedInputs():
value, set_value = hooks.use_state("")
return html.p(
Input("First input", value, set_value),
Input("Second input", value, set_value),
)
@component
def Input(label, value, set_value):
def handle_change(event):
set_value(event["target"]["value"])
return html.label(
label + " ", html.input({"value": value, "on_change": handle_change})
)
run(SyncedInputs)
================================================
FILE: docs/source/guides/managing-state/sharing-component-state/index.rst
================================================
Sharing Component State
=======================
.. note::
Parts of this document are still under construction 🚧
Sometimes, you want the state of two components to always change together. To do it,
remove state from both of them, move it to their closest common parent, and then pass it
down to them via props. This is known as “lifting state up”, and it’s one of the most
common things you will do writing code with ReactPy.
Synced Inputs
-------------
In the code below the two input boxes are synchronized, this happens because they share
state. The state is shared via the parent component ``SyncedInputs``. Check the ``value``
and ``set_value`` variables.
.. reactpy:: _examples/synced_inputs
Filterable List
----------------
In the example below the search input and the list of elements below share the
same state, the state represents the food name.
Note how the component ``Table`` gets called at each change of state. The
component is observing the state and reacting to state changes automatically,
just like it would do in React.
.. reactpy:: _examples/filterable_list
.. note::
Try typing a food name in the search bar.
================================================
FILE: docs/source/guides/managing-state/simplifying-updates-with-reducers/index.rst
================================================
Simplifying Updates with Reducers 🚧
====================================
.. note::
Under construction 🚧
================================================
FILE: docs/source/guides/managing-state/when-and-how-to-reset-state/index.rst
================================================
.. _When to Reset State:
When and How to Reset State 🚧
==============================
.. note::
Under construction 🚧
================================================
FILE: docs/source/guides/understanding-reactpy/index.rst
================================================
Understanding ReactPy
=====================
.. toctree::
:hidden:
representing-html
what-are-components
the-rendering-pipeline
why-reactpy-needs-keys
the-rendering-process
layout-render-servers
writing-tests
.. note::
Under construction 🚧
================================================
FILE: docs/source/guides/understanding-reactpy/layout-render-servers.rst
================================================
.. _Layout Render Servers:
Layout Render Servers 🚧
========================
.. note::
Under construction 🚧
================================================
FILE: docs/source/guides/understanding-reactpy/representing-html.rst
================================================
.. _Representing HTML:
Representing HTML 🚧
====================
.. note::
Under construction 🚧
We've already discussed how to construct HTML with ReactPy in a :ref:`previous section `, but we skimmed over the question of the data structure we use to represent
it. Let's reconsider the examples from before - on the top is some HTML and on the
bottom is the corresponding code to create it in ReactPy:
.. code-block:: html
My Todo List
Build a cool new app
Share it with the world!
.. testcode::
from reactpy import html
layout = html.div(
html.h1("My Todo List"),
html.ul(
html.li("Build a cool new app"),
html.li("Share it with the world!"),
)
)
Since we've captured our HTML into out the ``layout`` variable, we can inspect what it
contains. And, as it turns out, it holds a dictionary. Printing it produces the
following output:
.. testsetup::
from pprint import pprint
print = lambda *args, **kwargs: pprint(*args, **kwargs, sort_dicts=False)
.. testcode::
assert layout == {
'tagName': 'div',
'children': [
{
'tagName': 'h1',
'children': ['My Todo List']
},
{
'tagName': 'ul',
'children': [
{'tagName': 'li', 'children': ['Build a cool new app']},
{'tagName': 'li', 'children': ['Share it with the world!']}
]
}
]
}
This may look complicated, but let's take a moment to consider what's going on here. We
have a series of nested dictionaries that, in some way, represents the HTML structure
given above. If we look at their contents we should see a common form. Each has a
``tagName`` key which contains, as the name would suggest, the tag name of an HTML
element. Then within the ``children`` key is a list that either contains strings or
other dictionaries that represent HTML elements.
What we're seeing here is called a "virtual document object model" or :ref:`VDOM`. This
is just a fancy way of saying we have a representation of the document object model or
`DOM
`__
that is not the actual DOM.
================================================
FILE: docs/source/guides/understanding-reactpy/the-rendering-pipeline.rst
================================================
.. _The Rendering Pipeline:
The Rendering Pipeline 🚧
=========================
.. talk about layouts and dispatchers
.. note::
Under construction 🚧
================================================
FILE: docs/source/guides/understanding-reactpy/the-rendering-process.rst
================================================
.. _The Rendering Process:
The Rendering Process 🚧
========================
.. refer to https://beta.reactjs.org/learn/render-and-commit
.. note::
Under construction 🚧
================================================
FILE: docs/source/guides/understanding-reactpy/what-are-components.rst
================================================
.. _What Are Components:
What Are Components? 🚧
=======================
.. note::
Under construction 🚧
================================================
FILE: docs/source/guides/understanding-reactpy/why-reactpy-needs-keys.rst
================================================
.. _Why ReactPy Needs Keys:
Why ReactPy Needs Keys 🚧
=========================
.. note::
Under construction 🚧
================================================
FILE: docs/source/guides/understanding-reactpy/writing-tests.rst
================================================
.. _Writing Tests:
Writing Tests 🚧
================
.. note::
Under construction 🚧
================================================
FILE: docs/source/index.rst
================================================
.. card::
This documentation is still under construction 🚧. We welcome your `feedback
`__!
ReactPy
=======
.. toctree::
:hidden:
:caption: Guides
guides/getting-started/index
guides/creating-interfaces/index
guides/adding-interactivity/index
guides/managing-state/index
guides/escape-hatches/index
guides/understanding-reactpy/index
.. toctree::
:hidden:
:caption: Reference
reference/browser-events
reference/html-attributes
reference/hooks-api
_auto/apis
reference/javascript-api
reference/specifications
.. toctree::
:hidden:
:caption: About
about/changelog
about/contributor-guide
about/credits-and-licenses
Source Code
Community
ReactPy is a library for building user interfaces in Python without Javascript. ReactPy
interfaces are made from :ref:`components ` which look and behave
similarly to those found in `ReactJS `__. Designed with simplicity
in mind, ReactPy can be used by those without web development experience while also
being powerful enough to grow with your ambitions.
At a Glance
-----------
To get a rough idea of how to write apps in ReactPy, take a look at the tiny `"hello world"
`__ application below:
.. reactpy:: guides/getting-started/_examples/hello_world
.. hint::
Try clicking the **🚀 result** tab to see what this displays!
So what exactly does this code do? First, it imports a few tools from ``reactpy`` that will
get used to describe and execute an application. Then, we create an ``App`` function
which will define the content the application displays. Specifically, it displays a kind
of HTML element called an ``h1`` `section heading
`__.
Importantly though, a ``@component`` decorator has been applied to the ``App`` function
to turn it into a :ref:`component `. Finally, we :ref:`run
` a development web server by passing the ``App`` component to the
``run()`` function.
.. note::
See :ref:`Running ReactPy in Production` to learn how to use a production-grade server
to run ReactPy.
Learning ReactPy
----------------
This documentation is broken up into chapters and sections that introduce you to
concepts step by step with detailed explanations and lots of examples. You should feel
free to dive into any content that seems interesting. While each chapter assumes
knowledge from those that came before, when you encounter a concept you're unfamiliar
with you should look for links that will help direct you to the place where it was
originally taught.
Chapter 1 - :ref:`Getting Started`
-----------------------------------
If you want to follow along with examples in the sections that follow, you'll want to
start here so you can :ref:`install ReactPy `. This section also contains
more detailed information about how to :ref:`run ReactPy ` in different
contexts. For example, if you want to embed ReactPy into an existing application, or run
ReactPy within a Jupyter Notebook, this is where you can learn how to do those things.
.. grid:: 1 2 2 2
.. grid-item::
.. image:: _static/install-and-run-reactpy.gif
.. grid-item::
.. image:: guides/getting-started/_static/reactpy-in-jupyterlab.gif
.. card::
:link: guides/getting-started/index
:link-type: doc
:octicon:`book` Read More
^^^^^^^^^^^^^^^^^^^^^^^^^
Install ReactPy and run it in a variety of different ways - with different web servers
and frameworks. You'll even embed ReactPy into an existing app.
Chapter 2 - :ref:`Creating Interfaces`
--------------------------------------
ReactPy is a Python package for making user interfaces (UI). These interfaces are built
from small elements of functionality like buttons text and images. ReactPy allows you to
combine these elements into reusable :ref:`"components" `. In the
sections that follow you'll learn how these UI elements are created and organized into
components. Then, you'll use this knowledge to create interfaces from raw data:
.. reactpy:: guides/creating-interfaces/rendering-data/_examples/todo_list_with_keys
.. card::
:link: guides/creating-interfaces/index
:link-type: doc
:octicon:`book` Read More
^^^^^^^^^^^^^^^^^^^^^^^^^
Learn to construct user interfaces from basic HTML elements and reusable components.
Chapter 3 - :ref:`Adding Interactivity`
---------------------------------------
Components often need to change what’s on the screen as a result of an interaction. For
example, typing into the form should update the input field, clicking a “Comment” button
should bring up a text input field, clicking “Buy” should put a product in the shopping
cart. Components need to “remember” things like the current input value, the current
image, the shopping cart. In ReactPy, this kind of component-specific memory is created and
updated with a "hook" called ``use_state()`` that creates a **state variable** and
**state setter** respectively:
.. reactpy:: guides/adding-interactivity/components-with-state/_examples/adding_state_variable
In ReactPy, ``use_state``, as well as any other function whose name starts with ``use``, is
called a "hook". These are special functions that should only be called while ReactPy is
:ref:`rendering `. They let you "hook into" the different
capabilities of ReactPy's components of which ``use_state`` is just one (well get into the
other :ref:`later `).
.. card::
:link: guides/adding-interactivity/index
:link-type: doc
:octicon:`book` Read More
^^^^^^^^^^^^^^^^^^^^^^^^^
Learn how user interfaces can be made to respond to user interaction in real-time.
Chapter 4 - :ref:`Managing State`
---------------------------------
.. card::
:link: guides/managing-state/index
:link-type: doc
:octicon:`book` Read More
^^^^^^^^^^^^^^^^^^^^^^^^^
Under construction 🚧
Chapter 5 - :ref:`Escape Hatches`
---------------------------------
.. card::
:link: guides/escape-hatches/index
:link-type: doc
:octicon:`book` Read More
^^^^^^^^^^^^^^^^^^^^^^^^^
Under construction 🚧
Chapter 6 - :ref:`Understanding ReactPy`
----------------------------------------
.. card::
:link: guides/escape-hatches/index
:link-type: doc
:octicon:`book` Read More
^^^^^^^^^^^^^^^^^^^^^^^^^
Under construction 🚧
================================================
FILE: docs/source/reference/_examples/character_movement/main.py
================================================
from pathlib import Path
from typing import NamedTuple
from reactpy import component, html, run, use_state
from reactpy.widgets import image
HERE = Path(__file__)
CHARACTER_IMAGE = (HERE.parent / "static" / "bunny.png").read_bytes()
class Position(NamedTuple):
x: int
y: int
angle: int
def rotate(degrees):
return lambda old_position: Position(
old_position.x,
old_position.y,
old_position.angle + degrees,
)
def translate(x=0, y=0):
return lambda old_position: Position(
old_position.x + x,
old_position.y + y,
old_position.angle,
)
@component
def Scene():
position, set_position = use_state(Position(100, 100, 0))
return html.div(
{"style": {"width": "225px"}},
html.div(
{
"style": {
"width": "200px",
"height": "200px",
"background_color": "slategray",
}
},
image(
"png",
CHARACTER_IMAGE,
{
"style": {
"position": "relative",
"left": f"{position.x}px",
"top": f"{position.y}.px",
"transform": f"rotate({position.angle}deg) scale(2, 2)",
}
},
),
),
html.button(
{"on_click": lambda e: set_position(translate(x=-10))}, "Move Left"
),
html.button(
{"on_click": lambda e: set_position(translate(x=10))}, "Move Right"
),
html.button({"on_click": lambda e: set_position(translate(y=-10))}, "Move Up"),
html.button({"on_click": lambda e: set_position(translate(y=10))}, "Move Down"),
html.button({"on_click": lambda e: set_position(rotate(-30))}, "Rotate Left"),
html.button({"on_click": lambda e: set_position(rotate(30))}, "Rotate Right"),
)
run(Scene)
================================================
FILE: docs/source/reference/_examples/click_count.py
================================================
import reactpy
@reactpy.component
def ClickCount():
count, set_count = reactpy.hooks.use_state(0)
return reactpy.html.button(
{"on_click": lambda event: set_count(count + 1)}, [f"Click count: {count}"]
)
reactpy.run(ClickCount)
================================================
FILE: docs/source/reference/_examples/material_ui_switch.py
================================================
import reactpy
mui = reactpy.web.module_from_template("react", "@material-ui/core@^5.0", fallback="⌛")
Switch = reactpy.web.export(mui, "Switch")
@reactpy.component
def DayNightSwitch():
checked, set_checked = reactpy.hooks.use_state(False)
return reactpy.html.div(
Switch(
{
"checked": checked,
"onChange": lambda event, checked: set_checked(checked),
}
),
"🌞" if checked else "🌚",
)
reactpy.run(DayNightSwitch)
================================================
FILE: docs/source/reference/_examples/matplotlib_plot.py
================================================
from io import BytesIO
import matplotlib.pyplot as plt
import reactpy
from reactpy.widgets import image
@reactpy.component
def PolynomialPlot():
coefficients, set_coefficients = reactpy.hooks.use_state([0])
x = list(linspace(-1, 1, 50))
y = [polynomial(value, coefficients) for value in x]
return reactpy.html.div(
plot(f"{len(coefficients)} Term Polynomial", x, y),
ExpandableNumberInputs(coefficients, set_coefficients),
)
@reactpy.component
def ExpandableNumberInputs(values, set_values):
inputs = []
for i in range(len(values)):
def set_value_at_index(event, index=i):
new_value = float(event["target"]["value"] or 0)
set_values([*values[:index], new_value, *values[index + 1 :]])
inputs.append(poly_coef_input(i + 1, set_value_at_index))
def add_input():
set_values([*values, 0])
def del_input():
set_values(values[:-1])
return reactpy.html.div(
reactpy.html.div(
"add/remove term:",
reactpy.html.button({"on_click": lambda event: add_input()}, "+"),
reactpy.html.button({"on_click": lambda event: del_input()}, "-"),
),
inputs,
)
def plot(title, x, y):
fig, axes = plt.subplots()
axes.plot(x, y)
axes.set_title(title)
buffer = BytesIO()
fig.savefig(buffer, format="png")
plt.close(fig)
return image("png", buffer.getvalue())
def poly_coef_input(index, callback):
return reactpy.html.div(
{"style": {"margin-top": "5px"}, "key": index},
reactpy.html.label(
"C",
reactpy.html.sub(index),
" x X",
reactpy.html.sup(index),
),
reactpy.html.input({"type": "number", "on_change": callback}),
)
def polynomial(x, coefficients):
return sum(c * (x ** (i + 1)) for i, c in enumerate(coefficients))
def linspace(start, stop, n):
if n == 1:
yield stop
return
h = (stop - start) / (n - 1)
for i in range(n):
yield start + h * i
reactpy.run(PolynomialPlot)
================================================
FILE: docs/source/reference/_examples/network_graph.py
================================================
import random
import reactpy
react_cytoscapejs = reactpy.web.module_from_template(
"react",
"react-cytoscapejs",
fallback="⌛",
)
Cytoscape = reactpy.web.export(react_cytoscapejs, "default")
@reactpy.component
def RandomNetworkGraph():
return Cytoscape(
{
"style": {"width": "100%", "height": "200px"},
"elements": random_network(20),
"layout": {"name": "cose"},
}
)
def random_network(number_of_nodes):
conns = []
nodes = [{"data": {"id": 0, "label": 0}}]
for src_node_id in range(1, number_of_nodes + 1):
tgt_node = random.choice(nodes)
src_node = {"data": {"id": src_node_id, "label": src_node_id}}
new_conn = {"data": {"source": src_node_id, "target": tgt_node["data"]["id"]}}
nodes.append(src_node)
conns.append(new_conn)
return nodes + conns
reactpy.run(RandomNetworkGraph)
================================================
FILE: docs/source/reference/_examples/pigeon_maps.py
================================================
import reactpy
pigeon_maps = reactpy.web.module_from_template("react", "pigeon-maps", fallback="⌛")
Map, Marker = reactpy.web.export(pigeon_maps, ["Map", "Marker"])
@reactpy.component
def MapWithMarkers():
marker_anchor, add_marker_anchor, remove_marker_anchor = use_set()
markers = [
Marker(
{
"anchor": anchor,
"onClick": lambda event, a=anchor: remove_marker_anchor(a),
},
key=str(anchor),
)
for anchor in marker_anchor
]
return Map(
{
"defaultCenter": (37.774, -122.419),
"defaultZoom": 12,
"height": "300px",
"metaWheelZoom": True,
"onClick": lambda event: add_marker_anchor(tuple(event["latLng"])),
},
markers,
)
def use_set(initial_value=None):
values, set_values = reactpy.hooks.use_state(initial_value or set())
def add_value(lat_lon):
set_values(values.union({lat_lon}))
def remove_value(lat_lon):
set_values(values.difference({lat_lon}))
return values, add_value, remove_value
reactpy.run(MapWithMarkers)
================================================
FILE: docs/source/reference/_examples/simple_dashboard.py
================================================
import asyncio
import random
import time
import reactpy
from reactpy.widgets import Input
victory = reactpy.web.module_from_template(
"react",
"victory-line",
fallback="⌛",
# not usually required (see issue #461 for more info)
unmount_before_update=True,
)
VictoryLine = reactpy.web.export(victory, "VictoryLine")
@reactpy.component
def RandomWalk():
mu = reactpy.hooks.use_ref(0)
sigma = reactpy.hooks.use_ref(1)
return reactpy.html.div(
RandomWalkGraph(mu, sigma),
reactpy.html.style(
"""
.number-input-container {margin-bottom: 20px}
.number-input-container input {width: 48%;float: left}
.number-input-container input + input {margin-left: 4%}
"""
),
NumberInput(
"Mean",
mu.current,
mu.set_current,
(-1, 1, 0.01),
),
NumberInput(
"Standard Deviation",
sigma.current,
sigma.set_current,
(0, 1, 0.01),
),
)
@reactpy.component
def RandomWalkGraph(mu, sigma):
interval = use_interval(0.5)
data, set_data = reactpy.hooks.use_state([{"x": 0, "y": 0}] * 50)
@reactpy.hooks.use_async_effect
async def animate():
await interval
last_data_point = data[-1]
next_data_point = {
"x": last_data_point["x"] + 1,
"y": last_data_point["y"] + random.gauss(mu.current, sigma.current),
}
set_data([*data[1:], next_data_point])
return VictoryLine(
{
"data": data,
"style": {
"parent": {"width": "100%"},
"data": {"stroke": "royalblue"},
},
}
)
@reactpy.component
def NumberInput(label, value, set_value_callback, domain):
minimum, maximum, step = domain
attrs = {"min": minimum, "max": maximum, "step": step}
value, set_value = reactpy.hooks.use_state(value)
def update_value(value):
set_value(value)
set_value_callback(value)
return reactpy.html.fieldset(
{"class_name": "number-input-container"},
reactpy.html.legend({"style": {"font-size": "medium"}}, label),
Input(update_value, "number", value, attributes=attrs, cast=float),
Input(update_value, "range", value, attributes=attrs, cast=float),
)
def use_interval(rate):
usage_time = reactpy.hooks.use_ref(time.time())
async def interval() -> None:
await asyncio.sleep(rate - (time.time() - usage_time.current))
usage_time.current = time.time()
return asyncio.ensure_future(interval())
reactpy.run(RandomWalk)
================================================
FILE: docs/source/reference/_examples/slideshow.py
================================================
import reactpy
@reactpy.component
def Slideshow():
index, set_index = reactpy.hooks.use_state(0)
def next_image(event):
set_index(index + 1)
return reactpy.html.img(
{
"src": f"https://picsum.photos/id/{index}/800/300",
"style": {"cursor": "pointer"},
"on_click": next_image,
}
)
reactpy.run(Slideshow)
================================================
FILE: docs/source/reference/_examples/snake_game.py
================================================
import asyncio
import enum
import random
import time
import reactpy
class GameState(enum.Enum):
init = 0
lost = 1
won = 2
play = 3
@reactpy.component
def GameView():
game_state, set_game_state = reactpy.hooks.use_state(GameState.init)
if game_state == GameState.play:
return GameLoop(grid_size=6, block_scale=50, set_game_state=set_game_state)
start_button = reactpy.html.button(
{"on_click": lambda event: set_game_state(GameState.play)}, "Start"
)
if game_state == GameState.won:
menu = reactpy.html.div(reactpy.html.h3("You won!"), start_button)
elif game_state == GameState.lost:
menu = reactpy.html.div(reactpy.html.h3("You lost"), start_button)
else:
menu = reactpy.html.div(reactpy.html.h3("Click to play"), start_button)
menu_style = reactpy.html.style(
"""
.snake-game-menu h3 {
margin-top: 0px !important;
}
"""
)
return reactpy.html.div({"class_name": "snake-game-menu"}, menu_style, menu)
class Direction(enum.Enum):
ArrowUp = (0, -1)
ArrowLeft = (-1, 0)
ArrowDown = (0, 1)
ArrowRight = (1, 0)
@reactpy.component
def GameLoop(grid_size, block_scale, set_game_state):
# we `use_ref` here to capture the latest direction press without any delay
direction = reactpy.hooks.use_ref(Direction.ArrowRight.value)
# capture the last direction of travel that was rendered
last_direction = direction.current
snake, set_snake = reactpy.hooks.use_state(
[(grid_size // 2 - 1, grid_size // 2 - 1)]
)
food, set_food = use_snake_food(grid_size, snake)
grid = create_grid(grid_size, block_scale)
@reactpy.event(prevent_default=True)
def on_direction_change(event):
if hasattr(Direction, event["key"]):
maybe_new_direction = Direction[event["key"]].value
direction_vector_sum = tuple(
map(sum, zip(last_direction, maybe_new_direction, strict=False))
)
if direction_vector_sum != (0, 0):
direction.current = maybe_new_direction
grid_wrapper = reactpy.html.div({"on_key_down": on_direction_change}, grid)
assign_grid_block_color(grid, food, "blue")
for location in snake:
assign_grid_block_color(grid, location, "white")
new_game_state = None
if snake[-1] in snake[:-1]:
assign_grid_block_color(grid, snake[-1], "red")
new_game_state = GameState.lost
elif len(snake) == grid_size**2:
assign_grid_block_color(grid, snake[-1], "yellow")
new_game_state = GameState.won
interval = use_interval(0.5)
@reactpy.hooks.use_async_effect
async def animate():
if new_game_state is not None:
await asyncio.sleep(1)
set_game_state(new_game_state)
return
await interval
new_snake_head = (
# grid wraps due to mod op here
(snake[-1][0] + direction.current[0]) % grid_size,
(snake[-1][1] + direction.current[1]) % grid_size,
)
if snake[-1] == food:
set_food()
new_snake = [*snake, new_snake_head]
else:
new_snake = [*snake[1:], new_snake_head]
set_snake(new_snake)
return grid_wrapper
def use_snake_food(grid_size, current_snake):
grid_points = {(x, y) for x in range(grid_size) for y in range(grid_size)}
points_not_in_snake = grid_points.difference(current_snake)
food, _set_food = reactpy.hooks.use_state(current_snake[-1])
def set_food():
_set_food(random.choice(list(points_not_in_snake)))
return food, set_food
def use_interval(rate):
usage_time = reactpy.hooks.use_ref(time.time())
async def interval() -> None:
await asyncio.sleep(rate - (time.time() - usage_time.current))
usage_time.current = time.time()
return asyncio.ensure_future(interval())
def create_grid(grid_size, block_scale):
return reactpy.html.div(
{
"style": {
"height": f"{block_scale * grid_size}px",
"width": f"{block_scale * grid_size}px",
"cursor": "pointer",
"display": "grid",
"grid-gap": 0,
"grid-template-columns": f"repeat({grid_size}, {block_scale}px)",
"grid-template-rows": f"repeat({grid_size}, {block_scale}px)",
},
"tab_index": -1,
},
[
reactpy.html.div(
{"style": {"height": f"{block_scale}px"}, "key": i},
[
create_grid_block("black", block_scale, key=i)
for i in range(grid_size)
],
)
for i in range(grid_size)
],
)
def create_grid_block(color, block_scale, key):
return reactpy.html.div(
{
"style": {
"height": f"{block_scale}px",
"width": f"{block_scale}px",
"background_color": color,
"outline": "1px solid grey",
},
"key": key,
}
)
def assign_grid_block_color(grid, point, color):
x, y = point
block = grid["children"][x]["children"][y]
block["attributes"]["style"]["backgroundColor"] = color
reactpy.run(GameView)
================================================
FILE: docs/source/reference/_examples/todo.py
================================================
import reactpy
@reactpy.component
def Todo():
items, set_items = reactpy.hooks.use_state([])
async def add_new_task(event):
if event["key"] == "Enter":
set_items([*items, event["target"]["value"]])
tasks = []
for index, text in enumerate(items):
async def remove_task(event, index=index):
set_items(items[:index] + items[index + 1 :])
task_text = reactpy.html.td(reactpy.html.p(text))
delete_button = reactpy.html.td(
{"on_click": remove_task}, reactpy.html.button(["x"])
)
tasks.append(reactpy.html.tr(task_text, delete_button))
task_input = reactpy.html.input({"on_key_down": add_new_task})
task_table = reactpy.html.table(tasks)
return reactpy.html.div(
reactpy.html.p("press enter to add a task:"),
task_input,
task_table,
)
reactpy.run(Todo)
================================================
FILE: docs/source/reference/_examples/use_reducer_counter.py
================================================
import reactpy
def reducer(count, action):
if action == "increment":
return count + 1
elif action == "decrement":
return count - 1
elif action == "reset":
return 0
else:
msg = f"Unknown action '{action}'"
raise ValueError(msg)
@reactpy.component
def Counter():
count, dispatch = reactpy.hooks.use_reducer(reducer, 0)
return reactpy.html.div(
f"Count: {count}",
reactpy.html.button({"on_click": lambda event: dispatch("reset")}, "Reset"),
reactpy.html.button({"on_click": lambda event: dispatch("increment")}, "+"),
reactpy.html.button({"on_click": lambda event: dispatch("decrement")}, "-"),
)
reactpy.run(Counter)
================================================
FILE: docs/source/reference/_examples/use_state_counter.py
================================================
import reactpy
def increment(last_count):
return last_count + 1
def decrement(last_count):
return last_count - 1
@reactpy.component
def Counter():
initial_count = 0
count, set_count = reactpy.hooks.use_state(initial_count)
return reactpy.html.div(
f"Count: {count}",
reactpy.html.button(
{"on_click": lambda event: set_count(initial_count)}, "Reset"
),
reactpy.html.button({"on_click": lambda event: set_count(increment)}, "+"),
reactpy.html.button({"on_click": lambda event: set_count(decrement)}, "-"),
)
reactpy.run(Counter)
================================================
FILE: docs/source/reference/_examples/victory_chart.py
================================================
import reactpy
victory = reactpy.web.module_from_template("react", "victory-bar", fallback="⌛")
VictoryBar = reactpy.web.export(victory, "VictoryBar")
bar_style = {"parent": {"width": "500px"}, "data": {"fill": "royalblue"}}
reactpy.run(reactpy.component(lambda: VictoryBar({"style": bar_style})))
================================================
FILE: docs/source/reference/_static/vdom-json-schema.json
================================================
{
"$ref": "#/definitions/element",
"$schema": "http://json-schema.org/draft-07/schema",
"definitions": {
"element": {
"dependentSchemas": {
"error": {
"properties": {
"tagName": {
"maxLength": 0
}
}
}
},
"properties": {
"attributes": {
"type": "object"
},
"children": {
"$ref": "#/definitions/elementChildren"
},
"error": {
"type": "string"
},
"eventHandlers": {
"$ref": "#/definitions/elementEventHandlers"
},
"importSource": {
"$ref": "#/definitions/importSource"
},
"key": {
"type": "string"
},
"tagName": {
"type": "string"
}
},
"required": ["tagName"],
"type": "object"
},
"elementChildren": {
"items": {
"$ref": "#/definitions/elementOrString"
},
"type": "array"
},
"elementEventHandlers": {
"patternProperties": {
".*": {
"$ref": "#/definitions/eventHander"
}
},
"type": "object"
},
"elementOrString": {
"if": {
"type": "object"
},
"then": {
"$ref": "#/definitions/element"
},
"type": ["object", "string"]
},
"eventHandler": {
"properties": {
"preventDefault": {
"type": "boolean"
},
"stopPropagation": {
"type": "boolean"
},
"target": {
"type": "string"
}
},
"required": ["target"],
"type": "object"
},
"importSource": {
"properties": {
"fallback": {
"if": {
"not": {
"type": "null"
}
},
"then": {
"$ref": "#/definitions/elementOrString"
},
"type": ["object", "string", "null"]
},
"source": {
"type": "string"
},
"sourceType": {
"enum": ["URL", "NAME"]
},
"unmountBeforeUpdate": {
"type": "boolean"
}
},
"required": ["source"],
"type": "object"
}
}
}
================================================
FILE: docs/source/reference/browser-events.rst
================================================
.. _Browser Events:
Browser Events 🚧
=================
The event types below are triggered by an event in the bubbling phase. To register an
event handler for the capture phase, append Capture to the event name; for example,
instead of using ``onClick``, you would use ``onClickCapture`` to handle the click event
in the capture phase.
.. note::
Under construction 🚧
Clipboard Events
----------------
Composition Events
------------------
Keyboard Events
---------------
Focus Events
------------
Form Events
-----------
Generic Events
--------------
Mouse Events
------------
Pointer Events
--------------
Selection Events
----------------
Touch Events
------------
UI Events
---------
Wheel Events
------------
Media Events
------------
Image Events
------------
Animation Events
----------------
Transition Events
-----------------
Other Events
------------
================================================
FILE: docs/source/reference/hooks-api.rst
================================================
=========
Hooks API
=========
Hooks are functions that allow you to "hook into" the life cycle events and state of
Components. Their usage should always follow the :ref:`Rules of Hooks`. For most use
cases the :ref:`Basic Hooks` should be enough, however the remaining
:ref:`Supplementary Hooks` should fulfill less common scenarios.
Basic Hooks
===========
Use State
---------
.. code-block::
state, set_state = use_state(initial_state)
Returns a stateful value and a function to update it.
During the first render the ``state`` will be identical to the ``initial_state`` passed
as the first argument. However in subsequent renders ``state`` will take on the value
passed to ``set_state``.
.. code-block::
set_state(new_state)
The ``set_state`` function accepts a ``new_state`` as its only argument and schedules a
re-render of the component where ``use_state`` was initially called. During these
subsequent re-renders the ``state`` returned by ``use_state`` will take on the value
of ``new_state``.
.. note::
The identity of ``set_state`` is guaranteed to be preserved across renders. This
means it can safely be omitted from dependency lists in :ref:`Use Effect` or
:ref:`Use Callback`.
Functional Updates
..................
If the new state is computed from the previous state, you can pass a function which
accepts a single argument (the previous state) and returns the next state. Consider this
simply use case of a counter where we've pulled out logic for increment and
decremented the count:
.. reactpy:: _examples/use_state_counter
We use the functional form for the "+" and "-" buttons since the next ``count`` depends
on the previous value, while for the "Reset" button we simple assign the
``initial_count`` since it is independent of the prior ``count``. This is a trivial
example, but it demonstrates how complex state logic can be factored out into well
defined and potentially reusable functions.
Lazy Initial State
..................
In cases where it is costly to create the initial value for ``use_state``, you can pass
a constructor function that accepts no arguments instead - it will be called on the
first render of a component, but will be disregarded in all following renders:
.. code-block::
state, set_state = use_state(lambda: some_expensive_computation(a, b, c))
Skipping Updates
................
If you update a State Hook to the same value as the current state then the component which
owns that state will not be rendered again. We check ``if new_state is current_state``
in order to determine whether there has been a change. Thus the following would not
result in a re-render:
.. code-block::
state, set_state = use_state([])
set_state(state)
Use Effect
----------
.. code-block::
use_effect(did_render)
The ``use_effect`` hook accepts a function which may be imperative, or mutate state. The
function will be called immediately after the layout has fully updated.
Asynchronous actions, mutations, subscriptions, and other `side effects`_ can cause
unexpected bugs if placed in the main body of a component's render function. Thus the
``use_effect`` hook provides a way to safely escape the purely functional world of
component render functions.
.. note::
Normally in React the ``did_render`` function is called once an update has been
committed to the screen. Since no such action is performed by ReactPy, and the time
at which the update is displayed cannot be known we are unable to achieve parity
with this behavior.
Cleaning Up Effects
...................
If the effect you wish to enact creates resources, you'll probably need to clean them
up. In such cases you may simply return a function that addresses this from the
``did_render`` function which created the resource. Consider the case of opening and
then closing a connection:
.. code-block::
def establish_connection():
connection = open_connection()
return lambda: close_connection(connection)
use_effect(establish_connection)
The clean-up function will be run before the component is unmounted or, before the next
effect is triggered when the component re-renders. You can
:ref:`conditionally fire events ` to avoid triggering them each
time a component renders.
Conditional Effects
...................
By default, effects are triggered after every successful render to ensure that all state
referenced by the effect is up to date. However, when an effect function references
non-global variables, the effect will only if the value of that variable changes. For
example, imagine that we had an effect that connected to a ``url`` state variable:
.. code-block::
url, set_url = use_state("https://example.com")
def establish_connection():
connection = open_connection(url)
return lambda: close_connection(connection)
use_effect(establish_connection)
Here, a new connection will be established whenever a new ``url`` is set.
Async Effects
.............
A behavior unique to ReactPy's implementation of ``use_effect`` is that it natively
supports ``async`` functions:
.. code-block::
async def non_blocking_effect():
resource = await do_something_asynchronously()
return lambda: blocking_close(resource)
use_effect(non_blocking_effect)
There are **three important subtleties** to note about using asynchronous effects:
1. The cleanup function must be a normal synchronous function.
2. Asynchronous effects which do not complete before the next effect is created
following a re-render will be cancelled. This means an
:class:`~asyncio.CancelledError` will be raised somewhere in the body of the effect.
3. An asynchronous effect may occur any time after the update which added this effect
and before the next effect following a subsequent update.
Manual Effect Conditions
........................
In some cases, you may want to explicitly declare when an effect should be triggered.
You can do this by passing ``dependencies`` to ``use_effect``. Each of the following
values produce different effect behaviors:
- ``use_effect(..., dependencies=None)`` - triggers and cleans up on every render.
- ``use_effect(..., dependencies=[])`` - only triggers on the first and cleans up after
the last render.
- ``use_effect(..., dependencies=[x, y])`` - triggers on the first render and on subsequent renders if
``x`` or ``y`` have changed.
Use Context
-----------
.. code-block::
value = use_context(MyContext)
Accepts a context object (the value returned from
:func:`reactpy.core.hooks.create_context`) and returns the current context value for that
context. The current context value is determined by the ``value`` argument passed to the
nearest ``MyContext`` in the tree.
When the nearest above the component updates, this Hook will
trigger a rerender with the latest context value passed to that MyContext provider. Even
if an ancestor uses React.memo or shouldComponentUpdate, a rerender will still happen
starting at the component itself using useContext.
Supplementary Hooks
===================
Use Reducer
-----------
.. code-block::
state, dispatch_action = use_reducer(reducer, initial_state)
An alternative and derivative of :ref:`Use State` the ``use_reducer`` hook, instead of
directly assigning a new state, allows you to specify an action which will transition
the previous state into the next state. This transition is defined by a reducer function
of the form ``(current_state, action) -> new_state``. The ``use_reducer`` hook then
returns the current state and a ``dispatch_action`` function that accepts an ``action``
and causes a transition to the next state via the ``reducer``.
``use_reducer`` is generally preferred to ``use_state`` if logic for transitioning from
one state to the next is especially complex or involves nested data structures.
``use_reducer`` can also be used to collect several ``use_state`` calls together - this
may be slightly more performant as well as being preferable since there is only one
``dispatch_action`` callback versus the many ``set_state`` callbacks.
We can rework the :ref:`Functional Updates` counter example to use ``use_reducer``:
.. reactpy:: _examples/use_reducer_counter
.. note::
The identity of the ``dispatch_action`` function is guaranteed to be preserved
across re-renders throughout the lifetime of the component. This means it can safely
be omitted from dependency lists in :ref:`Use Effect` or :ref:`Use Callback`.
Use Callback
------------
.. code-block::
memoized_callback = use_callback(lambda: do_something(a, b))
A derivative of :ref:`Use Memo`, the ``use_callback`` hook returns a
`memoized `_ callback. This is useful when passing callbacks to child
components which check reference equality to prevent unnecessary renders. The
``memoized_callback`` will only change when any local variables is references do.
.. note::
You may manually specify what values the callback depends on in the :ref:`same way
as effects ` using the ``dependencies`` parameter.
Use Memo
--------
.. code-block::
memoized_value = use_memo(lambda: compute_something_expensive(a, b))
Returns a `memoized `_ value. By passing a constructor function accepting
no arguments and an array of dependencies for that constructor, the ``use_callback``
hook will return the value computed by the constructor. The ``memoized_value`` will only
be recomputed if a local variable referenced by the constructor changes (e.g. ``a`` or
``b`` here). This optimizes performance because you don't need to
``compute_something_expensive`` on every render.
Unlike ``use_effect`` the constructor function is called during each render (instead of
after) and should not incur side effects.
.. warning::
Remember that you shouldn't optimize something unless you know it's a performance
bottleneck. Write your code without ``use_memo`` first and then add it to targeted
sections that need a speed-up.
.. note::
You may manually specify what values the callback depends on in the :ref:`same way
as effects ` using the ``dependencies`` parameter.
Use Ref
-------
.. code-block::
ref_container = use_ref(initial_value)
Returns a mutable :class:`~reactpy.utils.Ref` object that has a single
:attr:`~reactpy.utils.Ref.current` attribute that at first contains the ``initial_state``.
The identity of the ``Ref`` object will be preserved for the lifetime of the component.
A ``Ref`` is most useful if you need to incur side effects since updating its
``.current`` attribute doesn't trigger a re-render of the component. You'll often use this
hook alongside :ref:`Use Effect` or in response to component event handlers.
.. links
.. =====
.. _React Hooks: https://reactjs.org/docs/hooks-reference.html
.. _side effects: https://en.wikipedia.org/wiki/Side_effect_(computer_science)
.. _memoization: https://en.wikipedia.org/wiki/Memoization
Rules of Hooks
==============
Hooks are just normal Python functions, but there's a bit of magic to them, and in order
for that magic to work you've got to follow two rules. Thankfully we supply a
:ref:`Flake8 Plugin` to help enforce them.
Only call outside flow controls
-------------------------------
**Don't call hooks inside loops, conditions, or nested functions.** Instead you must
always call hooks at the top level of your functions. By adhering to this rule you
ensure that hooks are always called in the exact same order. This fact is what allows
ReactPy to preserve the state of hooks between multiple calls to ``useState`` and
``useEffect`` calls.
Only call in render functions
-----------------------------
**Don't call hooks from regular Python functions.** Instead you should:
- ✅ Call Hooks from a component's render function.
- ✅ Call Hooks from another custom hook
Following this rule ensures stateful logic for ReactPy component is always clearly
separated from the rest of your codebase.
Flake8 Plugin
-------------
We provide a Flake8 plugin called `flake8-reactpy-hooks `_ that helps
to enforce the two rules described above. You can ``pip`` install it directly, or with
the ``lint`` extra for ReactPy:
.. code-block:: bash
pip install flake8-reactpy-hooks
Once installed running, ``flake8`` on your code will start catching errors. For example:
.. code-block:: bash
flake8 my_reactpy_components.py
Might produce something like the following output:
.. code-block:: text
./my_reactpy_components:10:8 ROH102 hook 'use_effect' used inside if statement
./my_reactpy_components:23:4 ROH102 hook 'use_state' used outside component or hook definition
See the Flake8 docs for
`more info `__.
.. links
.. =====
.. _Flake8 Linter Plugin: https://github.com/reactive-python/flake8-reactpy-hooks
================================================
FILE: docs/source/reference/html-attributes.rst
================================================
.. testcode::
from reactpy import html
HTML Attributes
===============
In ReactPy, HTML attributes are specified using snake_case instead of dash-separated
words. For example, ``tabindex`` and ``margin-left`` become ``tab_index`` and
``margin_left`` respectively.
Notable Attributes
-------------------
Some attributes in ReactPy are renamed, have special meaning, or are used differently
than in HTML.
``style``
.........
As mentioned above, instead of using a string to specify the ``style`` attribute, we use
a dictionary to describe the CSS properties we want to apply to an element. For example,
the following HTML:
.. code-block:: html
My Todo List
Build a cool new app
Share it with the world!
Would be written in ReactPy as:
.. testcode::
html.div(
{
"style": {
"width": "50%",
"margin_left": "25%",
},
},
html.h1(
{
"style": {
"margin_top": "0px",
},
},
"My Todo List",
),
html.ul(
html.li("Build a cool new app"),
html.li("Share it with the world!"),
),
)
``class`` vs ``class_name``
...........................
In HTML, the ``class`` attribute is used to specify a CSS class for an element. In
ReactPy, this attribute is renamed to ``class_name`` to avoid conflicting with the
``class`` keyword in Python. For example, the following HTML:
.. code-block:: html
My Todo List
Build a cool new app
Share it with the world!
Would be written in ReactPy as:
.. testcode::
html.div(
{"class_name": "container"},
html.h1({"class_name": "title"}, "My Todo List"),
html.ul(
{"class_name": "list"},
html.li({"class_name": "item"}, "Build a cool new app"),
html.li({"class_name": "item"}, "Share it with the world!"),
),
)
``for`` vs ``html_for``
.......................
In HTML, the ``for`` attribute is used to specify the ``id`` of the element it's
associated with. In ReactPy, this attribute is renamed to ``html_for`` to avoid
conflicting with the ``for`` keyword in Python. For example, the following HTML:
.. code-block:: html
Would be written in ReactPy as:
.. testcode::
html.div(
html.label({"html_for": "todo"}, "Todo:"),
html.input({"id": "todo", "type": "text"}),
)
``dangerously_set_inner_HTML``
..............................
This is used to set the ``innerHTML`` property of an element and should be provided a
dictionary with a single key ``__html`` whose value is the HTML to be set. It should be
used with **extreme caution** as it can lead to XSS attacks if the HTML inside isn't
trusted (for example if it comes from user input).
All Attributes
--------------
`access_key `__
A string. Specifies a keyboard shortcut for the element. Not generally recommended.
`aria_* `__
ARIA attributes let you specify the accessibility tree information for this element.
See ARIA attributes for a complete reference. In ReactPy, all ARIA attribute names are
exactly the same as in HTML.
`auto_capitalize `__
A string. Specifies whether and how the user input should be capitalized.
`content_editable `__
A boolean. If true, the browser lets the user edit the rendered element directly. This
is used to implement rich text input libraries like Lexical. ReactPy warns if you try
to pass children to an element with ``content_editable = True`` because ReactPy will
not be able to update its content after user edits.
`data_* `__
Data attributes let you attach some string data to the element, for example
data-fruit="banana". In ReactPy, they are not commonly used because you would usually
read data from props or state instead.
`dir `__
Either ``"ltr"`` or ``"rtl"``. Specifies the text direction of the element.
`draggable `__
A boolean. Specifies whether the element is draggable. Part of HTML Drag and Drop API.
`enter_key_hint `__
A string. Specifies which action to present for the enter key on virtual keyboards.
`hidden `__
A boolean or a string. Specifies whether the element should be hidden.
- `id `__:
A string. Specifies a unique identifier for this element, which can be used to find it
later or connect it with other elements. Generate it with useId to avoid clashes
between multiple instances of the same component.
`is `__
A string. If specified, the component will behave like a custom element.
`input_mode `__
A string. Specifies what kind of keyboard to display (for example, text, number, or telephone).
`item_prop `__
A string. Specifies which property the element represents for structured data crawlers.
`lang `__
A string. Specifies the language of the element.
`role `__
A string. Specifies the element role explicitly for assistive technologies.
`slot `__
A string. Specifies the slot name when using shadow DOM. In ReactPy, an equivalent
pattern is typically achieved by passing JSX as props, for example
``} right={} />``.
`spell_check `__
A boolean or null. If explicitly set to true or false, enables or disables spellchecking.
`tab_index `__
A number. Overrides the default Tab button behavior. Avoid using values other than -1 and 0.
`title `__
A string. Specifies the tooltip text for the element.
`translate `__
Either 'yes' or 'no'. Passing 'no' excludes the element content from being translated.
================================================
FILE: docs/source/reference/javascript-api.rst
================================================
.. _Javascript API:
Javascript API 🚧
=================
.. note::
Under construction 🚧
================================================
FILE: docs/source/reference/specifications.rst
================================================
Specifications
==============
Describes various data structures and protocols used to define and communicate virtual
document object models (:ref:`VDOM`). The definitions below follow in the footsteps of
`a specification `_
created by `Nteract `_ and which was built into
`JupyterLab `_. While ReactPy's specification
for VDOM is fairly well established, it should not be relied until it's been fully
adopted by the aforementioned organizations.
VDOM
----
A set of definitions that explain how ReactPy creates a virtual representation of
the document object model. We'll begin by looking at a bit of HTML that we'll convert
into its VDOM representation:
.. code-block:: html
Put your name here:
.. note::
For context, the following Python code would generate the HTML above:
.. code-block:: python
import reactpy
async def a_python_callback(new):
...
name_input_view = reactpy.html.div(
reactpy.html.input(
{
"type": "text",
"minLength": 4,
"maxLength": 8,
"onChange": a_python_callback,
}
),
["Put your name here: "],
)
We'll take this step by step in order to show exactly where each piece of the VDOM
model comes from. To get started we'll convert the outer ````:
.. code-block:: python
{
"tagName": "div",
"children": [
"To perform an action",
...
],
"attributes": {},
"eventHandlers": {}
}
.. note::
As we move though our conversation we'll be using ``...`` to fill in places that we
haven't converted yet.
In this simple case, all we've done is take the name of the HTML element (``div`` in
this case) and inserted it into the ``tagName`` field of a dictionary. Then we've taken
the inner HTML and added to a list of children where the text ``"to perform an action"``
has been made into a string, and the inner ``input`` (yet to be converted) will be
expanded out into its own VDOM representation. Since the outer ``div`` is pretty simple
there aren't any ``attributes`` or ``eventHandlers``.
No we come to the inner ``input``. If we expand this out now we'll get the following:
.. code-block:: python
{
"tagName": "div",
"children": [
"To perform an action",
{
"tagName": "input",
"children": [],
"attributes": {
"type": "text",
"minLength": 4,
"maxLength": 8
},
"eventHandlers": ...
}
],
"attributes": {},
"eventHandlers": {}
}
Here we've had to add some attributes to our VDOM. Take note of the differing
capitalization - instead of using all lowercase (an HTML convention) we've used
`camelCase `_ which is very common
in JavaScript.
Last, but not least we come to the ``eventHandlers`` for the ``input``:
.. code-block:: python
{
"tagName": "div",
"children": [
"To perform an action",
{
"tagName": "input",
"children": [],
"attributes": {
"type": "text",
"minLength": 4,
"maxLength": 8
},
"eventHandlers": {
"onChange": {
"target": "unique-id-of-a_python_callback",
"preventDefault": False,
"stopPropagation": False
}
}
}
],
"attributes": {},
"eventHandlers": {}
}
Again we've changed the all lowercase ``onchange`` into a cameCase ``onChange`` event
type name. The various properties for the ``onChange`` handler are:
- ``target``: the unique ID for a Python callback that exists in the backend.
- ``preventDefault``: Stop the event's default action. More info
`here `__.
- ``stopPropagation``: prevent the event from bubbling up through the DOM. More info
`here `__.
VDOM JSON Schema
................
To clearly describe the VDOM spec we've created a `JSON Schema `_:
.. literalinclude:: _static/vdom-json-schema.json
:language: json
JSON Patch
----------
Updates to VDOM modules are sent using the `JSON Patch`_ specification.
... this section is still Under construction 🚧
.. Links
.. =====
.. _JSON Patch: http://jsonpatch.com/
================================================
FILE: pyproject.toml
================================================
[build-system]
build-backend = "hatchling.build"
requires = ["hatchling", "hatch-build-scripts", "hatch"]
##############################
# >>> Hatch Build Config <<< #
##############################
[project]
name = "reactpy"
description = "It's React, but in Python."
readme = "README.md"
keywords = [
"react",
"reactjs",
"reactpy",
"components",
"asgi",
"wsgi",
"website",
"interactive",
"reactive",
"javascript",
"server",
]
license = "MIT"
authors = [
{ name = "Mark Bakhit", email = "archiethemonger@gmail.com" },
{ name = "Ryan Morshead", email = "ryan.morshead@gmail.com" },
]
requires-python = ">=3.11"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Programming Language :: Python",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
]
dependencies = ["fastjsonschema>=2.14.5", "requests>=2", "lxml>=4", "anyio>=3"]
dynamic = ["version"]
urls.Changelog = "https://reactpy.dev/docs/about/changelog.html"
urls.Documentation = "https://reactpy.dev/"
urls.Source = "https://github.com/reactive-python/reactpy"
[project.optional-dependencies]
all = ["reactpy[asgi,jinja,testing]"]
asgi = ["asgiref", "asgi-tools", "servestatic", "orjson"]
jinja = ["jinja2-simple-tags", "jinja2>=3"]
testing = ["playwright", "uvicorn[standard]"]
[tool.hatch.version]
path = "src/reactpy/__init__.py"
[tool.hatch.build.targets.sdist]
include = ["/src"]
artifacts = ["/src/reactpy/static/"]
[tool.hatch.build.targets.wheel]
artifacts = ["/src/reactpy/static/"]
[tool.hatch.metadata]
license-files = { paths = ["LICENSE"] }
[tool.hatch.envs.default]
installer = "uv"
[project.scripts]
reactpy = "reactpy._console.cli:entry_point"
[[tool.hatch.build.hooks.build-scripts.scripts]]
commands = []
artifacts = []
#############################
# >>> Hatch Test Runner <<< #
#############################
[tool.hatch.envs.hatch-test.scripts]
run = [
'hatch --env default run "src/build_scripts/install_playwright.py"',
"hatch --env default run javascript:build --dev",
"hatch --env default build -t wheel",
"pytest{env:HATCH_TEST_ARGS:} {args} --max-worker-restart 10",
]
run-cov = [
'hatch --env default run "src/build_scripts/install_playwright.py"',
"hatch --env default run javascript:build --dev",
"hatch --env default build -t wheel",
'hatch --env default run "src/build_scripts/delete_old_coverage.py"',
"coverage run -m pytest{env:HATCH_TEST_ARGS:} {args} --max-worker-restart 10",
]
cov-combine = "coverage combine"
cov-report = "coverage report"
[tool.hatch.envs.hatch-test]
extra-dependencies = [
"pytest-sugar",
"pytest-asyncio",
"pytest-timeout",
"responses",
"exceptiongroup",
"jsonpointer",
"starlette",
]
features = ["all"]
[[tool.hatch.envs.hatch-test.matrix]]
python = ["3.11", "3.12", "3.13", "3.14"]
[tool.pytest.ini_options]
addopts = ["--strict-config", "--strict-markers"]
filterwarnings = """
ignore::DeprecationWarning:uvicorn.*
ignore::DeprecationWarning:websockets.*
ignore::UserWarning:tests.test_core.test_vdom
ignore::UserWarning:tests.test_pyscript.test_components
ignore::UserWarning:tests.test_utils
"""
testpaths = "tests"
xfail_strict = true
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
asyncio_default_test_loop_scope = "session"
log_cli_level = "INFO"
timeout = 120
#######################################
# >>> Hatch Documentation Scripts <<< #
#######################################
[tool.hatch.envs.docs]
template = "docs"
dependencies = ["poetry"]
detached = true
[tool.hatch.envs.docs.scripts]
build = [
"cd docs && poetry install",
"cd docs && poetry run sphinx-build -a -T -W --keep-going -b doctest source build",
]
docker_build = [
"hatch run docs:build",
"docker build . --file ./docs/Dockerfile --tag reactpy-docs:latest",
]
docker_serve = [
"hatch run docs:docker_build",
"docker run --rm -p 5000:5000 reactpy-docs:latest",
]
check = [
"cd docs && poetry install",
"cd docs && poetry run sphinx-build -a -T -W --keep-going -b doctest source build",
"docker build . --file ./docs/Dockerfile",
]
serve = [
"cd docs && poetry install",
"cd docs && poetry run python main.py --watch=../src/ --ignore=**/_auto/* --ignore=**/custom.js --ignore=**/node_modules/* --ignore=**/package-lock.json -a -E -b html source build",
]
################################
# >>> Hatch Python Scripts <<< #
################################
[tool.hatch.envs.python]
extra-dependencies = [
"reactpy[all]",
"pyright",
"types-toml",
"types-click",
"types-requests",
"types-lxml",
"jsonpointer",
"pytest",
]
[tool.hatch.envs.python.scripts]
type_check = ["pyright src/reactpy"]
############################
# >>> Hatch JS Scripts <<< #
############################
[tool.hatch.envs.javascript]
detached = true
[tool.hatch.envs.javascript.scripts]
check = [
'hatch run javascript:build',
'bun run --cwd "src/js" lint',
'bun run --cwd "src/js/packages/event-to-object" checkTypes',
'bun run --cwd "src/js/packages/@reactpy/client" checkTypes',
'bun run --cwd "src/js/packages/@reactpy/app" checkTypes',
]
fix = ['bun install --cwd "src/js"', 'bun run --cwd "src/js" format']
test = ['hatch run javascript:build_event_to_object --dev', 'bun test']
build = [
'hatch run "src/build_scripts/clean_js_dir.py"',
'bun install --cwd "src/js"',
'hatch run javascript:build_event_to_object {args}',
'hatch run javascript:build_client {args}',
'hatch run javascript:build_app {args}',
'hatch --env default run "src/build_scripts/copy_dir.py" "src/js/node_modules/@pyscript/core/dist" "src/reactpy/static/pyscript"',
'hatch --env default run "src/build_scripts/copy_dir.py" "src/js/node_modules/morphdom/dist" "src/reactpy/static/morphdom"',
]
build_event_to_object = [
'hatch run "src/build_scripts/build_js_event_to_object.py" {args}',
]
build_client = ['hatch run "src/build_scripts/build_js_client.py" {args}']
build_app = ['hatch run "src/build_scripts/build_js_app.py" {args}']
publish_event_to_object = [
'hatch run javascript:build_event_to_object',
# FIXME: This is a temporary workaround. We are using `bun pm pack`->`npm publish` to fix missing "Trusted Publishing" support in `bun publish`
# See the following ticket https://github.com/oven-sh/bun/issues/15601
'cd "src/js/packages/event-to-object" && bun pm pack --filename "packages/event-to-object/dist.tgz" && bunx npm@11.8.0 publish dist.tgz --provenance --access public',
]
publish_client = [
'hatch run javascript:build_client',
# FIXME: This is a temporary workaround. We are using `bun pm pack`->`npm publish` to fix missing "Trusted Publishing" support in `bun publish`
# See the following ticket https://github.com/oven-sh/bun/issues/15601
'cd "src/js/packages/@reactpy/client" && bun pm pack --filename "packages/@reactpy/client/dist.tgz" && bunx npm@11.8.0 publish dist.tgz --provenance --access public',
]
#########################
# >>> Generic Tools <<< #
#########################
[tool.pyright]
reportIncompatibleVariableOverride = false
[tool.coverage.run]
source_pkgs = ["reactpy"]
branch = false
parallel = false
omit = [
"src/reactpy/__init__.py",
"src/reactpy/_console/*",
"src/reactpy/__main__.py",
"src/reactpy/executors/pyscript/layout_handler.py",
"src/reactpy/executors/pyscript/component_template.py",
]
[tool.coverage.report]
fail_under = 100
show_missing = true
skip_covered = true
sort = "Name"
exclude_also = [
"no ?cov",
'\.\.\.',
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
]
[tool.ruff]
line-length = 88
lint.select = [
"A",
"ARG",
"B",
"C",
"DTZ",
"E",
# error message linting is overkill
# "EM",
"F",
# TODO: turn this on later
# "FBT",
"I",
"ICN",
"N",
"PLC",
"PLE",
"PLR",
"PLW",
"Q",
"RUF",
"S",
"T",
"TID",
"UP",
"W",
"YTT",
]
lint.ignore = [
# TODO: turn this on later
"N802",
"N806", # allow TitleCase functions/variables
# We're not any cryptography
"S311",
# For loop variable re-assignment seems like an uncommon mistake
"PLW2901",
# Let Black deal with line-length
"E501",
# Allow args/attrs to shadow built-ins
"A002",
"A003",
# Allow unused args (useful for documenting what the parameter is for later)
"ARG001",
"ARG002",
"ARG005",
# Allow non-abstract empty methods in abstract base classes
"B027",
# Allow boolean positional values in function calls, like `dict.get(... True)`
"FBT003",
# If we're making an explicit comparison to a falsy value it was probably intentional
"PLC1901",
# Ignore checks for possible passwords
"S105",
"S106",
"S107",
# Ignore complexity
"C901",
"PLR0911",
"PLR0912",
"PLR0913",
"PLR0915",
# Allow imports anywhere
"PLC0415",
]
lint.unfixable = [
# Don't touch unused imports
"F401",
]
[tool.ruff.lint.isort]
known-first-party = ["reactpy"]
known-third-party = ["js"]
[tool.ruff.lint.per-file-ignores]
# Tests can use magic values, assertions, and relative imports
"**/tests/**/*" = ["PLR2004", "S101", "TID252"]
"docs/**/*.py" = [
# Examples require some extra setup before import
"E402",
# Allow exec
"S102",
# Allow print
"T201",
]
"scripts/**/*.py" = [
# Allow print
"T201",
]
================================================
FILE: src/build_scripts/build_js_app.py
================================================
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
import pathlib
import subprocess
import sys
dev_mode = "--dev" in sys.argv
root_dir = pathlib.Path(__file__).parent.parent.parent
build_commands = [
[
"bun",
"install",
"--cwd",
"src/js/packages/@reactpy/app",
],
[
"bun",
"run",
"--cwd",
"src/js/packages/@reactpy/app",
"buildDev" if dev_mode else "build",
],
]
for command in build_commands:
print(f"Running command: '{command}'...") # noqa: T201
subprocess.run(command, check=True, cwd=root_dir) # noqa: S603
================================================
FILE: src/build_scripts/build_js_client.py
================================================
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
import pathlib
import shutil
import subprocess
import sys
dev_mode = "--dev" in sys.argv
root_dir = pathlib.Path(__file__).parent.parent.parent
# Copy LICENSE file
shutil.copyfile(
root_dir / "LICENSE", root_dir / "src/js/packages/@reactpy/client/LICENSE"
)
build_commands = [
[
"bun",
"install",
"--cwd",
"src/js/packages/@reactpy/client",
],
[
"bun",
"run",
"--cwd",
"src/js/packages/@reactpy/client",
"build",
],
]
for command in build_commands:
print(f"Running command: '{command}'...") # noqa: T201
subprocess.run(command, check=True, cwd=root_dir) # noqa: S603
================================================
FILE: src/build_scripts/build_js_event_to_object.py
================================================
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
import pathlib
import shutil
import subprocess
import sys
dev_mode = "--dev" in sys.argv
root_dir = pathlib.Path(__file__).parent.parent.parent
# Copy LICENSE file
shutil.copyfile(
root_dir / "LICENSE", root_dir / "src/js/packages/event-to-object/LICENSE"
)
build_commands = [
[
"bun",
"install",
"--cwd",
"src/js/packages/event-to-object",
],
[
"bun",
"run",
"--cwd",
"src/js/packages/event-to-object",
"build",
],
]
for command in build_commands:
print(f"Running command: '{command}'...") # noqa: T201
subprocess.run(command, check=True, cwd=root_dir) # noqa: S603
================================================
FILE: src/build_scripts/clean_js_dir.py
================================================
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
# Deletes `dist`, `node_modules`, and `tsconfig.tsbuildinfo` from all JS packages in the JS source directory.
import contextlib
import glob
import os
import pathlib
import shutil
print("Cleaning JS source directory...") # noqa: T201
# Get the path to the JS source directory
js_src_dir = pathlib.Path(__file__).parent.parent / "js"
static_output_dir = pathlib.Path(__file__).parent.parent / "reactpy" / "static"
# Delete all `dist` folders
dist_dirs = glob.glob(str(js_src_dir / "**/dist"), recursive=True)
for dist_dir in dist_dirs:
with contextlib.suppress(FileNotFoundError):
shutil.rmtree(dist_dir)
# Delete all `*.tgz` files in `packages/**`
dist_tgz_files = glob.glob(str(js_src_dir / "**/*.tgz"), recursive=True)
for dist_tgz_file in dist_tgz_files:
with contextlib.suppress(FileNotFoundError):
os.remove(dist_tgz_file)
# Delete all `node_modules` folders
node_modules_dirs = glob.glob(str(js_src_dir / "**/node_modules"), recursive=True)
for node_modules_dir in node_modules_dirs:
with contextlib.suppress(FileNotFoundError):
shutil.rmtree(node_modules_dir)
# Delete all `tsconfig.tsbuildinfo` files
tsconfig_tsbuildinfo_files = glob.glob(
str(js_src_dir / "**/tsconfig.tsbuildinfo"), recursive=True
)
for tsconfig_tsbuildinfo_file in tsconfig_tsbuildinfo_files:
with contextlib.suppress(FileNotFoundError):
os.remove(tsconfig_tsbuildinfo_file)
# Delete all `index-*.js` files
index_js_files = glob.glob(str(static_output_dir / "index-*.js*"))
for index_js_file in index_js_files:
with contextlib.suppress(FileNotFoundError):
os.remove(index_js_file)
================================================
FILE: src/build_scripts/copy_dir.py
================================================
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
import logging
import shutil
import sys
from pathlib import Path
def copy_files(source: Path, destination: Path) -> None:
if destination.exists():
shutil.rmtree(destination)
destination.mkdir()
for file in source.iterdir():
if file.is_file():
shutil.copy(file, destination / file.name)
else:
copy_files(file, destination / file.name)
if __name__ == "__main__":
if len(sys.argv) != 3: # noqa
logging.error(
"Script used incorrectly!\nUsage: python copy_dir.py "
)
sys.exit(1)
root_dir = Path(__file__).parent.parent.parent
src = Path(root_dir / sys.argv[1])
dest = Path(root_dir / sys.argv[2])
print(f"Copying files from '{sys.argv[1]}' to '{sys.argv[2]}'...") # noqa: T201
if not src.exists():
logging.error("Source directory %s does not exist", src)
sys.exit(1)
copy_files(src, dest)
================================================
FILE: src/build_scripts/delete_old_coverage.py
================================================
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
import logging
from glob import glob
from pathlib import Path
# Delete old `.coverage*` files in the project root
print("Deleting old coverage files...") # noqa: T201
root_dir = Path(__file__).parent.parent.parent
coverage_files = glob(str(root_dir / ".coverage*"))
for path in coverage_files:
coverage_file = Path(path)
if coverage_file.exists():
try:
coverage_file.unlink()
except Exception as e:
logging.error(f"Failed to delete {coverage_file}: {e}")
================================================
FILE: src/build_scripts/install_playwright.py
================================================
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
import subprocess
print("Installing Playwright browsers...") # noqa: T201
# Install Chromium browser for Playwright, and fail if it cannot be installed
subprocess.run(["playwright", "install", "chromium"], check=True) # noqa: S607
# Try to install system dependencies. We don't generate an exception if this fails
# as *nix systems (such as WSL) return a failure code if there are *any* dependencies
# that could be cleaned up via `sudo apt autoremove`. This occurs even if we weren't
# the ones to install those dependencies in the first place.
subprocess.run(["playwright", "install-deps", "chromium"], check=False) # noqa: S607
================================================
FILE: src/js/.gitignore
================================================
tsconfig.tsbuildinfo
packages/**/package-lock.json
**/dist/*
node_modules
*.tgz
================================================
FILE: src/js/eslint.config.mjs
================================================
import { default as eslint } from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
export default [
eslint.configs.recommended,
...tseslint.configs.recommended,
{ ignores: ["**/node_modules/", "**/dist/"] },
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
ecmaVersion: "latest",
sourceType: "module",
},
rules: {
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-empty-function": "off",
},
},
];
================================================
FILE: src/js/package.json
================================================
{
"workspaces": [
"packages/event-to-object",
"packages/@reactpy/app",
"packages/@reactpy/client"
],
"catalog": {
"preact": "^10.27.2",
"@pyscript/core": "^0.7.11",
"morphdom": "^2.7.7",
"typescript": "^5.9.3",
"json-pointer": "^0.6.2",
"@types/json-pointer": "^1.0.34",
"@reactpy/client": "file:./packages/@reactpy/client",
"event-to-object": "2.0.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"bun-types": "^1.3.3",
"eslint": "^9.39.1",
"globals": "^16.5.0",
"prettier": "^3.6.2",
"typescript-eslint": "^8.47.0"
},
"license": "MIT",
"scripts": {
"format": "prettier --write . && eslint --fix",
"lint": "prettier --check . && eslint"
}
}
================================================
FILE: src/js/packages/@reactpy/app/package.json
================================================
{
"dependencies": {
"@reactpy/client": "catalog:",
"event-to-object": "catalog:",
"preact": "catalog:"
},
"description": "ReactPy's client-side entry point. This is strictly for internal use and is not designed to be distributed.",
"devDependencies": {
"@pyscript/core": "catalog:",
"morphdom": "catalog:",
"typescript": "catalog:"
},
"license": "MIT",
"name": "@reactpy/app",
"scripts": {
"build": "bun build \"src/index.ts\" \"src/preact.ts\" \"src/preact-dom.ts\" \"src/preact-jsx-runtime.ts\" --outdir=\"../../../../reactpy/static/\" --minify --production --sourcemap=\"linked\" --splitting",
"buildDev": "bun build \"src/index.ts\" \"src/preact.ts\" \"src/preact-dom.ts\" \"src/preact-jsx-runtime.ts\" --outdir=\"../../../../reactpy/static/\" --sourcemap=\"linked\" --splitting",
"checkTypes": "tsc --noEmit"
}
}
================================================
FILE: src/js/packages/@reactpy/app/src/index.ts
================================================
export { mountReactPy } from "@reactpy/client";
================================================
FILE: src/js/packages/@reactpy/app/src/preact-dom.ts
================================================
import ReactDOM from "preact/compat";
// @ts-ignore
export * from "preact/compat";
// @ts-ignore
export * from "preact/compat/client";
export default ReactDOM;
================================================
FILE: src/js/packages/@reactpy/app/src/preact-jsx-runtime.ts
================================================
export * from "preact/compat/jsx-runtime";
================================================
FILE: src/js/packages/@reactpy/app/src/preact.ts
================================================
import React from "preact/compat";
// @ts-ignore
export * from "preact/compat";
export default React;
================================================
FILE: src/js/packages/@reactpy/app/tsconfig.json
================================================
{
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"composite": true,
"noEmit": false,
"esModuleInterop": true
},
"extends": "../../../tsconfig.json",
"include": ["src"],
"references": [
{
"path": "../client"
}
]
}
================================================
FILE: src/js/packages/@reactpy/client/README.md
================================================
# @reactpy/client
A client for ReactPy implemented in React
================================================
FILE: src/js/packages/@reactpy/client/package.json
================================================
{
"author": "Mark Bakhit",
"contributors": [
"Ryan Morshead"
],
"dependencies": {
"json-pointer": "catalog:",
"preact": "catalog:",
"event-to-object": "catalog:"
},
"description": "A client for ReactPy implemented in React",
"files": [
"dist",
"src",
"LICENSE"
],
"devDependencies": {
"@types/json-pointer": "catalog:",
"typescript": "catalog:"
},
"keywords": [
"react",
"reactive",
"python",
"reactpy"
],
"license": "MIT",
"main": "dist/index.js",
"name": "@reactpy/client",
"repository": {
"type": "git",
"url": "https://github.com/reactive-python/reactpy"
},
"scripts": {
"build": "tsc -b",
"checkTypes": "tsc --noEmit"
},
"type": "module",
"version": "1.1.0"
}
================================================
FILE: src/js/packages/@reactpy/client/src/bind.tsx
================================================
import * as preact from "preact";
export async function infer_bind_from_environment() {
try {
// @ts-ignore
const React = await import("react");
// @ts-ignore
const ReactDOM = await import("react-dom/client");
return (node: HTMLElement) => reactjs_bind(node, React, ReactDOM);
} catch {
console.debug(
"ReactPy will render JavaScript components using internal bindings for 'react'.",
);
return (node: HTMLElement) => local_preact_bind(node);
}
}
function local_preact_bind(node: HTMLElement) {
return {
create: (type: any, props: any, children?: any[]) =>
preact.createElement(type, props, ...(children || [])),
render: (element: any) => {
preact.render(element, node);
},
unmount: () => preact.render(null, node),
};
}
const roots = new WeakMap();
function reactjs_bind(node: HTMLElement, React: any, ReactDOM: any) {
let root: any = null;
return {
create: (type: any, props: any, children?: any[]) =>
React.createElement(type, props, ...(children || [])),
render: (element: any) => {
if (!root) {
if (!roots.get(node)) {
root = ReactDOM.createRoot(node);
roots.set(node, root);
} else {
root = roots.get(node);
}
}
root.render(element);
},
unmount: () => {
if (root) {
root.unmount();
if (roots.get(node) === root) {
roots.delete(node);
}
root = null;
}
},
};
}
================================================
FILE: src/js/packages/@reactpy/client/src/client.ts
================================================
import logger from "./logger";
import type {
ReactPyClientInterface,
ReactPyModule,
GenericReactPyClientProps,
ReactPyUrls,
} from "./types";
import { createReconnectingWebSocket } from "./websocket";
export abstract class BaseReactPyClient implements ReactPyClientInterface {
private readonly handlers: { [key: string]: ((message: any) => void)[] } = {};
protected readonly ready: Promise;
private resolveReady: (value: undefined) => void;
constructor() {
this.resolveReady = () => {};
this.ready = new Promise((resolve) => (this.resolveReady = resolve));
}
onMessage(type: string, handler: (message: any) => void): () => void {
(this.handlers[type] || (this.handlers[type] = [])).push(handler);
this.resolveReady(undefined);
return () => {
this.handlers[type] = this.handlers[type].filter((h) => h !== handler);
};
}
abstract sendMessage(message: any): void;
abstract loadModule(moduleName: string): Promise;
/**
* Handle an incoming message.
*
* This should be called by subclasses when a message is received.
*
* @param message The message to handle. The message must have a `type` property.
*/
protected handleIncoming(message: any): void {
if (!message.type) {
logger.warn("Received message without type", message);
return;
}
const messageHandlers: ((m: any) => void)[] | undefined =
this.handlers[message.type];
if (!messageHandlers) {
logger.warn("Received message without handler", message);
return;
}
messageHandlers.forEach((h) => h(message));
}
}
export class ReactPyClient
extends BaseReactPyClient
implements ReactPyClientInterface
{
urls: ReactPyUrls;
socket: { current?: WebSocket };
mountElement: HTMLElement;
private readonly messageQueue: any[] = [];
constructor(props: GenericReactPyClientProps) {
super();
this.urls = props.urls;
this.mountElement = props.mountElement;
this.socket = createReconnectingWebSocket({
url: this.urls.componentUrl,
readyPromise: this.ready,
...props.reconnectOptions,
onOpen: () => {
while (this.messageQueue.length > 0) {
this.sendMessage(this.messageQueue.shift());
}
},
onMessage: async ({ data }) => this.handleIncoming(JSON.parse(data)),
});
}
sendMessage(message: any): void {
if (
this.socket.current &&
this.socket.current.readyState === WebSocket.OPEN
) {
this.socket.current.send(JSON.stringify(message));
} else {
this.messageQueue.push(message);
}
}
loadModule(moduleName: string): Promise {
return import(`${this.urls.jsModulesPath}${moduleName}`);
}
}
================================================
FILE: src/js/packages/@reactpy/client/src/components.tsx
================================================
import { set as setJsonPointer } from "json-pointer";
import type { MutableRefObject } from "preact/compat";
import {
createContext,
createElement,
Fragment,
type JSX,
type TargetedEvent,
} from "preact";
import { useContext, useEffect, useRef, useState } from "preact/hooks";
import type {
ImportSourceBinding,
ReactPyComponent,
ReactPyVdom,
} from "./types";
import { createAttributes, createChildren, loadImportSource } from "./vdom";
import type { ReactPyClient } from "./client";
const ClientContext = createContext(null as any);
export function Layout(props: { client: ReactPyClient }): JSX.Element {
const currentModel: ReactPyVdom = useState({ tagName: "" })[0];
const forceUpdate = useForceUpdate();
useEffect(
() =>
props.client.onMessage("layout-update", ({ path, model }) => {
if (path === "") {
Object.assign(currentModel, model);
} else {
setJsonPointer(currentModel, path, model);
}
forceUpdate();
}),
[currentModel, props.client],
);
return (
);
}
export function Element({ model }: { model: ReactPyVdom }): JSX.Element | null {
if (model.error !== undefined) {
if (model.error) {
return
{model.error}
;
} else {
return null;
}
}
let SpecializedElement: ReactPyComponent;
if (model.tagName in SPECIAL_ELEMENTS) {
SpecializedElement =
SPECIAL_ELEMENTS[model.tagName as keyof typeof SPECIAL_ELEMENTS];
} else if (model.importSource) {
SpecializedElement = ImportedElement;
} else {
SpecializedElement = StandardElement;
}
return ;
}
function StandardElement({ model }: { model: ReactPyVdom }) {
const client = useContext(ClientContext);
// Use createElement here to avoid warning about variable numbers of children not
// having keys. Warning about this must now be the responsibility of the client
// providing the models instead of the client rendering them.
return createElement(
model.tagName === "" ? Fragment : model.tagName,
createAttributes(model, client),
...createChildren(model, (child) => {
return ;
}),
);
}
function UserInputElement({ model }: { model: ReactPyVdom }): JSX.Element {
const client = useContext(ClientContext);
const props = createAttributes(model, client);
const [value, setValue] = useState(props.value);
// honor changes to value from the client via props
useEffect(() => setValue(props.value), [props.value]);
const givenOnChange = props.onChange;
if (typeof givenOnChange === "function") {
props.onChange = (event: TargetedEvent) => {
// immediately update the value to give the user feedback
if (event.target) {
setValue((event.target as HTMLInputElement).value);
}
// allow the client to respond (and possibly change the value)
givenOnChange(event);
};
}
// Use createElement here to avoid warning about variable numbers of children not
// having keys. Warning about this must now be the responsibility of the client
// providing the models instead of the client rendering them.
return createElement(
model.tagName,
// overwrite
{ ...props, value },
...createChildren(model, (child) => (
)),
);
}
function ScriptElement({ model }: { model: ReactPyVdom }) {
const ref = useRef(null);
useEffect(() => {
// Don't run if the parent element is missing
if (!ref.current) {
return;
}
// Create the script element
const scriptElement: HTMLScriptElement = document.createElement("script");
for (const [k, v] of Object.entries(model.attributes || {})) {
scriptElement.setAttribute(k, v);
}
// Add the script content as text
const scriptContent = model?.children?.filter(
(value): value is string => typeof value == "string",
)[0];
if (scriptContent) {
scriptElement.appendChild(document.createTextNode(scriptContent));
}
// Append the script element to the parent element
ref.current.appendChild(scriptElement);
// Remove the script element when the component is unmounted
return () => {
ref.current?.removeChild(scriptElement);
};
}, [model.attributes?.key]);
return ;
}
function ImportedElement({ model }: { model: ReactPyVdom }) {
const importSourceVdom = model.importSource;
const importSourceRef = useImportSource(model);
if (!importSourceVdom) {
return null;
}
const importSourceFallback = importSourceVdom.fallback;
if (!importSourceVdom) {
// display a fallback if one was given
if (!importSourceFallback) {
return null;
} else if (typeof importSourceFallback === "string") {
return {importSourceFallback};
} else {
return ;
}
} else {
return ;
}
}
function useForceUpdate() {
const [, setState] = useState(false);
return () => setState((old) => !old);
}
function useImportSource(model: ReactPyVdom): MutableRefObject {
const vdomImportSource = model.importSource;
const vdomImportSourceJsonString = JSON.stringify(vdomImportSource);
const mountPoint = useRef(null);
const client = useContext(ClientContext);
const [binding, setBinding] = useState(null);
const bindingSource = useRef(null);
useEffect(() => {
let unmounted = false;
let currentBinding: ImportSourceBinding | null = null;
if (vdomImportSource) {
loadImportSource(vdomImportSource, client).then((bind) => {
if (!unmounted && mountPoint.current) {
currentBinding = bind(mountPoint.current);
bindingSource.current = vdomImportSourceJsonString;
setBinding(currentBinding);
}
});
}
return () => {
unmounted = true;
if (
currentBinding &&
vdomImportSource &&
!vdomImportSource.unmountBeforeUpdate
) {
currentBinding.unmount();
}
};
}, [client, vdomImportSourceJsonString, setBinding, mountPoint.current]);
// this effect must run every time in case the model has changed
useEffect(() => {
if (!(binding && vdomImportSource)) {
return;
}
if (bindingSource.current !== vdomImportSourceJsonString) {
return;
}
binding.render(model);
if (vdomImportSource.unmountBeforeUpdate) {
return binding.unmount;
}
});
return mountPoint;
}
const SPECIAL_ELEMENTS = {
input: UserInputElement,
script: ScriptElement,
select: UserInputElement,
textarea: UserInputElement,
};
================================================
FILE: src/js/packages/@reactpy/client/src/index.ts
================================================
export * from "./client";
export * from "./components";
export * from "./mount";
export * from "./types";
export * from "./vdom";
export * from "./websocket";
export { default as React } from "preact/compat";
export { default as ReactDOM } from "preact/compat";
export { jsx, jsxs, Fragment } from "preact/jsx-runtime";
export * as preact from "preact";
================================================
FILE: src/js/packages/@reactpy/client/src/logger.ts
================================================
export default {
log: (...args: any[]): void => console.log("[ReactPy]", ...args),
info: (...args: any[]): void => console.info("[ReactPy]", ...args),
warn: (...args: any[]): void => console.warn("[ReactPy]", ...args),
error: (...args: any[]): void => console.error("[ReactPy]", ...args),
};
================================================
FILE: src/js/packages/@reactpy/client/src/mount.tsx
================================================
import { render } from "preact";
import { ReactPyClient } from "./client";
import { Layout } from "./components";
import type { MountProps } from "./types";
export function mountReactPy(props: MountProps) {
// WebSocket route for component rendering
const wsProtocol = `ws${window.location.protocol === "https:" ? "s" : ""}:`;
const wsOrigin = `${wsProtocol}//${window.location.host}`;
const componentUrl = new URL(
`${wsOrigin}${props.pathPrefix}${props.componentPath || ""}`,
);
// Embed the initial HTTP path into the WebSocket URL
componentUrl.searchParams.append("http_pathname", window.location.pathname);
if (window.location.search) {
componentUrl.searchParams.append(
"http_query_string",
window.location.search,
);
}
// Configure a new ReactPy client
const client = new ReactPyClient({
urls: {
componentUrl: componentUrl,
jsModulesPath: `${window.location.origin}${props.pathPrefix}modules/`,
},
reconnectOptions: {
interval: props.reconnectInterval || 750,
maxInterval: props.reconnectMaxInterval || 60000,
maxRetries: props.reconnectMaxRetries || 150,
backoffMultiplier: props.reconnectBackoffMultiplier || 1.25,
},
mountElement: props.mountElement,
});
// Start rendering the component
render(, props.mountElement);
}
================================================
FILE: src/js/packages/@reactpy/client/src/types.ts
================================================
import type { ComponentType } from "preact";
// #### CONNECTION TYPES ####
export type ReconnectOptions = {
interval: number;
maxInterval: number;
maxRetries: number;
backoffMultiplier: number;
};
export type CreateReconnectingWebSocketProps = {
url: URL;
readyPromise: Promise;
onMessage: (message: MessageEvent) => void;
onOpen?: () => void;
onClose?: () => void;
interval: number;
maxInterval: number;
maxRetries: number;
backoffMultiplier: number;
};
export type ReactPyUrls = {
componentUrl: URL;
jsModulesPath: string;
};
export type GenericReactPyClientProps = {
urls: ReactPyUrls;
reconnectOptions: ReconnectOptions;
mountElement: HTMLElement;
};
export type MountProps = {
mountElement: HTMLElement;
pathPrefix: string;
componentPath?: string;
reconnectInterval?: number;
reconnectMaxInterval?: number;
reconnectMaxRetries?: number;
reconnectBackoffMultiplier?: number;
};
// #### COMPONENT TYPES ####
export type ReactPyComponent = ComponentType<{ model: ReactPyVdom }>;
export type ReactPyVdom = {
tagName: string;
attributes?: { [key: string]: string };
children?: (ReactPyVdom | string)[];
error?: string;
eventHandlers?: { [key: string]: ReactPyVdomEventHandler };
inlineJavaScript?: { [key: string]: string };
importSource?: ReactPyVdomImportSource;
};
export type ReactPyVdomEventHandler = {
target: string;
preventDefault?: boolean;
stopPropagation?: boolean;
};
export type ReactPyVdomImportSource = {
source: string;
sourceType?: "URL" | "NAME";
fallback?: string | ReactPyVdom;
unmountBeforeUpdate?: boolean;
};
export type ReactPyModule = {
bind: (
node: HTMLElement,
context: ReactPyModuleBindingContext,
) => ReactPyModuleBinding;
} & { [key: string]: any };
export type ReactPyModuleBindingContext = {
sendMessage: ReactPyClientInterface["sendMessage"];
onMessage: ReactPyClientInterface["onMessage"];
};
export type ReactPyModuleBinding = {
create: (
type: any,
props?: any,
children?: (any | string | ReactPyVdom)[],
) => any;
render: (element: any) => void;
unmount: () => void;
};
export type BindImportSource = (
node: HTMLElement,
) => ImportSourceBinding | null;
export type ImportSourceBinding = {
render: (model: ReactPyVdom) => void;
unmount: () => void;
};
// #### MESSAGE TYPES ####
export type LayoutUpdateMessage = {
type: "layout-update";
path: string;
model: ReactPyVdom;
};
export type LayoutEventMessage = {
type: "layout-event";
target: string;
data: any;
};
export type IncomingMessage = LayoutUpdateMessage;
export type OutgoingMessage = LayoutEventMessage;
export type Message = IncomingMessage | OutgoingMessage;
// #### INTERFACES ####
/**
* A client for communicating with a ReactPy server.
*/
export interface ReactPyClientInterface {
/**
* Register a handler for a message type.
*
* The first time this is called, the client will be considered ready.
*
* @param type The type of message to handle.
* @param handler The handler to call when a message of the given type is received.
* @returns A function to unregister the handler.
*/
onMessage(type: string, handler: (message: any) => void): () => void;
/**
* Send a message to the server.
*
* @param message The message to send. Messages must have a `type` property.
*/
sendMessage(message: any): void;
/**
* Load a module from the server.
* @param moduleName The name of the module to load.
* @returns A promise that resolves to the module.
*/
loadModule(moduleName: string): Promise;
}
================================================
FILE: src/js/packages/@reactpy/client/src/vdom.tsx
================================================
import eventToObject from "event-to-object";
import { Fragment } from "preact";
import type {
ReactPyVdom,
ReactPyVdomImportSource,
ReactPyVdomEventHandler,
ReactPyModule,
BindImportSource,
ReactPyModuleBinding,
ImportSourceBinding,
} from "./types";
import { infer_bind_from_environment } from "./bind";
import log from "./logger";
import type { ReactPyClient } from "./client";
export async function loadImportSource(
vdomImportSource: ReactPyVdomImportSource,
client: ReactPyClient,
): Promise {
let module: ReactPyModule;
if (vdomImportSource.sourceType === "URL") {
module = await import(vdomImportSource.source);
} else {
module = await client.loadModule(vdomImportSource.source);
}
let { bind } = module;
if (typeof bind !== "function") {
bind = await infer_bind_from_environment();
}
return (node: HTMLElement) => {
const binding = bind(node, {
sendMessage: client.sendMessage,
onMessage: client.onMessage,
});
if (
!(
typeof binding.create === "function" &&
typeof binding.render === "function" &&
typeof binding.unmount === "function"
)
) {
log.error(`${vdomImportSource.source} returned an impropper binding`);
return null;
}
return {
render: (model) =>
binding.render(
createImportSourceElement({
client,
module,
binding,
model,
currentImportSource: vdomImportSource,
}),
),
unmount: binding.unmount,
};
};
}
function createImportSourceElement(props: {
client: ReactPyClient;
module: ReactPyModule;
binding: ReactPyModuleBinding;
model: ReactPyVdom;
currentImportSource: ReactPyVdomImportSource;
}): any {
let type: any;
if (props.model.importSource) {
if (
!isImportSourceEqual(props.currentImportSource, props.model.importSource)
) {
return props.binding.create("reactpy-child", {
ref: (node: ReactPyChild | null) => {
if (node) {
node.client = props.client;
node.model = props.model;
node.requestUpdate();
}
},
});
} else {
type = getComponentFromModule(
props.module,
props.model.tagName,
props.model.importSource,
);
if (!type) {
// Error message logged within getComponentFromModule
return null;
}
}
} else {
type = props.model.tagName === "" ? Fragment : props.model.tagName;
}
return props.binding.create(
type,
createAttributes(props.model, props.client),
createChildren(props.model, (child) =>
createImportSourceElement({
...props,
model: child,
}),
),
);
}
function getComponentFromModule(
module: ReactPyModule,
componentName: string,
importSource: ReactPyVdomImportSource,
): any {
/* Gets the component with the provided name from the provided module.
Built specifically to work on inifinitely deep nested components.
For example, component "My.Nested.Component" is accessed from
ModuleA like so: ModuleA["My"]["Nested"]["Component"].
*/
const componentParts: string[] = componentName.split(".");
let Component: any = null;
for (let i = 0; i < componentParts.length; i++) {
const iterAttr = componentParts[i];
Component = i == 0 ? module[iterAttr] : Component[iterAttr];
if (!Component) {
if (i == 0) {
log.error(
"Module from source " +
stringifyImportSource(importSource) +
` does not export ${iterAttr}`,
);
} else {
console.error(
`Component ${componentParts.slice(0, i).join(".")} from source ` +
stringifyImportSource(importSource) +
` does not have subcomponent ${iterAttr}`,
);
}
break;
}
}
return Component;
}
function isImportSourceEqual(
source1: ReactPyVdomImportSource,
source2: ReactPyVdomImportSource,
) {
return (
source1.source === source2.source &&
source1.sourceType === source2.sourceType
);
}
function stringifyImportSource(importSource: ReactPyVdomImportSource) {
return JSON.stringify({
source: importSource.source,
sourceType: importSource.sourceType,
});
}
export function createChildren(
model: ReactPyVdom,
createChild: (child: ReactPyVdom) => Child,
): (Child | string)[] {
if (!model.children) {
return [];
} else {
return model.children.map((child) => {
switch (typeof child) {
case "object":
return createChild(child);
case "string":
return child;
}
});
}
}
export function createAttributes(
model: ReactPyVdom,
client: ReactPyClient,
): { [key: string]: any } {
return Object.fromEntries(
Object.entries({
// Normal HTML attributes
...model.attributes,
// Construct event handlers
...Object.fromEntries(
Object.entries(model.eventHandlers || {}).map(([name, handler]) =>
createEventHandler(client, name, handler),
),
),
...Object.fromEntries(
Object.entries(model.inlineJavaScript || {}).map(
([name, inlineJavaScript]) =>
createInlineJavaScript(name, inlineJavaScript),
),
),
}),
);
}
function createEventHandler(
client: ReactPyClient,
name: string,
{ target, preventDefault, stopPropagation }: ReactPyVdomEventHandler,
): [string, () => void] {
const eventHandler = function (...args: any[]) {
const data = Array.from(args).map((value) => {
const event = value as Event;
if (preventDefault) {
event.preventDefault();
}
if (stopPropagation) {
event.stopPropagation();
}
// Convert JavaScript objects to plain JSON, if needed
if (typeof event === "object") {
return eventToObject(event);
} else {
return event;
}
});
client.sendMessage({ type: "layout-event", data, target });
};
eventHandler.isHandler = true;
return [name, eventHandler];
}
function createInlineJavaScript(
name: string,
inlineJavaScript: string,
): [string, () => void] {
/* Function that will execute the string-like InlineJavaScript
via eval in the most appropriate way */
const wrappedExecutable = function (...args: any[]) {
function handleExecution(...args: any[]) {
const evalResult = eval(inlineJavaScript);
if (typeof evalResult == "function") {
return evalResult(...args);
}
}
if (args.length > 0 && args[0] instanceof Event) {
/* If being triggered by an event, set the event's current
target to "this". This ensures that inline
javascript statements such as the following work:
html.button({"onclick": 'this.value = "Clicked!"'}, "Click Me")*/
return handleExecution.call(args[0].currentTarget, ...args);
} else {
/* If not being triggered by an event, do not set "this" and
just call normally */
return handleExecution(...args);
}
};
wrappedExecutable.isHandler = false;
return [name, wrappedExecutable];
}
class ReactPyChild extends HTMLElement {
mountPoint: HTMLDivElement;
binding: ImportSourceBinding | null = null;
_client: ReactPyClient | null = null;
_model: ReactPyVdom | null = null;
currentImportSource: ReactPyVdomImportSource | null = null;
constructor() {
super();
this.mountPoint = document.createElement("div");
this.mountPoint.style.display = "contents";
}
connectedCallback() {
this.appendChild(this.mountPoint);
}
set client(value: ReactPyClient) {
this._client = value;
}
set model(value: ReactPyVdom) {
this._model = value;
}
requestUpdate() {
this.update();
}
async update() {
if (!this._client || !this._model || !this._model.importSource) {
return;
}
const newImportSource = this._model.importSource;
if (
!this.binding ||
!this.currentImportSource ||
!isImportSourceEqual(this.currentImportSource, newImportSource)
) {
if (this.binding) {
this.binding.unmount();
this.binding = null;
}
this.currentImportSource = newImportSource;
try {
const bind = await loadImportSource(newImportSource, this._client);
if (
this.isConnected &&
this.currentImportSource &&
isImportSourceEqual(this.currentImportSource, newImportSource)
) {
const oldBinding = this.binding as ImportSourceBinding | null;
if (oldBinding) {
oldBinding.unmount();
}
this.binding = bind(this.mountPoint);
if (this.binding) {
this.binding.render(this._model);
}
}
} catch (error) {
console.error("Failed to load import source", error);
}
} else {
if (this.binding) {
this.binding.render(this._model);
}
}
}
disconnectedCallback() {
if (this.binding) {
this.binding.unmount();
this.binding = null;
this.currentImportSource = null;
}
}
}
if (
typeof customElements !== "undefined" &&
!customElements.get("reactpy-child")
) {
customElements.define("reactpy-child", ReactPyChild);
}
================================================
FILE: src/js/packages/@reactpy/client/src/websocket.ts
================================================
import type { CreateReconnectingWebSocketProps } from "./types";
import log from "./logger";
export function createReconnectingWebSocket(
props: CreateReconnectingWebSocketProps,
) {
const { interval, maxInterval, maxRetries, backoffMultiplier } = props;
let retries = 0;
let currentInterval = interval;
let everConnected = false;
const closed = false;
const socket: { current?: WebSocket } = {};
const connect = () => {
if (closed) {
return;
}
socket.current = new WebSocket(props.url);
socket.current.onopen = () => {
everConnected = true;
log.info("Connected!");
currentInterval = interval;
retries = 0;
if (props.onOpen) {
props.onOpen();
}
};
socket.current.onmessage = (event) => {
if (props.onMessage) {
props.onMessage(event);
}
};
socket.current.onclose = () => {
if (props.onClose) {
props.onClose();
}
if (!everConnected) {
log.info("Failed to connect!");
return;
}
log.info("Disconnected!");
if (retries >= maxRetries) {
log.info("Connection max retries exhausted!");
return;
}
log.info(
`Reconnecting in ${(currentInterval / 1000).toPrecision(4)} seconds...`,
);
setTimeout(connect, currentInterval);
currentInterval = nextInterval(
currentInterval,
backoffMultiplier,
maxInterval,
);
retries++;
};
};
props.readyPromise.then(() => log.info("Starting client...")).then(connect);
return socket;
}
export function nextInterval(
currentInterval: number,
backoffMultiplier: number,
maxInterval: number,
): number {
return Math.min(
// increase interval by backoff multiplier
currentInterval * backoffMultiplier,
// don't exceed max interval
maxInterval,
);
}
================================================
FILE: src/js/packages/@reactpy/client/tsconfig.json
================================================
{
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"composite": true,
"noEmit": false
},
"extends": "../../../tsconfig.json",
"include": ["src"],
"references": [
{
"path": "../../event-to-object"
}
]
}
================================================
FILE: src/js/packages/event-to-object/README.md
================================================
# Event to Object
Converts a JavaScript events to JSON serializable objects.
================================================
FILE: src/js/packages/event-to-object/package.json
================================================
{
"author": "Mark Bakhit",
"contributors": [
"Ryan Morshead"
],
"dependencies": {
"json-pointer": "catalog:"
},
"description": "Converts a JavaScript events to JSON serializable objects.",
"files": [
"dist",
"src",
"LICENSE"
],
"devDependencies": {
"happy-dom": "^15.0.0",
"lodash": "^4.17.21",
"typescript": "^5.8.3",
"vitest": "^2.1.8"
},
"keywords": [
"event",
"json",
"object",
"convert"
],
"license": "MIT",
"main": "dist/index.js",
"name": "event-to-object",
"repository": {
"type": "git",
"url": "https://github.com/reactive-python/reactpy"
},
"scripts": {
"build": "tsc -b",
"checkTypes": "tsc --noEmit"
},
"type": "module",
"version": "2.0.0"
}
================================================
FILE: src/js/packages/event-to-object/src/index.ts
================================================
const maxDepthSignal = { __stop__: true };
/**
* Convert any class object (such as `Event`) to a plain object.
*/
export default function convert(
classObject: { [key: string]: any },
maxDepth: number = 10,
): object {
// Immediately return `classObject` if given an unexpected (non-object) input
if (!classObject || typeof classObject !== "object") {
console.warn(
"eventToObject: Expected an object input, received:",
classObject,
);
return classObject;
}
// Begin conversion
const visited = new WeakSet();
visited.add(classObject);
const convertedObj: { [key: string]: any } = {};
for (const key in classObject) {
// Skip keys that cannot be converted
try {
if (shouldIgnoreValue(classObject[key], key)) {
continue;
}
// Handle objects (potentially cyclical)
else if (typeof classObject[key] === "object") {
const result = deepCloneClass(classObject[key], maxDepth, visited);
if (result !== maxDepthSignal) {
convertedObj[key] = result;
}
}
// Handle simple types (non-cyclical)
else {
convertedObj[key] = classObject[key];
}
} catch {
continue;
}
}
// Special case: Event selection
if (
typeof window !== "undefined" &&
window.Event &&
classObject instanceof window.Event
) {
convertedObj["selection"] = serializeSelection(maxDepth, visited);
}
return convertedObj;
}
/**
* Serialize the current window selection.
*/
function serializeSelection(
maxDepth: number,
visited: WeakSet,
): object | null {
if (typeof window === "undefined" || !window.getSelection) {
return null;
}
const selection = window.getSelection();
if (!selection) {
return null;
}
return {
type: selection.type,
anchorNode: selection.anchorNode
? deepCloneClass(selection.anchorNode, maxDepth, visited)
: null,
anchorOffset: selection.anchorOffset,
focusNode: selection.focusNode
? deepCloneClass(selection.focusNode, maxDepth, visited)
: null,
focusOffset: selection.focusOffset,
isCollapsed: selection.isCollapsed,
rangeCount: selection.rangeCount,
selectedText: selection.toString(),
};
}
/**
* Recursively convert a class-based object to a plain object.
*/
function deepCloneClass(
x: any,
_maxDepth: number,
visited: WeakSet,
): object {
const maxDepth = _maxDepth - 1;
// Return an indicator if maxDepth is reached
if (maxDepth <= 0 && typeof x === "object") {
return maxDepthSignal;
}
// Safety check: WeakSet only accepts objects (and not null)
if (!x || typeof x !== "object") {
return x;
}
if (visited.has(x)) {
return maxDepthSignal;
}
visited.add(x);
try {
// Convert array-like class (e.g., NodeList, ClassList, HTMLCollection)
if (
Array.isArray(x) ||
(typeof x?.length === "number" &&
typeof x[Symbol.iterator] === "function" &&
!Object.prototype.toString.call(x).includes("Map") &&
!(x instanceof CSSStyleDeclaration))
) {
return classToArray(x, maxDepth, visited);
}
// Convert mapping-like class (e.g., Node, Map, Set)
return classToObject(x, maxDepth, visited);
} finally {
visited.delete(x);
}
}
/**
* Convert an array-like class to a plain array.
*/
function classToArray(
x: any,
maxDepth: number,
visited: WeakSet,
): Array {
const result: Array = [];
for (let i = 0; i < x.length; i++) {
// Skip anything that should not be converted
if (shouldIgnoreValue(x[i])) {
continue;
}
// Only push objects as if we haven't reached max depth
else if (typeof x[i] === "object") {
const converted = deepCloneClass(x[i], maxDepth, visited);
if (converted !== maxDepthSignal) {
result.push(converted);
}
}
// Add plain values if not skippable
else {
result.push(x[i]);
}
}
return result;
}
/**
* Convert a mapping-like class to a plain JSON object.
* We must iterate through it with a for-loop in order to gain
* access to properties from all parent classes.
*/
function classToObject(
x: any,
maxDepth: number,
visited: WeakSet,
): object {
const result: { [key: string]: any } = {};
for (const key in x) {
try {
// Skip anything that should not be converted
if (shouldIgnoreValue(x[key], key, x)) {
continue;
}
// Add objects as a property if we haven't reached max depth
else if (typeof x[key] === "object") {
const converted = deepCloneClass(x[key], maxDepth, visited);
if (converted !== maxDepthSignal) {
result[key] = converted;
}
}
// Add plain values if not skippable
else {
result[key] = x[key];
}
} catch {
continue;
}
}
// Explicitly include dataset if it exists (it might not be enumerable)
if (
x &&
typeof x === "object" &&
"dataset" in x &&
!Object.prototype.hasOwnProperty.call(result, "dataset")
) {
const dataset = x["dataset"];
if (!shouldIgnoreValue(dataset, "dataset", x)) {
const converted = deepCloneClass(dataset, maxDepth, visited);
if (converted !== maxDepthSignal) {
result["dataset"] = converted;
}
}
}
// Explicitly include common input properties if they exist
const extraProps = ["value", "checked", "files", "type", "name"];
for (const prop of extraProps) {
if (
x &&
typeof x === "object" &&
prop in x &&
!Object.prototype.hasOwnProperty.call(result, prop)
) {
const val = x[prop];
if (!shouldIgnoreValue(val, prop, x)) {
if (typeof val === "object") {
// Ensure files have enough depth to be serialized
const propDepth = prop === "files" ? Math.max(maxDepth, 3) : maxDepth;
const converted = deepCloneClass(val, propDepth, visited);
if (converted !== maxDepthSignal) {
result[prop] = converted;
}
} else {
result[prop] = val;
}
}
}
}
// Explicitly include form elements if they exist and are not enumerable
const win = typeof window !== "undefined" ? window : undefined;
// @ts-ignore
const FormClass = win
? win.HTMLFormElement
: typeof HTMLFormElement !== "undefined"
? HTMLFormElement
: undefined;
if (FormClass && x instanceof FormClass && x.elements) {
for (let i = 0; i < x.elements.length; i++) {
const element = x.elements[i] as any;
if (
element.name &&
!Object.prototype.hasOwnProperty.call(result, element.name) &&
!shouldIgnoreValue(element, element.name, x)
) {
if (typeof element === "object") {
const converted = deepCloneClass(element, maxDepth, visited);
if (converted !== maxDepthSignal) {
result[element.name] = converted;
}
} else {
result[element.name] = element;
}
}
}
}
return result;
}
/**
* Check if a value is non-convertible or holds minimal value.
*/
function shouldIgnoreValue(
value: any,
keyName: string = "",
parent: any = undefined,
): boolean {
return (
// Useless data
value === null ||
value === undefined ||
keyName.startsWith("__") ||
(keyName.length > 0 && /^[A-Z_]+$/.test(keyName)) ||
// Non-convertible types
typeof value === "function" ||
value instanceof CSSStyleSheet ||
value instanceof Window ||
value instanceof Document ||
keyName === "view" ||
keyName === "size" ||
keyName === "length" ||
(parent instanceof CSSStyleDeclaration && value === "") ||
// DOM Node Blacklist
(typeof Node !== "undefined" &&
parent instanceof Node &&
// Recursive properties
(keyName === "parentNode" ||
keyName === "parentElement" ||
keyName === "ownerDocument" ||
keyName === "getRootNode" ||
keyName === "childNodes" ||
keyName === "children" ||
keyName === "firstChild" ||
keyName === "lastChild" ||
keyName === "previousSibling" ||
keyName === "nextSibling" ||
keyName === "previousElementSibling" ||
keyName === "nextElementSibling" ||
// Potentially large data
keyName === "innerHTML" ||
keyName === "outerHTML" ||
// Reflow triggers
keyName === "offsetParent" ||
keyName === "offsetWidth" ||
keyName === "offsetHeight" ||
keyName === "offsetLeft" ||
keyName === "offsetTop" ||
keyName === "clientTop" ||
keyName === "clientLeft" ||
keyName === "clientWidth" ||
keyName === "clientHeight" ||
keyName === "scrollWidth" ||
keyName === "scrollHeight" ||
keyName === "scrollTop" ||
keyName === "scrollLeft"))
);
}
================================================
FILE: src/js/packages/event-to-object/tests/event-to-object.test.ts
================================================
// @ts-ignore
import { window } from "./tooling/setup";
import { test, expect } from "bun:test";
import { Event } from "happy-dom";
import convert from "../src/index";
import { checkEventConversion } from "./tooling/check";
import { mockGamepad, mockTouch, mockTouchObject } from "./tooling/mock";
type SimpleTestCase = {
types: string[];
description: string;
givenEventType: new (type: string) => E;
expectedConversion: any;
initGivenEvent?: (event: E) => void;
};
const simpleTestCases: SimpleTestCase[] = [
{
types: [
"animationcancel",
"animationend",
"animationiteration",
"animationstart",
],
description: "animation event",
givenEventType: window.AnimationEvent,
expectedConversion: {
animationName: "",
pseudoElement: "",
elapsedTime: 0,
},
},
{
types: ["beforeinput"],
description: "event",
givenEventType: window.InputEvent,
expectedConversion: {
detail: 0,
data: "",
inputType: "",
dataTransfer: null,
isComposing: false,
},
},
{
types: ["compositionend", "compositionstart", "compositionupdate"],
description: "composition event",
givenEventType: window.CompositionEvent,
expectedConversion: {
data: undefined,
detail: undefined,
},
},
{
types: ["copy", "cut", "paste"],
description: "clipboard event",
givenEventType: window.ClipboardEvent,
expectedConversion: { clipboardData: null },
},
{
types: [
"drag",
"dragend",
"dragenter",
"dragleave",
"dragover",
"dragstart",
"drop",
],
description: "drag event",
givenEventType: window.DragEvent,
expectedConversion: {
altKey: undefined,
button: undefined,
buttons: undefined,
clientX: undefined,
clientY: undefined,
ctrlKey: undefined,
dataTransfer: null,
metaKey: undefined,
movementX: undefined,
movementY: undefined,
offsetX: undefined,
offsetY: undefined,
pageX: undefined,
pageY: undefined,
relatedTarget: null,
screenX: undefined,
screenY: undefined,
shiftKey: undefined,
x: undefined,
y: undefined,
},
},
{
types: ["error"],
description: "event",
givenEventType: window.ErrorEvent,
expectedConversion: { detail: 0 },
},
{
types: ["blur", "focus", "focusin", "focusout"],
description: "focus event",
givenEventType: window.FocusEvent,
expectedConversion: {
relatedTarget: null,
detail: 0,
},
},
{
types: ["gamepadconnected", "gamepaddisconnected"],
description: "gamepad event",
givenEventType: window.GamepadEvent,
expectedConversion: { gamepad: mockGamepad },
initGivenEvent: (event) => {
event.gamepad = mockGamepad;
},
},
{
types: ["keydown", "keypress", "keyup"],
description: "keyboard event",
givenEventType: window.KeyboardEvent,
expectedConversion: {
altKey: false,
code: "",
ctrlKey: false,
isComposing: false,
key: "",
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
detail: 0,
},
},
{
types: [
"click",
"auxclick",
"dblclick",
"mousedown",
"mouseenter",
"mouseleave",
"mousemove",
"mouseout",
"mouseover",
"mouseup",
"scroll",
],
description: "mouse event",
givenEventType: window.MouseEvent,
expectedConversion: {
altKey: false,
button: 0,
buttons: 0,
clientX: 0,
clientY: 0,
ctrlKey: false,
metaKey: false,
movementX: 0,
movementY: 0,
offsetX: 0,
offsetY: 0,
pageX: 0,
pageY: 0,
relatedTarget: null,
screenX: 0,
screenY: 0,
shiftKey: false,
x: undefined,
y: undefined,
},
},
{
types: [
"auxclick",
"click",
"contextmenu",
"dblclick",
"mousedown",
"mouseenter",
"mouseleave",
"mousemove",
"mouseout",
"mouseover",
"mouseup",
],
description: "mouse event",
givenEventType: window.MouseEvent,
expectedConversion: {
altKey: false,
button: 0,
buttons: 0,
clientX: 0,
clientY: 0,
ctrlKey: false,
metaKey: false,
movementX: 0,
movementY: 0,
offsetX: 0,
offsetY: 0,
pageX: 0,
pageY: 0,
relatedTarget: null,
screenX: 0,
screenY: 0,
shiftKey: false,
x: undefined,
y: undefined,
},
},
{
types: [
"gotpointercapture",
"lostpointercapture",
"pointercancel",
"pointerdown",
"pointerenter",
"pointerleave",
"pointerlockchange",
"pointerlockerror",
"pointermove",
"pointerout",
"pointerover",
"pointerup",
],
description: "pointer event",
givenEventType: window.PointerEvent,
expectedConversion: {
altKey: false,
button: 0,
buttons: 0,
clientX: 0,
clientY: 0,
ctrlKey: false,
metaKey: false,
movementX: 0,
movementY: 0,
offsetX: 0,
offsetY: 0,
pageX: 0,
pageY: 0,
relatedTarget: null,
screenX: 0,
screenY: 0,
shiftKey: false,
x: undefined,
y: undefined,
pointerId: 0,
pointerType: "",
pressure: 0,
tiltX: 0,
tiltY: 0,
width: 1,
height: 1,
isPrimary: false,
twist: 0,
tangentialPressure: 0,
},
},
{
types: ["submit"],
description: "event",
givenEventType: window.Event,
expectedConversion: { submitter: null },
initGivenEvent: (event) => {
event.submitter = null;
},
},
{
types: ["touchcancel", "touchend", "touchmove", "touchstart"],
description: "touch event",
givenEventType: window.TouchEvent,
expectedConversion: {
altKey: undefined,
changedTouches: [mockTouchObject],
ctrlKey: undefined,
metaKey: undefined,
targetTouches: [mockTouchObject],
touches: [mockTouchObject],
detail: undefined,
shiftKey: undefined,
},
initGivenEvent: (event) => {
event.changedTouches = [mockTouch];
event.targetTouches = [mockTouch];
event.touches = [mockTouch];
},
},
{
types: [
"transitioncancel",
"transitionend",
"transitionrun",
"transitionstart",
],
description: "transition event",
givenEventType: window.TransitionEvent,
expectedConversion: {
propertyName: undefined,
elapsedTime: undefined,
pseudoElement: undefined,
},
},
{
types: ["wheel"],
description: "wheel event",
givenEventType: window.WheelEvent,
expectedConversion: {
altKey: undefined,
button: undefined,
buttons: undefined,
clientX: undefined,
clientY: undefined,
ctrlKey: undefined,
deltaMode: 0,
deltaX: 0,
deltaY: 0,
deltaZ: 0,
metaKey: undefined,
movementX: undefined,
movementY: undefined,
offsetX: undefined,
offsetY: undefined,
pageX: 0,
pageY: 0,
relatedTarget: null,
screenX: undefined,
screenY: undefined,
shiftKey: undefined,
x: undefined,
y: undefined,
},
},
];
simpleTestCases.forEach((testCase) => {
testCase.types.forEach((type) => {
test(`converts ${type} ${testCase.description}`, () => {
const event = new testCase.givenEventType(type);
if (testCase.initGivenEvent) {
testCase.initGivenEvent(event);
}
checkEventConversion(event, testCase.expectedConversion);
});
});
});
test("adds text of current selection", () => {
document.body.innerHTML = `
START
MIDDLE
END
`;
const start = document.getElementById("start");
const end = document.getElementById("end");
window.getSelection()!.setBaseAndExtent(start! as any, 0, end! as any, 0);
checkEventConversion(new window.Event("fake"), {
type: "fake",
selection: {
type: "Range",
anchorNode: {},
anchorOffset: 0,
focusNode: {},
focusOffset: 0,
isCollapsed: false,
rangeCount: 1,
selectedText: "START\n MIDDLE\n ",
},
eventPhase: undefined,
isTrusted: undefined,
});
});
test("includes data-* attributes in dataset", () => {
const div = document.createElement("div");
div.setAttribute("data-test-value", "123");
div.setAttribute("data-other", "foo");
const event = new window.Event("click");
Object.defineProperty(event, "target", {
value: div,
enumerable: true,
writable: true,
});
Object.defineProperty(event, "currentTarget", {
value: div,
enumerable: true,
writable: true,
});
checkEventConversion(event, {
target: {
dataset: {
testValue: "123",
other: "foo",
},
},
currentTarget: {
dataset: {
testValue: "123",
other: "foo",
},
},
});
});
test("includes value and checked for radio and checkbox inputs", () => {
const radio = document.createElement("input");
radio.type = "radio";
radio.checked = true;
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = true;
const radioEvent = new window.Event("change");
Object.defineProperty(radioEvent, "target", {
value: radio,
enumerable: true,
writable: true,
});
checkEventConversion(radioEvent, {
target: {
value: "on",
checked: true,
type: "radio",
},
});
const checkboxEvent = new window.Event("change");
Object.defineProperty(checkboxEvent, "target", {
value: checkbox,
enumerable: true,
writable: true,
});
checkEventConversion(checkboxEvent, {
target: {
value: "on",
checked: true,
type: "checkbox",
},
});
});
test("excludes 'on' properties when missing", () => {
const div = document.createElement("div");
div.onclick = () => {};
// @ts-ignore
div.oncustom = null;
const event = new window.Event("click");
Object.defineProperty(event, "target", {
value: div,
enumerable: true,
writable: true,
});
const converted: any = convert(event);
expect(converted.target.onclick).toBeUndefined();
expect(converted.target.oncustom).toBeUndefined();
});
test("includes name property for inputs", () => {
const input = document.createElement("input");
input.name = "test-input";
input.value = "test-value";
const event = new window.Event("change");
Object.defineProperty(event, "target", {
value: input,
enumerable: true,
writable: true,
});
checkEventConversion(event, {
target: {
name: "test-input",
value: "test-value",
},
});
});
test("includes checked property for checkboxes", () => {
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
// Test checked = true
checkbox.checked = true;
let event = new window.Event("change");
Object.defineProperty(event, "target", {
value: checkbox,
enumerable: true,
writable: true,
});
checkEventConversion(event, {
target: {
checked: true,
type: "checkbox",
},
});
// Test checked = false
checkbox.checked = false;
event = new window.Event("change");
Object.defineProperty(event, "target", {
value: checkbox,
enumerable: true,
writable: true,
});
checkEventConversion(event, {
target: {
checked: false,
type: "checkbox",
},
});
});
test("converts file input with files", () => {
const input = window.document.createElement("input");
input.type = "file";
// Create a mock file
const file = new window.File(["content"], "test.txt", {
type: "text/plain",
lastModified: 1234567890,
});
// Mock the files property
const mockFileList = {
0: file,
length: 1,
item: (index: number) => (index === 0 ? file : null),
[Symbol.iterator]: function* () {
yield file;
},
};
Object.defineProperty(input, "files", {
value: mockFileList,
writable: true,
});
const event = new window.Event("change");
Object.defineProperty(event, "target", {
value: input,
enumerable: true,
writable: true,
});
const converted: any = convert(event);
expect(converted.target.files).toBeDefined();
expect(converted.target.files.length).toBe(1);
expect(converted.target.files[0].name).toBe("test.txt");
});
test("converts form submission with file input", () => {
const form = window.document.createElement("form");
const input = window.document.createElement("input");
input.type = "file";
input.name = "myFile";
// Create a mock file
const file = new window.File(["content"], "test.txt", {
type: "text/plain",
lastModified: 1234567890,
});
// Mock the files property
const mockFileList = {
0: file,
length: 1,
item: (index: number) => (index === 0 ? file : null),
[Symbol.iterator]: function* () {
yield file;
},
};
Object.defineProperty(input, "files", {
value: mockFileList,
writable: true,
});
form.appendChild(input);
const event = new window.Event("submit");
Object.defineProperty(event, "target", {
value: form,
enumerable: true,
writable: true,
});
const converted: any = convert(event);
expect(converted.target.myFile).toBeDefined();
expect(converted.target.myFile.files).toBeDefined();
expect(converted.target.myFile.files.length).toBe(1);
expect(converted.target.myFile.files[0].name).toBe("test.txt");
});
test("handles recursive structures", () => {
// Direct recursion
const recursive: any = { a: 1 };
recursive.self = recursive;
const converted: any = convert(recursive);
expect(converted.a).toBe(1);
expect(converted.self).toBeUndefined();
// Indirect recursion
const indirect: any = { name: "root" };
const child: any = { name: "child" };
indirect.child = child;
child.parent = indirect;
const convertedIndirect: any = convert(indirect);
expect(convertedIndirect.name).toBe("root");
expect(convertedIndirect.child.name).toBe("child");
expect(convertedIndirect.child.parent).toBeUndefined();
});
test("handles shared references without stopping", () => {
const shared = { name: "shared" };
const root = {
left: { item: shared },
right: { item: shared },
};
const converted: any = convert(root);
expect(converted.left.item.name).toBe("shared");
expect(converted.right.item.name).toBe("shared");
expect(converted.left.item).not.toEqual({ __stop__: true });
expect(converted.right.item).not.toEqual({ __stop__: true });
});
test("handles recursive HTML node structures", () => {
const parent = window.document.createElement("div");
const child = window.document.createElement("span");
parent.appendChild(child);
// Add explicit circular references to ensure we test recursion
// even if standard DOM properties are not enumerable in this environment.
(parent as any).circular = parent;
(child as any).parentLink = parent;
(parent as any).childLink = child;
const converted: any = convert(parent);
// Verify explicit cycle is handled
expect(converted.circular).toBeUndefined();
// Verify child link is handled
if (converted.childLink) {
expect(converted.childLink.parentLink).toBeUndefined();
}
// If the DOM implementation enumerates parentNode, it should be handled gracefully
if (
converted.children &&
converted.children.length > 0 &&
converted.children[0].parentNode
) {
expect(converted.children[0].parentNode).toBeUndefined();
}
});
test("pass-through on unexpected non-object inputs", () => {
expect(convert(null as any)).toEqual(null);
expect(convert(undefined as any)).toEqual(undefined);
expect(convert(42 as any)).toEqual(42);
expect(convert("test" as any)).toEqual("test");
});
================================================
FILE: src/js/packages/event-to-object/tests/tooling/check.ts
================================================
import { expect } from "bun:test";
import { Event } from "happy-dom";
// @ts-ignore
import lodash from "lodash";
import convert from "../../src/index";
export function checkEventConversion(
givenEvent: Event,
expectedConversion: any,
): void {
// Patch happy-dom event to make standard properties enumerable and defined
const standardProps = [
"bubbles",
"cancelable",
"composed",
"currentTarget",
"defaultPrevented",
"eventPhase",
"isTrusted",
"target",
"type",
"srcElement",
"returnValue",
"altKey",
"metaKey",
"ctrlKey",
"shiftKey",
"elapsedTime",
"propertyName",
"pseudoElement",
];
for (const prop of standardProps) {
if (prop in givenEvent) {
try {
Object.defineProperty(givenEvent, prop, {
enumerable: true,
value: (givenEvent as any)[prop],
writable: true,
configurable: true,
});
} catch {
// ignore
}
}
}
// timeStamp is special
try {
Object.defineProperty(givenEvent, "timeStamp", {
enumerable: true,
value: givenEvent.timeStamp || Date.now(),
writable: true,
configurable: true,
});
} catch {
// ignore
}
// Patch undefined properties that are expected to be 0 or null
const defaults: any = {
offsetX: 0,
offsetY: 0,
layerX: 0,
layerY: 0,
pageX: 0,
pageY: 0,
x: 0,
y: 0,
screenX: 0,
screenY: 0,
movementX: 0,
movementY: 0,
detail: 0,
which: 0,
relatedTarget: null,
};
for (const [key, value] of Object.entries(defaults)) {
if ((givenEvent as any)[key] === undefined && key in givenEvent) {
try {
Object.defineProperty(givenEvent, key, {
enumerable: true,
value: value,
writable: true,
configurable: true,
});
} catch {
// ignore
}
}
}
const actualSerializedEvent = convert(
// @ts-ignore
givenEvent,
5,
);
if (!actualSerializedEvent) {
expect(actualSerializedEvent).toEqual(expectedConversion);
return;
}
// too hard to compare
// @ts-ignore
expect(typeof actualSerializedEvent.timeStamp).toBe("number");
// Remove nulls from expectedConversionDefaults because convert() strips nulls
const comparisonDefaults = {
bubbles: false,
cancelable: false,
composed: false,
defaultPrevented: false,
eventPhase: 0,
};
const expected = lodash.merge(
// @ts-ignore
{ timeStamp: actualSerializedEvent.timeStamp, type: givenEvent.type },
comparisonDefaults,
expectedConversion,
);
// Remove keys from expected that are null or undefined, because convert() strips them
for (const key in expected) {
if (expected[key] === null || expected[key] === undefined) {
delete expected[key];
}
}
// Use toMatchObject to allow extra properties in actual (like layerX, detail, etc.)
expect(actualSerializedEvent).toMatchObject(expected);
// verify result is JSON serializable
JSON.stringify(actualSerializedEvent);
}
================================================
FILE: src/js/packages/event-to-object/tests/tooling/mock.ts
================================================
export const mockBoundingRect = {
left: 0,
top: 0,
right: 0,
bottom: 0,
x: 0,
y: 0,
height: 0,
width: 0,
};
export const mockElement = {
tagName: null,
getBoundingClientRect: () => mockBoundingRect,
};
export const mockGamepad = {
id: "test",
index: 0,
connected: true,
mapping: "standard",
axes: [],
buttons: [
{
pressed: false,
touched: false,
value: 0,
},
],
};
export const mockTouch = {
identifier: 0,
pageX: 0,
pageY: 0,
screenX: 0,
screenY: 0,
clientX: 0,
clientY: 0,
force: 0,
radiusX: 0,
radiusY: 0,
rotationAngle: 0,
target: mockElement,
};
export const mockTouchObject = {
...mockTouch,
target: {},
};
================================================
FILE: src/js/packages/event-to-object/tests/tooling/setup.js
================================================
import { Window } from "happy-dom";
import { beforeAll, beforeEach } from "bun:test";
export const window = new Window();
export function setup() {
global.window = window;
global.document = window.document;
global.navigator = window.navigator;
global.getComputedStyle = window.getComputedStyle;
global.requestAnimationFrame = null;
global.CSSStyleSheet = window.CSSStyleSheet;
global.CSSStyleDeclaration = window.CSSStyleDeclaration;
global.Window = window.constructor;
global.Document = window.document.constructor;
global.Node = window.Node;
global.Element = window.Element;
global.HTMLElement = window.HTMLElement;
}
export function reset() {
window.document.title = "";
window.document.head.innerHTML = "";
window.document.body.innerHTML = "";
window.getSelection().removeAllRanges();
}
beforeAll(setup);
beforeEach(reset);
================================================
FILE: src/js/packages/event-to-object/tsconfig.json
================================================
{
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"composite": true,
"noEmit": false
},
"extends": "../../tsconfig.json",
"include": ["src"]
}
================================================
FILE: src/js/packages/event-to-object/vitest.config.ts
================================================
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
environment: "happy-dom",
},
});
================================================
FILE: src/js/tsconfig.json
================================================
{
"compilerOptions": {
"allowJs": true,
"allowSyntheticDefaultImports": true,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"jsx": "react-jsx",
"jsxImportSource": "preact",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "Preserve",
"moduleDetection": "force",
"moduleResolution": "bundler",
"noEmit": true,
"noUnusedLocals": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"target": "ESNext",
"verbatimModuleSyntax": true
}
}
================================================
FILE: src/reactpy/__init__.py
================================================
from reactpy import config, logging, reactjs, types, web, widgets
from reactpy._html import h, html
from reactpy.core import hooks
from reactpy.core.component import component
from reactpy.core.events import event
from reactpy.core.hooks import (
create_context,
use_async_effect,
use_callback,
use_connection,
use_context,
use_debug_value,
use_effect,
use_location,
use_memo,
use_reducer,
use_ref,
use_scope,
use_state,
)
from reactpy.core.vdom import Vdom
from reactpy.executors.pyscript.components import pyscript_component
from reactpy.utils import Ref, reactpy_to_string, string_to_reactpy
__author__ = "The Reactive Python Team"
__version__ = "2.0.0b10"
__all__ = [
"Ref",
"Vdom",
"component",
"config",
"create_context",
"event",
"h",
"hooks",
"html",
"logging",
"pyscript_component",
"reactjs",
"reactpy_to_string",
"string_to_reactpy",
"types",
"use_async_effect",
"use_callback",
"use_connection",
"use_context",
"use_debug_value",
"use_effect",
"use_location",
"use_memo",
"use_reducer",
"use_ref",
"use_scope",
"use_state",
"web",
"widgets",
]
================================================
FILE: src/reactpy/_console/__init__.py
================================================
================================================
FILE: src/reactpy/_console/ast_utils.py
================================================
# pyright: reportAttributeAccessIssue=false
from __future__ import annotations
import ast
from collections.abc import Iterator, Sequence
from dataclasses import dataclass
from pathlib import Path
from textwrap import indent
from tokenize import COMMENT as COMMENT_TOKEN
from tokenize import generate_tokens
from typing import Any
import click
from reactpy import html
def rewrite_changed_nodes(
file: Path,
source: str,
tree: ast.AST,
changed: list[ChangedNode],
) -> str:
ast.fix_missing_locations(tree)
lines = source.split("\n")
# find closest parent nodes that should be re-written
nodes_to_unparse: list[ast.AST] = []
for change in changed:
node_lineage = [change.node, *change.parents]
for i in range(len(node_lineage) - 1):
current_node, next_node = node_lineage[i : i + 2]
if (
not hasattr(next_node, "lineno")
or next_node.lineno < change.node.lineno
or isinstance(next_node, (ast.ClassDef, ast.FunctionDef))
):
nodes_to_unparse.append(current_node)
break
else: # nocov
msg = "Failed to change code"
raise RuntimeError(msg)
# check if an nodes to rewrite contain each other, pick outermost nodes
current_outermost_node, *sorted_nodes_to_unparse = sorted(
nodes_to_unparse, key=lambda n: n.lineno
)
outermost_nodes_to_unparse = [current_outermost_node]
for node in sorted_nodes_to_unparse:
if (
not current_outermost_node.end_lineno
or node.lineno > current_outermost_node.end_lineno
):
current_outermost_node = node
outermost_nodes_to_unparse.append(node)
moved_comment_lines_from_end: list[int] = []
# now actually rewrite these nodes (in reverse to avoid changes earlier in file)
for node in reversed(outermost_nodes_to_unparse):
# make a best effort to preserve any comments that we're going to overwrite
comments = _find_comments(lines[node.lineno - 1 : node.end_lineno])
# there may be some content just before and after the content we're re-writing
before_replacement = lines[node.lineno - 1][: node.col_offset].lstrip()
after_replacement = (
lines[node.end_lineno - 1][node.end_col_offset :].strip()
if node.end_lineno is not None and node.end_col_offset is not None
else ""
)
replacement = indent(
before_replacement
+ "\n".join([*comments, ast.unparse(node)])
+ after_replacement,
" " * (node.col_offset - len(before_replacement)),
)
lines[node.lineno - 1 : node.end_lineno or node.lineno] = [replacement]
if comments:
moved_comment_lines_from_end.append(len(lines) - node.lineno)
for lineno_from_end in sorted(set(moved_comment_lines_from_end)):
click.echo(f"Moved comments to {file}:{len(lines) - lineno_from_end}")
return "\n".join(lines)
@dataclass
class ChangedNode:
node: ast.AST
parents: Sequence[ast.AST]
def find_element_constructor_usages(
tree: ast.AST, add_props: bool = False
) -> Iterator[ElementConstructorInfo]:
changed: list[Sequence[ast.AST]] = []
for parents, node in _walk_with_parent(tree):
if not (isinstance(node, ast.Call)):
continue
func = node.func
if isinstance(func, ast.Attribute) and (
(isinstance(func.value, ast.Name) and func.value.id == "html")
or (isinstance(func.value, ast.Attribute) and func.value.attr == "html")
):
name = func.attr
elif isinstance(func, ast.Name):
name = func.id
else:
continue
maybe_attr_dict_node: Any | None = None
if name == "vdom":
if len(node.args) == 0:
continue
elif len(node.args) == 1:
maybe_attr_dict_node = ast.Dict(keys=[], values=[])
if add_props:
node.args.append(maybe_attr_dict_node)
else:
continue
elif isinstance(node.args[1], (ast.Constant, ast.JoinedStr)):
maybe_attr_dict_node = ast.Dict(keys=[], values=[])
if add_props:
node.args.insert(1, maybe_attr_dict_node)
else:
continue
elif len(node.args) >= 2: # noqa: PLR2004
maybe_attr_dict_node = node.args[1]
elif hasattr(html, name):
if len(node.args) == 0:
maybe_attr_dict_node = ast.Dict(keys=[], values=[])
if add_props:
node.args.append(maybe_attr_dict_node)
else:
continue
elif isinstance(node.args[0], (ast.Constant, ast.JoinedStr)):
maybe_attr_dict_node = ast.Dict(keys=[], values=[])
if add_props:
node.args.insert(0, maybe_attr_dict_node)
else:
continue
else:
maybe_attr_dict_node = node.args[0]
if not maybe_attr_dict_node:
continue
if isinstance(maybe_attr_dict_node, ast.Dict) or (
isinstance(maybe_attr_dict_node, ast.Call)
and isinstance(maybe_attr_dict_node.func, ast.Name)
and maybe_attr_dict_node.func.id == "dict"
and isinstance(maybe_attr_dict_node.func.ctx, ast.Load)
):
yield ElementConstructorInfo(node, maybe_attr_dict_node, parents)
return changed
@dataclass
class ElementConstructorInfo:
call: ast.Call
props: ast.Dict | ast.Call
parents: Sequence[ast.AST]
def _find_comments(lines: list[str]) -> list[str]:
iter_lines = iter(lines)
return [
token
for token_type, token, _, _, _ in generate_tokens(lambda: next(iter_lines))
if token_type == COMMENT_TOKEN
]
def _walk_with_parent(
node: ast.AST, parents: tuple[ast.AST, ...] = ()
) -> Iterator[tuple[tuple[ast.AST, ...], ast.AST]]:
parents = (node, *parents)
for child in ast.iter_child_nodes(node):
yield parents, child
yield from _walk_with_parent(child, parents)
================================================
FILE: src/reactpy/_console/cli.py
================================================
"""Entry point for the ReactPy CLI."""
import click
import reactpy
from reactpy._console.rewrite_props import rewrite_props
@click.group()
@click.version_option(version=reactpy.__version__, prog_name=reactpy.__name__)
def entry_point() -> None:
pass
entry_point.add_command(rewrite_props)
if __name__ == "__main__":
entry_point()
================================================
FILE: src/reactpy/_console/rewrite_keys.py
================================================
from __future__ import annotations
import ast
from pathlib import Path
import click
from reactpy import html
from reactpy._console.ast_utils import (
ChangedNode,
find_element_constructor_usages,
rewrite_changed_nodes,
)
@click.command()
@click.argument("paths", nargs=-1, type=click.Path(exists=True))
def rewrite_keys(paths: list[str]) -> None:
"""Rewrite files under the given paths using the new html element API.
The old API required users to pass a dictionary of attributes to html element
constructor functions. For example:
>>> html.div({"className": "x"}, "y")
{"tagName": "div", "attributes": {"className": "x"}, "children": ["y"]}
The latest API though allows for attributes to be passed as snake_cased keyword
arguments instead. The above example would be rewritten as:
>>> html.div("y", class_name="x")
{"tagName": "div", "attributes": {"class_name": "x"}, "children": ["y"]}
All snake_case attributes are converted to camelCase by the client where necessary.
----- Notes -----
While this command does it's best to preserve as much of the original code as
possible, there are inevitably some limitations in doing this. As a result, we
recommend running your code formatter like Black against your code after executing
this command.
Additionally, We are unable to preserve the location of comments that lie within any
rewritten code. This command will place the comments in the code it plans to rewrite
just above its changes. As such it requires manual intervention to put those
comments back in their original location.
"""
for p in map(Path, paths):
for f in [p] if p.is_file() else p.rglob("*.py"):
result = generate_rewrite(file=f, source=f.read_text(encoding="utf-8"))
if result is not None:
f.write_text(result)
def generate_rewrite(file: Path, source: str) -> str | None:
tree = ast.parse(source)
changed = find_nodes_to_change(tree)
if not changed:
log_could_not_rewrite(file, tree)
return None
new = rewrite_changed_nodes(file, source, tree, changed)
log_could_not_rewrite(file, ast.parse(new))
return new
def find_nodes_to_change(tree: ast.AST) -> list[ChangedNode]:
changed: list[ChangedNode] = []
for el_info in find_element_constructor_usages(tree, add_props=True):
for kw in list(el_info.call.keywords):
if kw.arg == "key":
break
else:
continue
if isinstance(el_info.props, ast.Dict):
el_info.props.keys.append(ast.Constant("key"))
el_info.props.values.append(kw.value)
else:
el_info.props.keywords.append(ast.keyword(arg="key", value=kw.value))
el_info.call.keywords.remove(kw)
changed.append(ChangedNode(el_info.call, el_info.parents))
return changed
def log_could_not_rewrite(file: Path, tree: ast.AST) -> None:
for node in ast.walk(tree):
if not (isinstance(node, ast.Call) and node.keywords):
continue
func = node.func
if isinstance(func, ast.Attribute):
name = func.attr
elif isinstance(func, ast.Name):
name = func.id
else:
continue
if name == "vdom" or (
hasattr(html, name) and any(kw.arg == "key" for kw in node.keywords)
):
click.echo(f"Unable to rewrite usage at {file}:{node.lineno}")
================================================
FILE: src/reactpy/_console/rewrite_props.py
================================================
from __future__ import annotations
import ast
from collections.abc import Callable
from copy import copy
from keyword import kwlist
from pathlib import Path
import click
from reactpy._console.ast_utils import (
ChangedNode,
find_element_constructor_usages,
rewrite_changed_nodes,
)
@click.command()
@click.argument("paths", nargs=-1, type=click.Path(exists=True))
def rewrite_props(paths: list[str]) -> None:
"""Rewrite snake_case props to camelCase within ."""
for p in map(Path, paths):
# Process each file or recursively process each Python file in directories
for f in [p] if p.is_file() else p.rglob("*.py"):
result = generate_rewrite(file=f, source=f.read_text(encoding="utf-8"))
if result is not None:
f.write_text(result)
def generate_rewrite(file: Path, source: str) -> str | None:
"""Generate the rewritten source code if changes are detected"""
tree = ast.parse(source) # Parse the source code into an AST
changed = find_nodes_to_change(tree) # Find nodes that need to be changed
if not changed:
return None # Return None if no changes are needed
new = rewrite_changed_nodes(
file, source, tree, changed
) # Rewrite the changed nodes
return new
def find_nodes_to_change(tree: ast.AST) -> list[ChangedNode]:
"""Find nodes in the AST that need to be changed"""
changed: list[ChangedNode] = []
for el_info in find_element_constructor_usages(tree):
# Check if the props need to be rewritten
if _rewrite_props(el_info.props, _construct_prop_item):
# Add the changed node to the list
changed.append(ChangedNode(el_info.call, el_info.parents))
return changed
def conv_attr_name(name: str) -> str:
"""Convert snake_case attribute name to camelCase"""
# Return early if the value is a Python keyword
if name in kwlist:
return name
# Return early if the value is not snake_case
if "_" not in name:
return name
# Split the string by underscores
components = name.split("_")
# Capitalize the first letter of each component except the first one
# and join them together
return components[0] + "".join(x.title() for x in components[1:])
def _construct_prop_item(key: str, value: ast.expr) -> tuple[str, ast.expr]:
"""Construct a new prop item with the converted key and possibly modified value"""
if key == "style" and isinstance(value, (ast.Dict, ast.Call)):
# Create a copy of the value to avoid modifying the original
new_value = copy(value)
if _rewrite_props(
new_value,
lambda k, v: (
(k, v)
# Avoid infinite recursion
if k == "style"
else _construct_prop_item(k, v)
),
):
# Update the value if changes were made
value = new_value
else:
# Convert the key to camelCase
key = conv_attr_name(key)
return key, value
def _rewrite_props(
props_node: ast.Dict | ast.Call,
constructor: Callable[[str, ast.expr], tuple[str, ast.expr]],
) -> bool:
"""Rewrite the props in the given AST node using the provided constructor"""
did_change = False
if isinstance(props_node, ast.Dict):
keys: list[ast.expr | None] = []
values: list[ast.expr] = []
# Iterate over the keys and values in the dictionary
for k, v in zip(props_node.keys, props_node.values, strict=False):
if isinstance(k, ast.Constant) and isinstance(k.value, str):
# Construct the new key and value
k_value, new_v = constructor(k.value, v)
if k_value != k.value or new_v is not v:
did_change = True
k = ast.Constant(value=k_value)
v = new_v
keys.append(k)
values.append(v)
if not did_change:
return False # Return False if no changes were made
props_node.keys = keys
props_node.values = values
else:
did_change = False
keywords: list[ast.keyword] = []
# Iterate over the keywords in the call
for kw in props_node.keywords:
if kw.arg is not None:
# Construct the new keyword argument and value
kw_arg, kw_value = constructor(kw.arg, kw.value)
if kw_arg != kw.arg or kw_value is not kw.value:
did_change = True
kw = ast.keyword(arg=kw_arg, value=kw_value)
keywords.append(kw)
if not did_change:
return False # Return False if no changes were made
props_node.keywords = keywords
return True
================================================
FILE: src/reactpy/_html.py
================================================
from __future__ import annotations
from collections.abc import Sequence
from typing import ClassVar, overload
from reactpy.core.vdom import Vdom
from reactpy.types import (
EventHandlerDict,
VdomAttributes,
VdomChild,
VdomChildren,
VdomConstructor,
VdomDict,
)
__all__ = ["h", "html"]
NO_CHILDREN_ALLOWED_HTML_BODY = {
"area",
"base",
"br",
"col",
"command",
"embed",
"hr",
"img",
"input",
"iframe",
"keygen",
"link",
"meta",
"param",
"portal",
"source",
"track",
"wbr",
}
NO_CHILDREN_ALLOWED_SVG = {
"animate",
"animateMotion",
"animateTransform",
"circle",
"desc",
"discard",
"ellipse",
"feBlend",
"feColorMatrix",
"feComponentTransfer",
"feComposite",
"feConvolveMatrix",
"feDiffuseLighting",
"feDisplacementMap",
"feDistantLight",
"feDropShadow",
"feFlood",
"feFuncA",
"feFuncB",
"feFuncG",
"feFuncR",
"feGaussianBlur",
"feImage",
"feMerge",
"feMergeNode",
"feMorphology",
"feOffset",
"fePointLight",
"feSpecularLighting",
"feSpotLight",
"feTile",
"feTurbulence",
"filter",
"foreignObject",
"hatch",
"hatchpath",
"image",
"line",
"linearGradient",
"metadata",
"mpath",
"path",
"polygon",
"polyline",
"radialGradient",
"rect",
"script",
"set",
"stop",
"style",
"text",
"textPath",
"title",
"tspan",
"use",
"view",
}
def _fragment(
attributes: VdomAttributes,
children: Sequence[VdomChild],
event_handlers: EventHandlerDict,
) -> VdomDict:
"""An HTML fragment - this element will not appear in the DOM"""
if any(k != "key" for k in attributes) or event_handlers:
msg = "Fragments cannot have attributes besides 'key'"
raise TypeError(msg)
model = VdomDict(tagName="")
if children:
model["children"] = children
if attributes:
model["attributes"] = attributes
return model
def _script(
attributes: VdomAttributes,
children: Sequence[VdomChild],
event_handlers: EventHandlerDict,
) -> VdomDict:
"""Create a new `"
)
def pyscript_setup_html(
extra_py: Sequence[str],
extra_js: dict[str, Any] | str,
config: dict[str, Any] | str,
) -> str:
"""Renders the PyScript setup code."""
hide_pyscript_debugger = f''
pyscript_config = extend_pyscript_config(extra_py, extra_js, config)
return (
f''
f"{'' if REACTPY_DEBUG.current else hide_pyscript_debugger}"
f'"
f""
)
def extend_pyscript_config(
extra_py: Sequence[str],
extra_js: dict[str, str] | str,
config: dict[str, Any] | str,
) -> str:
# Extends ReactPy's default PyScript config with user provided values.
pyscript_config: dict[str, Any] = {
"packages": [reactpy_version_string(), "jsonpointer==3.*", "ssl"],
"js_modules": {
"main": {
f"{REACTPY_PATH_PREFIX.current}static/morphdom/morphdom-esm.js": "morphdom"
}
},
}
pyscript_config["packages"].extend(extra_py)
# FIXME: https://github.com/pyscript/pyscript/issues/2282
if any(pkg.endswith(".whl") for pkg in pyscript_config["packages"]): # nocov
pyscript_config["packages_cache"] = "never"
# Extend the JavaScript dependency list
if extra_js and isinstance(extra_js, str):
pyscript_config["js_modules"]["main"].update(json.loads(extra_js))
elif extra_js and isinstance(extra_js, dict):
pyscript_config["js_modules"]["main"].update(extra_js)
# Update other config attributes
if config and isinstance(config, str):
pyscript_config.update(json.loads(config))
elif config and isinstance(config, dict):
pyscript_config.update(config)
return json.dumps(pyscript_config)
def reactpy_version_string() -> str: # nocov
from reactpy.testing.common import GITHUB_ACTIONS
local_version = reactpy.__version__
# Get a list of all versions via `pip index versions`
result = get_reactpy_versions()
# Check if the command failed
if not result:
_logger.warning(
"Failed to verify what versions of ReactPy exist on PyPi. "
"PyScript functionality may not work as expected.",
)
return f"reactpy=={local_version}"
# Have `pip` tell us what versions are available
known_versions: list[str] = result.get("versions", [])
latest_version: str = result.get("latest", "")
# Return early if the version is available on PyPi and we're not in a CI environment
if local_version in known_versions and not GITHUB_ACTIONS:
return f"reactpy=={local_version}"
# We are now determining an alternative method of installing ReactPy for PyScript
if not GITHUB_ACTIONS:
_logger.warning(
"Your ReactPy version isn't available on PyPi. "
"Attempting to find an alternative installation method for PyScript...",
)
# Build a local wheel for ReactPy, if needed
dist_dir = Path(reactpy.__file__).parent.parent.parent / "dist"
wheel_glob = glob(str(dist_dir / f"reactpy-{local_version}-*.whl"))
if not wheel_glob:
_logger.warning("Attempting to build a local wheel for ReactPy...")
subprocess.run(
["hatch", "build", "-t", "wheel"],
capture_output=True,
text=True,
check=False,
cwd=Path(reactpy.__file__).parent.parent.parent,
)
wheel_glob = glob(str(dist_dir / f"reactpy-{local_version}-*.whl"))
# Move the local wheel to the web modules directory, if it exists
if wheel_glob:
wheel_file = Path(wheel_glob[0])
new_path = REACTPY_WEB_MODULES_DIR.current / wheel_file.name
if not new_path.exists():
_logger.warning(
"PyScript will utilize local wheel '%s'.",
wheel_file.name,
)
shutil.copy(wheel_file, new_path)
return f"{REACTPY_PATH_PREFIX.current}modules/{wheel_file.name}"
# Building a local wheel failed, try our best to give the user any version.
if latest_version:
_logger.warning(
"Failed to build a local wheel for ReactPy, likely due to missing build dependencies. "
"PyScript will default to using the latest ReactPy version on PyPi."
)
return f"reactpy=={latest_version}"
_logger.error(
"Failed to build a local wheel for ReactPy, and could not determine the latest version on PyPi. "
"PyScript functionality may not work as expected.",
)
return f"reactpy=={local_version}"
@functools.cache
def get_reactpy_versions() -> dict[Any, Any]:
"""Fetches the available versions of a package from PyPI."""
try:
try:
response = request.urlopen("https://pypi.org/pypi/reactpy/json", timeout=5)
except Exception:
response = request.urlopen("http://pypi.org/pypi/reactpy/json", timeout=5)
if response.status == 200: # noqa: PLR2004
data = json.load(response)
versions = list(data.get("releases", {}).keys())
latest = data.get("info", {}).get("version", "")
if versions and latest:
return {"versions": versions, "latest": latest}
except Exception:
_logger.exception("Error fetching ReactPy package versions from PyPI!")
return {}
@functools.cache
def cached_file_read(file_path: str, minifiy: bool = True) -> str:
content = Path(file_path).read_text(encoding="utf-8").strip()
return minify_python(content) if minifiy else content
================================================
FILE: src/reactpy/executors/utils.py
================================================
from __future__ import annotations
import logging
from collections.abc import Iterable
from typing import Any
from reactpy._option import Option
from reactpy.config import (
REACTPY_PATH_PREFIX,
REACTPY_RECONNECT_BACKOFF_MULTIPLIER,
REACTPY_RECONNECT_INTERVAL,
REACTPY_RECONNECT_MAX_INTERVAL,
REACTPY_RECONNECT_MAX_RETRIES,
)
from reactpy.types import ReactPyConfig, VdomDict
from reactpy.utils import import_dotted_path, reactpy_to_string
logger = logging.getLogger(__name__)
def import_components(dotted_paths: Iterable[str]) -> dict[str, Any]:
"""Imports a list of dotted paths and returns the callables."""
return {
dotted_path: import_dotted_path(dotted_path) for dotted_path in dotted_paths
}
def check_path(url_path: str) -> str: # nocov
"""Check that a path is valid URL path."""
if not url_path:
return "URL path must not be empty."
if not isinstance(url_path, str):
return "URL path is must be a string."
if not url_path.startswith("/"):
return "URL path must start with a forward slash."
if not url_path.endswith("/"):
return "URL path must end with a forward slash."
return ""
def vdom_head_to_html(head: VdomDict) -> str:
if isinstance(head, dict) and head.get("tagName") == "head":
return reactpy_to_string(head)
raise ValueError("Head element must be constructed with `html.head`.")
def process_settings(settings: ReactPyConfig) -> None:
"""Process the settings and return the final configuration."""
from reactpy import config
for setting in settings:
config_name = f"REACTPY_{setting.upper()}"
config_object: Option[Any] | None = getattr(config, config_name, None)
if config_object:
config_object.set_current(settings[setting]) # type: ignore
else:
raise ValueError(f'Unknown ReactPy setting "{setting}".')
def server_side_component_html(
element_id: str, class_: str, component_path: str
) -> str:
return (
f''
""
'"
)
def default_import_map() -> str:
path_prefix = REACTPY_PATH_PREFIX.current.strip("/")
return f"""{{
"imports": {{
"react": "/{path_prefix}/static/preact.js",
"react-dom": "/{path_prefix}/static/preact-dom.js",
"react-dom/client": "/{path_prefix}/static/preact-dom.js",
"react/jsx-runtime": "/{path_prefix}/static/preact-jsx-runtime.js"
}}
}}""".replace("\n", "").replace(" ", "")
================================================
FILE: src/reactpy/logging.py
================================================
import logging
import sys
from logging.config import dictConfig
from reactpy.config import REACTPY_DEBUG
dictConfig(
{
"version": 1,
"disable_existing_loggers": False,
"loggers": {
"reactpy": {"handlers": ["console"]},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "generic",
"stream": sys.stdout,
}
},
"formatters": {"generic": {"datefmt": r"%Y-%m-%dT%H:%M:%S%z"}},
}
)
ROOT_LOGGER = logging.getLogger("reactpy")
"""ReactPy's root logger instance"""
@REACTPY_DEBUG.subscribe
def _set_debug_level(debug: bool) -> None:
if debug:
ROOT_LOGGER.setLevel("DEBUG")
ROOT_LOGGER.debug("ReactPy is in debug mode")
else:
ROOT_LOGGER.setLevel("INFO")
================================================
FILE: src/reactpy/py.typed
================================================
# Marker file for PEP 561
================================================
FILE: src/reactpy/reactjs/__init__.py
================================================
from __future__ import annotations
import hashlib
from pathlib import Path
from typing import Any, overload
from reactpy.reactjs.module import (
file_to_module,
import_reactjs,
module_to_vdom,
string_to_module,
url_to_module,
)
from reactpy.reactjs.types import (
NAME_SOURCE,
URL_SOURCE,
)
from reactpy.types import JavaScriptModule, VdomConstructor
__all__ = [
"NAME_SOURCE",
"URL_SOURCE",
"component_from_file",
"component_from_npm",
"component_from_string",
"component_from_url",
"import_reactjs",
]
_URL_JS_MODULE_CACHE: dict[str, JavaScriptModule] = {}
_FILE_JS_MODULE_CACHE: dict[str, JavaScriptModule] = {}
_STRING_JS_MODULE_CACHE: dict[str, JavaScriptModule] = {}
@overload
def component_from_url(
url: str,
import_names: str,
resolve_imports: bool = ...,
resolve_imports_depth: int = ...,
fallback: Any | None = ...,
unmount_before_update: bool = ...,
allow_children: bool = ...,
) -> VdomConstructor: ...
@overload
def component_from_url(
url: str,
import_names: list[str] | tuple[str, ...],
resolve_imports: bool = ...,
resolve_imports_depth: int = ...,
fallback: Any | None = ...,
unmount_before_update: bool = ...,
allow_children: bool = ...,
) -> list[VdomConstructor]: ...
def component_from_url(
url: str,
import_names: str | list[str] | tuple[str, ...],
resolve_imports: bool = False,
resolve_imports_depth: int = 5,
fallback: Any | None = None,
unmount_before_update: bool = False,
allow_children: bool = True,
) -> VdomConstructor | list[VdomConstructor]:
"""Import a component from a URL.
Parameters:
url:
The URL to import the component from.
import_names:
One or more component names to import. If given as a string, a single component
will be returned. If a list is given, then a list of components will be
returned.
resolve_imports:
Whether to try and find all the named imports of this module.
resolve_imports_depth:
How deeply to search for those imports.
fallback:
What to temporarily display while the module is being loaded.
unmount_before_update:
Cause the component to be unmounted before each update. This option should
only be used if the imported package fails to re-render when props change.
Using this option has negative performance consequences since all DOM
elements must be changed on each render. See :issue:`461` for more info.
allow_children:
Whether or not these components can have children.
"""
key = f"{url}{resolve_imports}{resolve_imports_depth}{unmount_before_update}"
if key in _URL_JS_MODULE_CACHE:
module = _URL_JS_MODULE_CACHE[key]
else:
module = url_to_module(
url,
fallback=fallback,
resolve_imports=resolve_imports,
resolve_imports_depth=resolve_imports_depth,
unmount_before_update=unmount_before_update,
)
_URL_JS_MODULE_CACHE[key] = module
return module_to_vdom(module, import_names, fallback, allow_children)
@overload
def component_from_npm(
package: str,
import_names: str,
resolve_imports: bool = ...,
resolve_imports_depth: int = ...,
version: str = "latest",
cdn: str = "https://esm.sh/v135",
fallback: Any | None = ...,
unmount_before_update: bool = ...,
allow_children: bool = ...,
) -> VdomConstructor: ...
@overload
def component_from_npm(
package: str,
import_names: list[str] | tuple[str, ...],
resolve_imports: bool = ...,
resolve_imports_depth: int = ...,
version: str = "latest",
cdn: str = "https://esm.sh/v135",
fallback: Any | None = ...,
unmount_before_update: bool = ...,
allow_children: bool = ...,
) -> list[VdomConstructor]: ...
def component_from_npm(
package: str,
import_names: str | list[str] | tuple[str, ...],
resolve_imports: bool = False,
resolve_imports_depth: int = 5,
version: str = "latest",
cdn: str = "https://esm.sh/v135",
fallback: Any | None = None,
unmount_before_update: bool = False,
allow_children: bool = True,
) -> VdomConstructor | list[VdomConstructor]:
"""Import a component from an NPM package.
Is is mandatory to load `reactpy.reactjs.import_reactjs()` on your page before using this
function. It is recommended to put this within your HTML content.
Parameters:
package:
The name of the NPM package.
import_names:
One or more component names to import. If given as a string, a single component
will be returned. If a list is given, then a list of components will be
returned.
resolve_imports:
Whether to try and find all the named imports of this module.
resolve_imports_depth:
How deeply to search for those imports.
version:
The version of the package to use. Defaults to "latest".
cdn:
The CDN to use. Defaults to "https://esm.sh".
fallback:
What to temporarily display while the module is being loaded.
unmount_before_update:
Cause the component to be unmounted before each update. This option should
only be used if the imported package fails to re-render when props change.
Using this option has negative performance consequences since all DOM
elements must be changed on each render. See :issue:`461` for more info.
allow_children:
Whether or not these components can have children.
"""
url = f"{cdn}/{package}@{version}"
if "esm.sh" in cdn:
url += "&" if "?" in url else "?"
url += "external=react,react-dom,react/jsx-runtime&bundle&target=es2020"
return component_from_url(
url,
import_names,
fallback=fallback,
resolve_imports=resolve_imports,
resolve_imports_depth=resolve_imports_depth,
unmount_before_update=unmount_before_update,
allow_children=allow_children,
)
@overload
def component_from_file(
file: str | Path,
import_names: str,
resolve_imports: bool = ...,
resolve_imports_depth: int = ...,
name: str = "",
fallback: Any | None = ...,
unmount_before_update: bool = ...,
symlink: bool = ...,
allow_children: bool = ...,
) -> VdomConstructor: ...
@overload
def component_from_file(
file: str | Path,
import_names: list[str] | tuple[str, ...],
resolve_imports: bool = ...,
resolve_imports_depth: int = ...,
name: str = "",
fallback: Any | None = ...,
unmount_before_update: bool = ...,
symlink: bool = ...,
allow_children: bool = ...,
) -> list[VdomConstructor]: ...
def component_from_file(
file: str | Path,
import_names: str | list[str] | tuple[str, ...],
resolve_imports: bool = False,
resolve_imports_depth: int = 5,
name: str = "",
fallback: Any | None = None,
unmount_before_update: bool = False,
symlink: bool = False,
allow_children: bool = True,
) -> VdomConstructor | list[VdomConstructor]:
"""Import a component from a file.
Parameters:
file:
The file from which the content of the web module will be created.
import_names:
One or more component names to import. If given as a string, a single component
will be returned. If a list is given, then a list of components will be
returned.
resolve_imports:
Whether to try and find all the named imports of this module.
resolve_imports_depth:
How deeply to search for those imports.
name:
The human-readable name of the ReactJS package
fallback:
What to temporarily display while the module is being loaded.
unmount_before_update:
Cause the component to be unmounted before each update. This option should
only be used if the imported package fails to re-render when props change.
Using this option has negative performance consequences since all DOM
elements must be changed on each render. See :issue:`461` for more info.
symlink:
Whether the web module should be saved as a symlink to the given ``file``.
allow_children:
Whether or not these components can have children.
"""
name = name or hashlib.sha256(str(file).encode()).hexdigest()[:10]
key = f"{name}{resolve_imports}{resolve_imports_depth}{unmount_before_update}"
if key in _FILE_JS_MODULE_CACHE:
module = _FILE_JS_MODULE_CACHE[key]
else:
module = file_to_module(
name,
file,
fallback=fallback,
resolve_imports=resolve_imports,
resolve_imports_depth=resolve_imports_depth,
unmount_before_update=unmount_before_update,
symlink=symlink,
)
_FILE_JS_MODULE_CACHE[key] = module
return module_to_vdom(module, import_names, fallback, allow_children)
@overload
def component_from_string(
content: str,
import_names: str,
resolve_imports: bool = ...,
resolve_imports_depth: int = ...,
name: str = "",
fallback: Any | None = ...,
unmount_before_update: bool = ...,
allow_children: bool = ...,
) -> VdomConstructor: ...
@overload
def component_from_string(
content: str,
import_names: list[str] | tuple[str, ...],
resolve_imports: bool = ...,
resolve_imports_depth: int = ...,
name: str = "",
fallback: Any | None = ...,
unmount_before_update: bool = ...,
allow_children: bool = ...,
) -> list[VdomConstructor]: ...
def component_from_string(
content: str,
import_names: str | list[str] | tuple[str, ...],
resolve_imports: bool = False,
resolve_imports_depth: int = 5,
name: str = "",
fallback: Any | None = None,
unmount_before_update: bool = False,
allow_children: bool = True,
) -> VdomConstructor | list[VdomConstructor]:
"""Import a component from a string.
Parameters:
content:
The contents of the web module
import_names:
One or more component names to import. If given as a string, a single component
will be returned. If a list is given, then a list of components will be
returned.
resolve_imports:
Whether to try and find all the named imports of this module.
resolve_imports_depth:
How deeply to search for those imports.
name:
The human-readable name of the ReactJS package
fallback:
What to temporarily display while the module is being loaded.
unmount_before_update:
Cause the component to be unmounted before each update. This option should
only be used if the imported package fails to re-render when props change.
Using this option has negative performance consequences since all DOM
elements must be changed on each render. See :issue:`461` for more info.
allow_children:
Whether or not these components can have children.
"""
name = name or hashlib.sha256(content.encode()).hexdigest()[:10]
key = f"{name}{resolve_imports}{resolve_imports_depth}{unmount_before_update}"
if key in _STRING_JS_MODULE_CACHE:
module = _STRING_JS_MODULE_CACHE[key]
else:
module = string_to_module(
name,
content,
fallback=fallback,
resolve_imports=resolve_imports,
resolve_imports_depth=resolve_imports_depth,
unmount_before_update=unmount_before_update,
)
_STRING_JS_MODULE_CACHE[key] = module
return module_to_vdom(module, import_names, fallback, allow_children)
================================================
FILE: src/reactpy/reactjs/module.py
================================================
from __future__ import annotations
import logging
from pathlib import Path, PurePosixPath
from typing import Any, Literal
from reactpy.config import REACTPY_DEBUG, REACTPY_WEB_MODULES_DIR
from reactpy.core.vdom import Vdom
from reactpy.reactjs.types import NAME_SOURCE, URL_SOURCE
from reactpy.reactjs.utils import (
are_files_identical,
copy_file,
file_lock,
resolve_names_from_file,
resolve_names_from_url,
)
from reactpy.types import ImportSourceDict, JavaScriptModule, VdomConstructor, VdomDict
logger = logging.getLogger(__name__)
def url_to_module(
url: str,
fallback: Any | None = None,
resolve_imports: bool = True,
resolve_imports_depth: int = 5,
unmount_before_update: bool = False,
) -> JavaScriptModule:
return JavaScriptModule(
source=url,
source_type=URL_SOURCE,
default_fallback=fallback,
file=None,
import_names=(
resolve_names_from_url(url, resolve_imports_depth)
if resolve_imports
else None
),
unmount_before_update=unmount_before_update,
)
def file_to_module(
name: str,
file: str | Path,
fallback: Any | None = None,
resolve_imports: bool = True,
resolve_imports_depth: int = 5,
unmount_before_update: bool = False,
symlink: bool = False,
) -> JavaScriptModule:
name += module_name_suffix(name)
source_file = Path(file).resolve()
target_file = get_module_path(name)
with file_lock(target_file.with_name(f"{target_file.name}.lock")):
if not source_file.exists():
msg = f"Source file does not exist: {source_file}"
raise FileNotFoundError(msg)
if not target_file.exists():
copy_file(target_file, source_file, symlink)
elif not are_files_identical(source_file, target_file):
logger.info(
f"Existing web module {name!r} will "
f"be replaced with {target_file.resolve()}"
)
copy_file(target_file, source_file, symlink)
return JavaScriptModule(
source=name,
source_type=NAME_SOURCE,
default_fallback=fallback,
file=target_file,
import_names=(
resolve_names_from_file(source_file, resolve_imports_depth)
if resolve_imports
else None
),
unmount_before_update=unmount_before_update,
)
def string_to_module(
name: str,
content: str,
fallback: Any | None = None,
resolve_imports: bool = True,
resolve_imports_depth: int = 5,
unmount_before_update: bool = False,
) -> JavaScriptModule:
name += module_name_suffix(name)
target_file = get_module_path(name)
if target_file.exists() and target_file.read_text(encoding="utf-8") != content:
logger.info(
f"Existing web module {name!r} will "
f"be replaced with {target_file.resolve()}"
)
target_file.unlink()
target_file.parent.mkdir(parents=True, exist_ok=True)
target_file.write_text(content)
return JavaScriptModule(
source=name,
source_type=NAME_SOURCE,
default_fallback=fallback,
file=target_file,
import_names=(
resolve_names_from_file(target_file, resolve_imports_depth)
if resolve_imports
else None
),
unmount_before_update=unmount_before_update,
)
def module_to_vdom(
web_module: JavaScriptModule,
import_names: str | list[str] | tuple[str, ...],
fallback: Any | None = None,
allow_children: bool = True,
) -> VdomConstructor | list[VdomConstructor]:
"""Return one or more VDOM constructors from a :class:`JavaScriptModule`
Parameters:
import_names:
One or more names to import. If given as a string, a single component
will be returned. If a list is given, then a list of components will be
returned.
fallback:
What to temporarily display while the module is being loaded.
allow_children:
Whether or not these components can have children.
"""
if isinstance(import_names, str):
if (
web_module.import_names is not None
and import_names.split(".")[0] not in web_module.import_names
):
msg = f"{web_module.source!r} does not contain {import_names!r}"
raise ValueError(msg)
return make_module(web_module, import_names, fallback, allow_children)
else:
if web_module.import_names is not None:
missing = sorted(
{e.split(".")[0] for e in import_names}.difference(
web_module.import_names
)
)
if missing:
msg = f"{web_module.source!r} does not contain {missing!r}"
raise ValueError(msg)
return [
make_module(web_module, name, fallback, allow_children)
for name in import_names
]
def make_module(
web_module: JavaScriptModule,
name: str,
fallback: Any | None,
allow_children: bool,
) -> VdomConstructor:
return Vdom(
name,
allow_children=allow_children,
import_source=ImportSourceDict(
source=web_module.source,
sourceType=web_module.source_type,
fallback=(fallback or web_module.default_fallback),
unmountBeforeUpdate=web_module.unmount_before_update,
),
)
def import_reactjs(
framework: Literal["preact", "react"] | None = None,
version: str | None = None,
use_local: bool = False,
) -> VdomDict:
"""
Return an import map script tag for ReactJS or Preact.
Parameters:
framework:
The framework to use, either "preact" or "react". Defaults to "preact" for
performance reasons. Set this to `react` if you are experiencing compatibility
issues with your component library.
version:
The version of the framework to use. Example values include "18", "10.2.4",
or "latest". If left as `None`, a default version will be used depending on the
selected framework.
use_local:
Whether to use the local framework ReactPy is bundled with (Preact).
Raises:
ValueError:
If both `framework` and `react_url_prefix` are provided, or if
`framework` is not one of "preact" or "react".
Returns:
A VDOM script tag containing the import map.
"""
from reactpy import html
from reactpy.executors.utils import default_import_map
if use_local and (framework or version): # nocov
raise ValueError("use_local cannot be used with framework or version")
framework = framework or "preact"
if framework and framework not in {"preact", "react"}: # nocov
raise ValueError("framework must be 'preact' or 'react'")
# Import map for ReactPy's local framework (re-exported/bundled/minified version of Preact)
if use_local:
return html.script(
{"type": "importmap", "id": "reactpy-importmap"},
default_import_map(),
)
# Import map for ReactJS from esm.sh
if framework == "react":
version = version or "19"
postfix = "?dev" if REACTPY_DEBUG.current else ""
return html.script(
{"type": "importmap", "id": "reactpy-importmap"},
f"""{{
"imports": {{
"react": "https://esm.sh/v135/react@{version}{postfix}",
"react-dom": "https://esm.sh/v135/react-dom@{version}{postfix}",
"react-dom/client": "https://esm.sh/v135/react-dom@{version}/client{postfix}",
"react/jsx-runtime": "https://esm.sh/v135/react@{version}/jsx-runtime{postfix}"
}}
}}""".replace("\n", "").replace(" ", ""),
)
# Import map for Preact from esm.sh
if framework == "preact":
version = version or "10"
postfix = "?dev" if REACTPY_DEBUG.current else ""
return html.script(
{"type": "importmap", "id": "reactpy-importmap"},
f"""{{
"imports": {{
"react": "https://esm.sh/v135/preact@{version}/compat{postfix}",
"react-dom": "https://esm.sh/v135/preact@{version}/compat{postfix}",
"react-dom/client": "https://esm.sh/v135/preact@{version}/compat/client{postfix}",
"react/jsx-runtime": "https://esm.sh/v135/preact@{version}/compat/jsx-runtime{postfix}"
}}
}}""".replace("\n", "").replace(" ", ""),
)
def module_name_suffix(name: str) -> str:
if name.startswith("@"):
name = name[1:]
head, _, tail = name.partition("@") # handle version identifier
_, _, tail = tail.partition("/") # get section after version
return PurePosixPath(tail or head).suffix or ".js"
def get_module_path(name: str) -> Path:
directory = REACTPY_WEB_MODULES_DIR.current
path = directory.joinpath(*name.split("/"))
return path.with_suffix(path.suffix)
================================================
FILE: src/reactpy/reactjs/types.py
================================================
from reactpy.types import SourceType
NAME_SOURCE = SourceType("NAME")
"""A named source - usually a Javascript package name"""
URL_SOURCE = SourceType("URL")
"""A source loaded from a URL, usually a CDN"""
================================================
FILE: src/reactpy/reactjs/utils.py
================================================
import filecmp
import logging
import os
import re
import shutil
import time
from contextlib import contextmanager, suppress
from pathlib import Path
from urllib.parse import urlparse, urlunparse
import requests
logger = logging.getLogger(__name__)
def resolve_names_from_file(
file: Path,
max_depth: int,
is_regex_import: bool = False,
) -> set[str]:
if max_depth == 0:
logger.warning(f"Did not resolve all imports for {file} - max depth reached")
return set()
elif not file.exists():
logger.warning(f"Did not resolve imports for unknown file {file}")
return set()
names, references = resolve_names_from_source(
file.read_text(encoding="utf-8"), exclude_default=is_regex_import
)
for ref in references:
if urlparse(ref).scheme: # is an absolute URL
names.update(
resolve_names_from_url(ref, max_depth - 1, is_regex_import=True)
)
else:
path = file.parent.joinpath(*ref.split("/"))
names.update(
resolve_names_from_file(path, max_depth - 1, is_regex_import=True)
)
return names
def resolve_names_from_url(
url: str,
max_depth: int,
is_regex_import: bool = False,
) -> set[str]:
if max_depth == 0:
logger.warning(f"Did not resolve all imports for {url} - max depth reached")
return set()
try:
text = requests.get(url, timeout=5).text
except requests.exceptions.ConnectionError as error:
reason = "" if error is None else " - {error.errno}"
logger.warning(f"Did not resolve imports for url {url} {reason}")
return set()
names, references = resolve_names_from_source(text, exclude_default=is_regex_import)
for ref in references:
url = normalize_url_path(url, ref)
names.update(resolve_names_from_url(url, max_depth - 1, is_regex_import=True))
return names
def resolve_names_from_source(
content: str, exclude_default: bool
) -> tuple[set[str], set[str]]:
"""Find names exported by the given JavaScript module content to assist with ReactPy import resolution.
Parmeters:
content: The content of the JavaScript module.
Returns:
A tuple where the first item is a set of exported names and the second item is a set of
referenced module paths.
"""
all_names: set[str] = set()
references: set[str] = set()
if _JS_DEFAULT_EXPORT_PATTERN.search(content):
all_names.add("default")
# Exporting functions and classes
all_names.update(_JS_FUNC_OR_CLS_EXPORT_PATTERN.findall(content))
for name in _JS_GENERAL_EXPORT_PATTERN.findall(content):
name = name.rstrip(";").strip()
# Exporting individual features
if name.startswith("let "):
all_names.update(let.split("=", 1)[0] for let in name[4:].split(","))
# Renaming exports and export list
elif name.startswith("{") and name.endswith("}"):
all_names.update(
item.split(" as ", 1)[-1] for item in name.strip("{}").split(",")
)
# Exporting destructured assignments with renaming
elif name.startswith("const "):
all_names.update(
item.split(":", 1)[0]
for item in name[6:].split("=", 1)[0].strip("{}").split(",")
)
# Default exports
elif name.startswith("default "):
all_names.add("default")
# Aggregating modules
elif name.startswith("* as "):
all_names.add(name[5:].split(" from ", 1)[0])
elif name.startswith("* "):
references.add(name[2:].split("from ", 1)[-1].strip("'\""))
elif name.startswith("{") and " from " in name:
all_names.update(
item.split(" as ", 1)[-1]
for item in name.split(" from ")[0].strip("{}").split(",")
)
elif not (name.startswith("function ") or name.startswith("class ")):
logger.warning(f"Found unknown export type {name!r}")
all_names = {n.strip() for n in all_names}
references = {r.strip() for r in references}
if exclude_default and "default" in all_names:
all_names.remove("default")
return all_names, references
def normalize_url_path(base_url: str, rel_url: str) -> str:
if not rel_url.startswith("."):
if rel_url.startswith("/"):
# copy scheme and hostname from base_url
return urlunparse(urlparse(base_url)[:2] + urlparse(rel_url)[2:])
else:
return rel_url
base_url = base_url.rsplit("/", 1)[0]
if rel_url.startswith("./"):
return base_url + rel_url[1:]
while rel_url.startswith("../"):
base_url = base_url.rsplit("/", 1)[0]
rel_url = rel_url[3:]
return f"{base_url}/{rel_url}"
def are_files_identical(f1: Path, f2: Path) -> bool:
f1 = f1.resolve()
f2 = f2.resolve()
return (
(f1.is_symlink() or f2.is_symlink()) and (f1.resolve() == f2.resolve())
) or filecmp.cmp(str(f1), str(f2), shallow=False)
def copy_file(target: Path, source: Path, symlink: bool) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
if symlink:
if target.exists():
target.unlink()
target.symlink_to(source)
else:
temp_target = target.with_suffix(f"{target.suffix}.tmp")
shutil.copy(source, temp_target)
try:
temp_target.replace(target)
except OSError:
# On Windows, replace might fail if the file is open
# Retry once after a short delay
time.sleep(0.1)
try:
temp_target.replace(target)
except OSError:
# If it still fails, try to unlink and rename
# This is not atomic, but it's a fallback
if target.exists():
target.unlink()
temp_target.rename(target)
_JS_DEFAULT_EXPORT_PATTERN = re.compile(
r";?\s*export\s+default\s",
)
_JS_FUNC_OR_CLS_EXPORT_PATTERN = re.compile(
r";?\s*export\s+(?:function|class)\s+([a-zA-Z_$][0-9a-zA-Z_$]*)"
)
_JS_GENERAL_EXPORT_PATTERN = re.compile(
r"(?:^|;|})\s*export(?=\s+|{)(.*?)(?=;|$)", re.MULTILINE
)
@contextmanager
def file_lock(lock_file: Path, timeout: float = 10.0):
start_time = time.time()
while True:
try:
fd = os.open(lock_file, os.O_CREAT | os.O_EXCL | os.O_RDWR)
os.close(fd)
break
except OSError as e:
if time.time() - start_time > timeout:
raise TimeoutError(f"Could not acquire lock {lock_file}") from e
time.sleep(0.1)
try:
yield
finally:
with suppress(OSError):
os.unlink(lock_file)
================================================
FILE: src/reactpy/static/pyscript-hide-debug.css
================================================
.py-error {
display: none;
}
================================================
FILE: src/reactpy/templatetags/__init__.py
================================================
from reactpy.templatetags.jinja import ReactPyJinja
__all__ = ["ReactPyJinja"]
================================================
FILE: src/reactpy/templatetags/jinja.py
================================================
from typing import ClassVar
from uuid import uuid4
from jinja2_simple_tags import StandaloneTag
from reactpy.executors.pyscript.utils import (
pyscript_component_html,
pyscript_setup_html,
)
from reactpy.executors.utils import server_side_component_html
class ReactPyJinja(StandaloneTag): # type: ignore
safe_output = True
tags: ClassVar[set[str]] = {"component", "pyscript_component", "pyscript_setup"}
def render(self, *args: str, **kwargs: str) -> str:
if self.tag_name == "component":
return component(*args, **kwargs)
if self.tag_name == "pyscript_component":
return pyscript_component(*args, **kwargs)
if self.tag_name == "pyscript_setup":
return pyscript_setup(*args, **kwargs)
# This should never happen, but we validate it for safety.
raise ValueError(f"Unknown tag: {self.tag_name}") # nocov
def component(dotted_path: str, **kwargs: str) -> str:
class_ = kwargs.pop("class", "")
if kwargs:
raise ValueError(f"Unexpected keyword arguments: {', '.join(kwargs)}")
return server_side_component_html(
element_id=uuid4().hex, class_=class_, component_path=f"{dotted_path}/"
)
def pyscript_component(*file_paths: str, initial: str = "", root: str = "root") -> str:
return pyscript_component_html(file_paths=file_paths, initial=initial, root=root)
def pyscript_setup(*extra_py: str, extra_js: str = "", config: str = "") -> str:
return pyscript_setup_html(extra_py=extra_py, extra_js=extra_js, config=config)
================================================
FILE: src/reactpy/testing/__init__.py
================================================
from reactpy.testing.backend import BackendFixture
from reactpy.testing.common import GITHUB_ACTIONS, HookCatcher, StaticEventHandler, poll
from reactpy.testing.display import DisplayFixture
from reactpy.testing.logs import (
LogAssertionError,
assert_reactpy_did_log,
assert_reactpy_did_not_log,
capture_reactpy_logs,
)
__all__ = [
"GITHUB_ACTIONS",
"BackendFixture",
"DisplayFixture",
"HookCatcher",
"LogAssertionError",
"StaticEventHandler",
"assert_reactpy_did_log",
"assert_reactpy_did_not_log",
"capture_reactpy_logs",
"poll",
]
================================================
FILE: src/reactpy/testing/backend.py
================================================
from __future__ import annotations
import asyncio
import logging
import socket
from collections.abc import Callable
from contextlib import AsyncExitStack
from types import TracebackType
from typing import Any
from urllib.parse import urlencode, urlunparse
import uvicorn
from reactpy.core.component import component
from reactpy.core.hooks import use_callback, use_effect, use_state
from reactpy.executors.asgi.middleware import ReactPyMiddleware
from reactpy.executors.asgi.standalone import ReactPy
from reactpy.executors.asgi.types import AsgiApp
from reactpy.testing.logs import (
LogAssertionError,
capture_reactpy_logs,
list_logged_exceptions,
)
from reactpy.types import ComponentConstructor
from reactpy.utils import Ref
class BackendFixture:
"""A test fixture for running a server and imperatively displaying views
This fixture is typically used alongside async web drivers like ``playwight``.
Example:
.. code-block::
async with BackendFixture() as server:
server.mount(MyComponent)
"""
log_records: list[logging.LogRecord]
_server_future: asyncio.Task[Any]
_exit_stack = AsyncExitStack()
def __init__(
self,
app: AsgiApp | None = None,
host: str = "127.0.0.1",
port: int | None = None,
**reactpy_config: Any,
) -> None:
self.host = host
self.port = port or 0
self.mount = mount_to_hotswap
if isinstance(app, (ReactPyMiddleware, ReactPy)):
self._app = app
elif app:
self._app = ReactPyMiddleware(
app,
root_components=["reactpy.testing.backend.root_hotswap_component"],
**reactpy_config,
)
else:
self._app = ReactPy(
root_hotswap_component,
**reactpy_config,
)
self.webserver = uvicorn.Server(
uvicorn.Config(
app=self._app, host=self.host, port=self.port, loop="asyncio"
)
)
def url(self, path: str = "", query: Any | None = None) -> str:
"""Return a URL string pointing to the host and point of the server
Args:
path: the path to a resource on the server
query: a dictionary or list of query parameters
"""
return urlunparse(
[
"http",
f"{self.host}:{self.port}",
path,
"",
urlencode(query or ()),
"",
]
)
def list_logged_exceptions(
self,
pattern: str = "",
types: type[Any] | tuple[type[Any], ...] = Exception,
log_level: int = logging.ERROR,
del_log_records: bool = True,
) -> list[BaseException]:
"""Return a list of logged exception matching the given criteria
Args:
log_level: The level of log to check
exclude_exc_types: Any exception types to ignore
del_log_records: Whether to delete the log records for yielded exceptions
"""
return list_logged_exceptions(
self.log_records,
pattern,
types,
log_level,
del_log_records,
)
async def __aenter__(self) -> BackendFixture:
self._exit_stack = AsyncExitStack()
self.log_records = self._exit_stack.enter_context(capture_reactpy_logs())
# Wait for the server to start
self.webserver.config.get_loop_factory()
self.webserver_task = asyncio.create_task(self.webserver.serve())
for _ in range(100):
if self.webserver.started and self.webserver.servers:
break
await asyncio.sleep(0.1)
else:
msg = "Server failed to start"
raise RuntimeError(msg)
# Determine the port if it was set to 0 (auto-select port)
if self.port == 0:
for server in self.webserver.servers:
for sock in server.sockets:
if sock.family == socket.AF_INET:
self.port = sock.getsockname()[1]
self.webserver.config.port = self.port
break
if self.port != 0:
break
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
await self._exit_stack.aclose()
logged_errors = self.list_logged_exceptions(del_log_records=False)
if logged_errors: # nocov
msg = "Unexpected logged exception"
raise LogAssertionError(msg) from logged_errors[0]
await self.webserver.shutdown()
self.webserver_task.cancel()
async def restart(self) -> None:
"""Restart the server"""
await self.__aexit__(None, None, None)
await self.__aenter__()
_MountFunc = Callable[["Callable[[], Any] | None"], None]
def _hotswap(update_on_change: bool = False) -> tuple[_MountFunc, ComponentConstructor]:
"""Swap out components from a layout on the fly.
Since you can't change the component functions used to create a layout
in an imperative manner, you can use ``hotswap`` to do this so
long as you set things up ahead of time.
Parameters:
update_on_change: Whether or not all views of the layout should be updated on a swap.
Example:
.. code-block:: python
import reactpy
show, root = reactpy.hotswap()
PerClientStateServer(root).run_in_thread("localhost", 8765)
@reactpy.component
def DivOne(self):
return {"tagName": "div", "children": [1]}
show(DivOne)
# displaying the output now will show DivOne
@reactpy.component
def DivTwo(self):
return {"tagName": "div", "children": [2]}
show(DivTwo)
# displaying the output now will show DivTwo
"""
constructor_ref: Ref[Callable[[], Any]] = Ref(lambda: None)
if update_on_change:
set_constructor_callbacks: set[Callable[[Callable[[], Any]], None]] = set()
@component
def HotSwap() -> Any:
# new displays will adopt the latest constructor and arguments
constructor, _set_constructor = use_state(lambda: constructor_ref.current)
set_constructor = use_callback(lambda new: _set_constructor(lambda _: new))
def add_callback() -> Callable[[], None]:
set_constructor_callbacks.add(set_constructor)
return lambda: set_constructor_callbacks.remove(set_constructor)
use_effect(add_callback)
return constructor()
def swap(constructor: Callable[[], Any] | None) -> None:
constructor = constructor_ref.current = constructor or (lambda: None)
for set_constructor in set_constructor_callbacks:
set_constructor(constructor)
else:
@component
def HotSwap() -> Any:
return constructor_ref.current()
def swap(constructor: Callable[[], Any] | None) -> None:
constructor_ref.current = constructor or (lambda: None)
return swap, HotSwap
mount_to_hotswap, root_hotswap_component = _hotswap()
================================================
FILE: src/reactpy/testing/common.py
================================================
from __future__ import annotations
import asyncio
import inspect
import os
import time
from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
from typing import Any, Generic, ParamSpec, TypeVar, cast
from uuid import uuid4
from weakref import ref
from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT
from reactpy.core._life_cycle_hook import HOOK_STACK, LifeCycleHook
from reactpy.core.events import EventHandler, to_event_handler_function
from reactpy.utils import str_to_bool
_P = ParamSpec("_P")
_R = TypeVar("_R")
_DEFAULT_POLL_DELAY = 0.1
GITHUB_ACTIONS = str_to_bool(os.getenv("GITHUB_ACTIONS", ""))
class poll(Generic[_R]): # noqa: N801
"""Wait until the result of an sync or async function meets some condition"""
def __init__(
self,
function: Callable[_P, Awaitable[_R] | _R],
*args: _P.args,
**kwargs: _P.kwargs,
) -> None:
coro: Callable[_P, Awaitable[_R]]
if not inspect.iscoroutinefunction(function):
async def async_func(*args: _P.args, **kwargs: _P.kwargs) -> _R:
return cast(_R, function(*args, **kwargs))
coro = async_func
else:
coro = cast(Callable[_P, Coroutine[Any, Any, _R]], function)
self._func = coro
self._args = args
self._kwargs = kwargs
async def until(
self,
condition: Callable[[_R], bool],
timeout: float = REACTPY_TESTS_DEFAULT_TIMEOUT.current,
delay: float = _DEFAULT_POLL_DELAY,
description: str = "condition to be true",
) -> None:
"""Check that the coroutines result meets a condition within the timeout"""
started_at = time.time()
while True:
await asyncio.sleep(delay)
result = await self._func(*self._args, **self._kwargs)
if condition(result):
break
elif (time.time() - started_at) > timeout: # nocov
msg = f"Expected {description} after {timeout} seconds - last value was {result!r}"
raise TimeoutError(msg)
async def until_is(
self,
right: _R,
timeout: float = REACTPY_TESTS_DEFAULT_TIMEOUT.current,
delay: float = _DEFAULT_POLL_DELAY,
) -> None:
"""Wait until the result is identical to the given value"""
return await self.until(
lambda left: left is right,
timeout,
delay,
f"value to be identical to {right!r}",
)
async def until_equals(
self,
right: _R,
timeout: float = REACTPY_TESTS_DEFAULT_TIMEOUT.current,
delay: float = _DEFAULT_POLL_DELAY,
) -> None:
"""Wait until the result is equal to the given value"""
return await self.until(
lambda left: left == right,
timeout,
delay,
f"value to equal {right!r}",
)
class HookCatcher:
"""Utility for capturing a LifeCycleHook from a component
Example:
.. code-block::
hooks = HookCatcher(index_by_kwarg="thing")
@reactpy.component
@hooks.capture
def MyComponent(thing):
...
... # render the component
# grab the last render of where MyComponent(thing='something')
hooks.index["something"]
# or grab the hook from the component's last render
hooks.latest
After the first render of ``MyComponent`` the ``HookCatcher`` will have
captured the component's ``LifeCycleHook``.
"""
latest: LifeCycleHook
def __init__(self, index_by_kwarg: str | None = None):
self.index_by_kwarg = index_by_kwarg
self.index: dict[Any, LifeCycleHook] = {}
def capture(self, render_function: Callable[..., Any]) -> Callable[..., Any]:
"""Decorator for capturing a ``LifeCycleHook`` on each render of a component"""
# The render function holds a reference to `self` and, via the `LifeCycleHook`,
# the component. Some tests check whether components are garbage collected, thus
# we must use a `ref` here to ensure these checks pass once the catcher itself
# has been collected.
self_ref = ref(self)
@wraps(render_function)
def wrapper(*args: Any, **kwargs: Any) -> Any:
self = self_ref()
if self is None:
raise RuntimeError("Hook catcher has been garbage collected")
hook = HOOK_STACK.current_hook()
if self.index_by_kwarg is not None:
self.index[kwargs[self.index_by_kwarg]] = hook
self.latest = hook
return render_function(*args, **kwargs)
return wrapper
class StaticEventHandler:
"""Utility for capturing the target of one event handler
Example:
.. code-block::
static_handler = StaticEventHandler()
@reactpy.component
def MyComponent():
state, set_state = reactpy.hooks.use_state(0)
handler = static_handler.use(lambda event: set_state(state + 1))
return reactpy.html.button({"onClick": handler}, "Click me!")
# gives the target ID for onClick where from the last render of MyComponent
static_handlers.target
If you need to capture event handlers from different instances of a component
the you should create multiple ``StaticEventHandler`` instances.
.. code-block::
static_handlers_by_key = {
"first": StaticEventHandler(),
"second": StaticEventHandler(),
}
@reactpy.component
def Parent():
return reactpy.html.div(Child(key="first"), Child(key="second"))
@reactpy.component
def Child(key):
state, set_state = reactpy.hooks.use_state(0)
handler = static_handlers_by_key[key].use(lambda event: set_state(state + 1))
return reactpy.html.button({"onClick": handler}, "Click me!")
# grab the individual targets for each instance above
first_target = static_handlers_by_key["first"].target
second_target = static_handlers_by_key["second"].target
"""
def __init__(self) -> None:
self.target = uuid4().hex
def use(
self,
function: Callable[..., Any],
stop_propagation: bool = False,
prevent_default: bool = False,
) -> EventHandler:
return EventHandler(
to_event_handler_function(function),
stop_propagation,
prevent_default,
self.target,
)
================================================
FILE: src/reactpy/testing/display.py
================================================
from __future__ import annotations
import os
from contextlib import AsyncExitStack
from logging import getLogger
from types import TracebackType
from typing import TYPE_CHECKING, Any
from playwright.async_api import Browser, Page, async_playwright, expect
from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT as DEFAULT_TIMEOUT
from reactpy.testing.backend import BackendFixture
from reactpy.types import RootComponentConstructor
if TYPE_CHECKING:
import pytest
_logger = getLogger(__name__)
class DisplayFixture:
"""A fixture for running web-based tests using ``playwright``"""
page: Page
browser_is_external: bool = False
backend_is_external: bool = False
def __init__(
self,
backend: BackendFixture | None = None,
browser: Browser | None = None,
headless: bool = False,
timeout: float | None = None,
) -> None:
if backend:
self.backend_is_external = True
self.backend = backend
if browser:
self.browser_is_external = True
self.browser = browser
self.timeout = DEFAULT_TIMEOUT.current if timeout is None else timeout
self.headless = headless
async def show(
self,
component: RootComponentConstructor,
) -> None:
self.backend.mount(component)
await self.goto("/")
async def goto(self, path: str, query: Any | None = None) -> None:
await self.configure_page()
await self.page.goto(self.backend.url(path, query))
async def __aenter__(self) -> DisplayFixture:
self.exit_stack = AsyncExitStack()
if not hasattr(self, "browser"):
pw = await self.exit_stack.enter_async_context(async_playwright())
self.browser = await self.exit_stack.enter_async_context(
await pw.chromium.launch(headless=not _playwright_visible())
)
expect.set_options(timeout=self.timeout * 1000)
await self.configure_page()
if not hasattr(self, "backend"): # nocov
self.backend = BackendFixture()
await self.exit_stack.enter_async_context(self.backend)
return self
async def configure_page(self) -> None:
if getattr(self, "page", None) is None:
self.page = await self.browser.new_page()
self.page = await self.exit_stack.enter_async_context(self.page)
self.page.set_default_navigation_timeout(self.timeout * 1000)
self.page.set_default_timeout(self.timeout * 1000)
self.page.on(
"requestfailed",
lambda x: _logger.error(f"BROWSER LOAD ERROR: {x.url}\n{x.failure}"),
)
self.page.on(
"console", lambda x: _logger.info(f"BROWSER CONSOLE: {x.text}")
)
self.page.on(
"pageerror",
lambda x: _logger.error(
f"BROWSER ERROR: {x.name} - {x.message}\n{x.stack}"
),
)
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
self.backend.mount(None)
await self.exit_stack.aclose()
def _playwright_visible(pytestconfig: pytest.Config | None = None) -> bool:
if (pytestconfig and pytestconfig.getoption("visible")) or os.environ.get(
"PLAYWRIGHT_VISIBLE"
) == "1":
os.environ.setdefault("PLAYWRIGHT_VISIBLE", "1")
return True
return False
================================================
FILE: src/reactpy/testing/logs.py
================================================
from __future__ import annotations
import logging
import re
from collections.abc import Iterator
from contextlib import contextmanager
from traceback import format_exception
from typing import Any, NoReturn
from reactpy.logging import ROOT_LOGGER
class LogAssertionError(AssertionError):
"""An assertion error raised in relation to log messages."""
@contextmanager
def assert_reactpy_did_log(
match_message: str = "",
error_type: type[Exception] | None = None,
match_error: str = "",
) -> Iterator[None]:
"""Assert that ReactPy produced a log matching the described message or error.
Args:
match_message: Must match a logged message.
error_type: Checks the type of logged exceptions.
match_error: Must match an error message.
"""
message_pattern = re.compile(match_message)
error_pattern = re.compile(match_error)
with capture_reactpy_logs() as log_records:
try:
yield None
except Exception:
raise
else:
for record in list(log_records):
if (
# record message matches
message_pattern.findall(record.getMessage())
# error type matches
and (
error_type is None
or (
record.exc_info is not None
and record.exc_info[0] is not None
and issubclass(record.exc_info[0], error_type)
)
)
# error message pattern matches
and (
not match_error
or (
record.exc_info is not None
and error_pattern.findall(
"".join(format_exception(*record.exc_info))
)
)
)
):
break
else: # nocov
_raise_log_message_error(
"Could not find a log record matching the given",
match_message,
error_type,
match_error,
)
@contextmanager
def assert_reactpy_did_not_log(
match_message: str = "",
error_type: type[Exception] | None = None,
match_error: str = "",
) -> Iterator[None]:
"""Assert the inverse of :func:`assert_reactpy_logged`"""
try:
with assert_reactpy_did_log(match_message, error_type, match_error):
yield None
except LogAssertionError:
pass
else:
_raise_log_message_error(
"Did find a log record matching the given",
match_message,
error_type,
match_error,
)
def list_logged_exceptions(
log_records: list[logging.LogRecord],
pattern: str = "",
types: type[Any] | tuple[type[Any], ...] = Exception,
log_level: int = logging.ERROR,
del_log_records: bool = True,
) -> list[BaseException]:
"""Return a list of logged exception matching the given criteria
Args:
log_level: The level of log to check
exclude_exc_types: Any exception types to ignore
del_log_records: Whether to delete the log records for yielded exceptions
"""
found: list[BaseException] = []
compiled_pattern = re.compile(pattern)
for index, record in enumerate(log_records):
if record.levelno >= log_level and record.exc_info:
error = record.exc_info[1]
if (
error is not None
and isinstance(error, types)
and compiled_pattern.search(str(error))
):
if del_log_records:
del log_records[index - len(found)]
found.append(error)
return found
@contextmanager
def capture_reactpy_logs() -> Iterator[list[logging.LogRecord]]:
"""Capture logs from ReactPy
Any logs produced in this context are cleared afterwards
"""
original_level = ROOT_LOGGER.level
ROOT_LOGGER.setLevel(logging.DEBUG)
try:
if _LOG_RECORD_CAPTOR in ROOT_LOGGER.handlers:
start_index = len(_LOG_RECORD_CAPTOR.records)
try:
yield _LOG_RECORD_CAPTOR.records
finally:
end_index = len(_LOG_RECORD_CAPTOR.records)
_LOG_RECORD_CAPTOR.records[start_index:end_index] = []
return None
ROOT_LOGGER.addHandler(_LOG_RECORD_CAPTOR)
try:
yield _LOG_RECORD_CAPTOR.records
finally:
ROOT_LOGGER.removeHandler(_LOG_RECORD_CAPTOR)
_LOG_RECORD_CAPTOR.records.clear()
finally:
ROOT_LOGGER.setLevel(original_level)
class _LogRecordCaptor(logging.NullHandler):
def __init__(self) -> None:
self.records: list[logging.LogRecord] = []
super().__init__()
def handle(self, record: logging.LogRecord) -> bool:
self.records.append(record)
return True
_LOG_RECORD_CAPTOR = _LogRecordCaptor()
def _raise_log_message_error(
prefix: str,
match_message: str = "",
error_type: type[Exception] | None = None,
match_error: str = "",
) -> NoReturn:
conditions = []
if match_message:
conditions.append(f"log message pattern {match_message!r}")
if error_type:
conditions.append(f"exception type {error_type}")
if match_error:
conditions.append(f"error message pattern {match_error!r}")
raise LogAssertionError(f"{prefix} " + " and ".join(conditions))
================================================
FILE: src/reactpy/transforms.py
================================================
from __future__ import annotations
from typing import Any, cast
from reactpy.core.events import EventHandler, to_event_handler_function
from reactpy.types import VdomAttributes, VdomDict
def attributes_to_reactjs(attributes: VdomAttributes):
"""Convert HTML attribute names to their ReactJS equivalents."""
attrs = cast(VdomAttributes, attributes.items())
attrs = cast(
VdomAttributes,
{REACT_PROP_SUBSTITUTIONS.get(k, k): v for k, v in attrs},
)
return attrs
class RequiredTransforms:
"""Performs any necessary transformations related to `string_to_reactpy` to automatically prevent
issues with React's rendering engine.
"""
def __init__(self, vdom: VdomDict, intercept_links: bool = True) -> None:
self._intercept_links = intercept_links
# Run every transform in this class.
for name in dir(self):
# Any method that doesn't start with an underscore is assumed to be a transform.
if not name.startswith("_"):
getattr(self, name)(vdom)
def normalize_style_attributes(self, vdom: dict[str, Any]) -> None:
"""Convert style attribute from str -> dict with camelCase keys"""
if (
"attributes" in vdom
and "style" in vdom["attributes"]
and isinstance(vdom["attributes"]["style"], str)
):
vdom["attributes"]["style"] = {
self._kebab_to_camel_case(key.strip()): value.strip()
for key, value in (
part.split(":", 1)
for part in vdom["attributes"]["style"].split(";")
if ":" in part
)
}
@staticmethod
def textarea_children_to_prop(vdom: VdomDict) -> None:
"""Transformation that converts the text content of a