diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 171fc9853..dcdf54342 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -8,23 +8,28 @@ Always reference these instructions first and fallback to search or bash command **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. +**CODE RETRIEVAL**: Always prioritize using Serena tools (e.g., `mcp_oraios_serena_find_symbol`, `mcp_oraios_serena_search_for_pattern`) for code retrieval and analysis over standard file reading or searching tools. + ## 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 + +- 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 @@ -37,41 +42,48 @@ 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 + +- `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. + +- `hatch build --clean` -- takes 10 seconds. NEVER CANCEL. Set timeout to 60+ minutes for safety. **Run Python Tests:** -- `hatch test` -- takes 10-30 seconds for basic tests. NEVER CANCEL. Set timeout to 60+ minutes for full test suite. **All tests must always pass - failures are never expected or allowed.** -- `hatch test --cover` -- run tests with coverage reporting (used in CI) -- `hatch test -k test_name` -- run specific tests -- `hatch test tests/test_config.py` -- run specific test files -- Note: Some tests require Playwright browser automation and may fail in headless environments + +- `hatch test` -- takes 10-30 seconds for basic tests. NEVER CANCEL. Set timeout to 60+ minutes for full test suite. **All tests must always pass - failures are never expected or allowed.** +- `hatch test --cover` -- run tests with coverage reporting (used in CI) +- `hatch test -k test_name` -- run specific tests +- `hatch test tests/test_config.py` -- run specific test files +- Note: Some tests require Playwright browser automation and may fail in headless environments **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) + +- `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 (note: may fail in headless environments due to DOM dependencies) + +- `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 (note: may fail in headless environments due to DOM dependencies) **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 + +- `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 @@ -94,6 +106,7 @@ print(f"Component rendered: {type(vdom)}") ``` **Server Functionality Test:** + ```python # Test ASGI server creation (most common deployment) from reactpy import component, html @@ -115,13 +128,14 @@ print("✓ ASGI server created successfully") ``` **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({ @@ -135,58 +149,64 @@ 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` -- **All tests must always pass - failures are never expected or allowed** + +- `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` -- **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 + +- 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) + +- `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 + +- `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 + +- 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 @@ -201,12 +221,13 @@ 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 +@component def my_component(): return html.h1("Hello from ReactPy!") @@ -215,13 +236,14 @@ 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({ @@ -231,16 +253,18 @@ def my_component(initial_value=0): ``` ### 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` + +- 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 # Run all tests (**All tests must always pass**) hatch test --cover # Run tests with coverage (used in CI) @@ -255,6 +279,7 @@ hatch build --clean # Build Python package (10 seconds) ``` ### Environment Management + ```bash hatch env show # Show all environments hatch shell # Enter default shell @@ -262,14 +287,15 @@ 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 + +- **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 @@ -293,76 +319,85 @@ Follow this step-by-step process for effective development: ## 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` + +- 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` ### Test Issues: -- Playwright tests may fail in headless environments -- this is expected -- Tests requiring browser DOM should be marked appropriately -- Use `hatch test -k "not playwright"` to skip browser-dependent tests -- JavaScript tests may fail with "window is not defined" in Node.js environment -- this is expected + +- Playwright tests may fail in headless environments -- this is expected +- Tests requiring browser DOM should be marked appropriately +- Use `hatch test -k "not playwright"` to skip browser-dependent tests +- JavaScript tests may fail with "window is not defined" in Node.js environment -- this is expected ### 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` + +- 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 + +- 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 + +- `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 + +- `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 + +- **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 --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 + +- `test-python-coverage` -- Python test coverage with `hatch test --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 + +- **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 -- **Browser automation tests may fail in headless environments** - this is expected behavior for Playwright tests -- **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 \ No newline at end of file +- **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 +- **Browser automation tests may fail in headless environments** - this is expected behavior for Playwright tests +- **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 diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 9c21380ca..957284d1c 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -27,7 +27,7 @@ jobs: job-name: "python-{0} {1}" run-cmd: "hatch test" runs-on: '["ubuntu-latest", "macos-latest", "windows-latest"]' - python-version: '["3.10", "3.11", "3.12", "3.13"]' + 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 diff --git a/.prettierrc b/.prettierrc index 32ad81f35..c8d22583b 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,4 +1,5 @@ { "proseWrap": "never", - "trailingComma": "all" + "trailingComma": "all", + "endOfLine": "auto" } diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 000000000..14d86ad62 --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/.serena/memories/code_style.md b/.serena/memories/code_style.md new file mode 100644 index 000000000..cd9ad1ecd --- /dev/null +++ b/.serena/memories/code_style.md @@ -0,0 +1,8 @@ +# Code Style & Conventions + +- **Formatting**: Enforced by `hatch fmt`. +- **Type Hints**: Required, checked by `hatch run python:type_check`. +- **JS Linting**: Enforced by `hatch run javascript:check`. +- **Tests**: All tests must pass. Failures not allowed. +- **Documentation**: Must be updated with code changes. +- **Changelog**: Required for significant changes. diff --git a/.serena/memories/development_workflow.md b/.serena/memories/development_workflow.md new file mode 100644 index 000000000..e2d0cc191 --- /dev/null +++ b/.serena/memories/development_workflow.md @@ -0,0 +1,12 @@ +# Development Workflow + +1. **Bootstrap**: Install Python 3.9+, Hatch, Bun. +2. **Changes**: Modify code. +3. **Format**: `hatch fmt`. +4. **Type Check**: `hatch run python:type_check`. +5. **JS Check**: `hatch run javascript:check` (if JS changed). +6. **Test**: `hatch test`. +7. **Validate**: Manual component/server tests. +8. **Build JS**: `hatch run javascript:build` (if JS changed). +9. **Docs**: Update if Python source changed. +10. **Changelog**: Add entry in `docs/source/about/changelog.rst`. diff --git a/.serena/memories/project_overview.md b/.serena/memories/project_overview.md new file mode 100644 index 000000000..b36012911 --- /dev/null +++ b/.serena/memories/project_overview.md @@ -0,0 +1,16 @@ +# ReactPy Overview + +## Purpose +ReactPy builds UIs in Python without JS, using React-like components and a Python-to-JS bridge. + +## Tech Stack +- **Python**: 3.9+ +- **Build**: Hatch +- **JS Runtime**: Bun +- **Deps**: fastjsonschema, requests, lxml, anyio + +## Structure +- `src/reactpy/`: Python source (core, web, executors). +- `src/js/`: JS packages (client, app). +- `tests/`: Test suite. +- `docs/`: Documentation. diff --git a/.serena/memories/suggested_commands.md b/.serena/memories/suggested_commands.md new file mode 100644 index 000000000..66f252da4 --- /dev/null +++ b/.serena/memories/suggested_commands.md @@ -0,0 +1,17 @@ +# Suggested Commands (Hatch) + +## Python +- **Test**: `hatch test` (All must pass) +- **Coverage**: `hatch test --cover` +- **Format**: `hatch fmt` +- **Check Format**: `hatch fmt --check` +- **Type Check**: `hatch run python:type_check` +- **Build**: `hatch build --clean` + +## JavaScript +- **Build**: `hatch run javascript:build` (Rebuild after JS changes) +- **Check**: `hatch run javascript:check` +- **Fix**: `hatch run javascript:fix` + +## Shell +- **Dev Shell**: `hatch shell` diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 000000000..fa4652a00 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,84 @@ +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp csharp_omnisharp +# dart elixir elm erlang fortran go +# haskell java julia kotlin lua markdown +# nix perl php python python_jedi r +# rego ruby ruby_solargraph rust scala swift +# terraform typescript typescript_vts yaml zig +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# Special requirements: +# - csharp: Requires the presence of a .sln file in the project folder. +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- python + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# whether to use the project's gitignore file to ignore files +# Added on 2025-04-07 +ignore_all_files_in_gitignore: true + +# list of additional paths to ignore +# same syntax as gitignore, so you can use * and ** +# Was previously called `ignored_dirs`, please update your config if you are using that. +# Added (renamed) on 2025-04-07 +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +excluded_tools: [] + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +project_name: "reactpy" +included_optional_tools: [] diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index a7f147f24..8f7e87c78 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -17,44 +17,49 @@ 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:`1267` - Added ``shutdown_timeout`` parameter to the ``reactpy.use_async_effect`` hook. - :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:`1113` - Added ``asgi`` and ``jinja`` installation extras (for example ``pip install reactpy[asgi, jinja]``). -- :pull:`1113` - Added support for Python 3.12 and 3.13. - :pull:`1264` - Added ``reactpy.use_async_effect`` hook. -- :pull:`1267` - Added ``shutdown_timeout`` parameter to the ``reactpy.use_async_effect`` hook. -- :pull:`1281` - ``reactpy.html`` will now automatically flatten lists recursively (ex. ``reactpy.html(["child1", ["child2"]])``) - :pull:`1281` - Added ``reactpy.Vdom`` primitive interface for creating VDOM dictionaries. -- :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:`1307` - Added ``reactpy.web.reactjs_component_from_file`` to import ReactJS components from a file. - :pull:`1307` - Added ``reactpy.web.reactjs_component_from_url`` to import ReactJS components from a URL. - :pull:`1307` - Added ``reactpy.web.reactjs_component_from_string`` to import ReactJS components from a string. -- :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``). **Changed** - :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 script elements. +- :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:`1113` - ``@reactpy/client`` now exports ``React`` and ``ReactDOM``. - :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:`1196` - Rewrite the ``event-to-object`` package to be more robust at handling properties on events. +- :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. **Deprecated** @@ -63,10 +68,15 @@ Unreleased -:pull:`1307` - ``reactpy.web.module_from_url`` is deprecated. Use ``reactpy.web.reactjs_component_from_url`` instead. -:pull:`1307` - ``reactpy.web.module_from_string`` is deprecated. Use ``reactpy.web.reactjs_component_from_string`` 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 ``html.svg.*``. - :pull:`1255` - Removed ``reactpy.html._`` function. Use ``html.fragment`` instead. @@ -75,17 +85,13 @@ Unreleased - :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:`1113` - Removed backend specific installation extras (such as ``pip install reactpy[starlette]``). -- :pull:`1113` - Removed deprecated function ``module_from_template``. -- :pull:`1113` - Removed support for Python 3.9. -- :pull:`1264` - Removed support for async functions within ``reactpy.use_effect`` hook. Use ``reactpy.use_async_effect`` 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.core.serve.Stop`` type due to extended deprecation. -- :pull:`1311` - Removed ``reactpy.Layout`` top-level export. Use ``reactpy.core.layout.Layout`` instead. -- :pull:`1311` - Removed ``reactpy.widgets.hotswap`` due to extended deprecation. - +- :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. **Fixed** @@ -93,6 +99,7 @@ Unreleased - :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. + v1.1.0 ------ :octicon:`milestone` *released on 2024-11-24* @@ -102,7 +109,7 @@ v1.1.0 - :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` - Fixes needless unmounting of JavaScript components during each ReactPy render. +- :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 diff --git a/pyproject.toml b/pyproject.toml index 8c1329f08..ae1288b53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,20 +10,32 @@ requires = ["hatchling", "hatch-build-scripts"] name = "reactpy" description = "It's React, but in Python." readme = "README.md" -keywords = ["react", "javascript", "reactpy", "component"] +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.9" +requires-python = ">=3.11" classifiers = [ "Development Status :: 5 - Production/Stable", "Programming Language :: Python", - "Programming Language :: Python :: 3.10", "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", ] @@ -32,7 +44,6 @@ dependencies = [ "requests >=2", "lxml >=4", "anyio >=3", - "typing-extensions >=3.10", ] dynamic = ["version"] urls.Changelog = "https://reactpy.dev/docs/about/changelog.html" @@ -41,7 +52,7 @@ urls.Source = "https://github.com/reactive-python/reactpy" [project.optional-dependencies] all = ["reactpy[asgi,jinja,testing]"] -asgi = ["asgiref", "asgi-tools", "servestatic", "orjson", "pip"] +asgi = ["asgiref", "asgi-tools", "servestatic", "orjson"] jinja = ["jinja2-simple-tags", "jinja2 >=3"] testing = ["playwright", "uvicorn[standard]"] @@ -95,7 +106,7 @@ extra-dependencies = [ features = ["all"] [[tool.hatch.envs.hatch-test.matrix]] -python = ["3.10", "3.11", "3.12", "3.13"] +python = ["3.11", "3.12", "3.13", "3.14"] [tool.pytest.ini_options] addopts = ["--strict-config", "--strict-markers"] diff --git a/src/js/bun.lockb b/src/js/bun.lockb index 2bd0b800a..91f0e2ea9 100644 Binary files a/src/js/bun.lockb and b/src/js/bun.lockb differ diff --git a/src/js/package.json b/src/js/package.json index a6fd35598..0b32e28ae 100644 --- a/src/js/package.json +++ b/src/js/package.json @@ -11,7 +11,7 @@ "json-pointer": "^0.6.2", "@types/json-pointer": "^1.0.34", "@reactpy/client": "file:./packages/@reactpy/client", - "event-to-object": "file:./packages/event-to-object" + "event-to-object": "^1.0.1" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/src/js/packages/@reactpy/client/package.json b/src/js/packages/@reactpy/client/package.json index c55622688..d8e2fcefd 100644 --- a/src/js/packages/@reactpy/client/package.json +++ b/src/js/packages/@reactpy/client/package.json @@ -31,5 +31,5 @@ "checkTypes": "tsc --noEmit" }, "type": "module", - "version": "1.0.1" + "version": "1.0.2" } diff --git a/src/js/packages/@reactpy/client/src/vdom.tsx b/src/js/packages/@reactpy/client/src/vdom.tsx index f87ae4617..b94ba3003 100644 --- a/src/js/packages/@reactpy/client/src/vdom.tsx +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -1,5 +1,6 @@ import type { ReactPyClientInterface } from "./types"; import eventToObject from "event-to-object"; +import * as preact from "preact"; import type { ReactPyVdom, ReactPyVdomImportSource, @@ -20,14 +21,18 @@ export async function loadImportSource( } else { module = await client.loadModule(vdomImportSource.source); } - if (typeof module.bind !== "function") { - throw new Error( - `${vdomImportSource.source} did not export a function 'bind'`, + + let { bind } = module; + if (typeof bind !== "function") { + console.debug( + "Using generic ReactJS binding for components in module", + module, ); + bind = generic_reactjs_bind; } return (node: HTMLElement) => { - const binding = module.bind(node, { + const binding = bind(node, { sendMessage: client.sendMessage, onMessage: client.onMessage, }); @@ -254,3 +259,14 @@ function createInlineJavaScript( wrappedExecutable.isHandler = false; return [name, wrappedExecutable]; } + +function generic_reactjs_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), + }; +} diff --git a/src/reactpy/__init__.py b/src/reactpy/__init__.py index bea5c6d1a..fc6c5332e 100644 --- a/src/reactpy/__init__.py +++ b/src/reactpy/__init__.py @@ -23,7 +23,7 @@ from reactpy.utils import Ref, reactpy_to_string, string_to_reactpy __author__ = "The Reactive Python Team" -__version__ = "2.0.0b4" +__version__ = "2.0.0b5" __all__ = [ "Ref", diff --git a/src/reactpy/_console/rewrite_props.py b/src/reactpy/_console/rewrite_props.py index f7ae7c656..e66b38735 100644 --- a/src/reactpy/_console/rewrite_props.py +++ b/src/reactpy/_console/rewrite_props.py @@ -1,10 +1,10 @@ from __future__ import annotations import ast +from collections.abc import Callable from copy import copy from keyword import kwlist from pathlib import Path -from typing import Callable import click @@ -102,7 +102,7 @@ def _rewrite_props( 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): + 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) diff --git a/src/reactpy/_option.py b/src/reactpy/_option.py index 1db0857e3..9e57c2289 100644 --- a/src/reactpy/_option.py +++ b/src/reactpy/_option.py @@ -1,8 +1,9 @@ from __future__ import annotations import os +from collections.abc import Callable from logging import getLogger -from typing import Any, Callable, Generic, TypeVar, cast +from typing import Any, Generic, TypeVar, cast from reactpy._warnings import warn diff --git a/src/reactpy/config.py b/src/reactpy/config.py index 993e6d8b4..415f346cd 100644 --- a/src/reactpy/config.py +++ b/src/reactpy/config.py @@ -85,11 +85,11 @@ def boolean(value: str | bool | int) -> bool: REACTPY_ASYNC_RENDERING = Option( "REACTPY_ASYNC_RENDERING", - default=False, + default=True, mutable=True, validator=boolean, ) -"""Whether to render components asynchronously. This is currently an experimental feature.""" +"""Whether to render components asynchronously.""" REACTPY_RECONNECT_INTERVAL = Option( "REACTPY_RECONNECT_INTERVAL", diff --git a/src/reactpy/core/_life_cycle_hook.py b/src/reactpy/core/_life_cycle_hook.py index c940bf01b..14b1bc084 100644 --- a/src/reactpy/core/_life_cycle_hook.py +++ b/src/reactpy/core/_life_cycle_hook.py @@ -3,13 +3,14 @@ import logging import sys from asyncio import Event, Task, create_task, gather +from collections.abc import Callable from contextvars import ContextVar, Token -from typing import Any, Callable, Protocol, TypeVar +from typing import Any, Protocol, TypeVar from anyio import Semaphore from reactpy.core._thread_local import ThreadLocal -from reactpy.types import ComponentType, Context, ContextProviderType +from reactpy.types import Component, Context, ContextProvider from reactpy.utils import Singleton T = TypeVar("T") @@ -145,13 +146,13 @@ async def my_effect(stop_event): "component", ) - component: ComponentType + component: Component def __init__( self, schedule_render: Callable[[], None], ) -> None: - self._context_providers: dict[Context[Any], ContextProviderType[Any]] = {} + self._context_providers: dict[Context[Any], ContextProvider[Any]] = {} self._schedule_render_callback = schedule_render self._scheduled_render = False self._rendered_atleast_once = False @@ -200,7 +201,7 @@ def add_effect(self, effect_func: EffectFunc) -> None: """ self._effect_funcs.append(effect_func) - def set_context_provider(self, provider: ContextProviderType[Any]) -> None: + def set_context_provider(self, provider: ContextProvider[Any]) -> None: """Set a context provider for this hook The context provider will be used to provide state to any child components @@ -208,9 +209,7 @@ def set_context_provider(self, provider: ContextProviderType[Any]) -> None: """ self._context_providers[provider.type] = provider - def get_context_provider( - self, context: Context[T] - ) -> ContextProviderType[T] | None: + def get_context_provider(self, context: Context[T]) -> ContextProvider[T] | None: """Get a context provider for this hook of the given type The context provider will have been set by a parent component. If no provider @@ -218,7 +217,7 @@ def get_context_provider( """ return self._context_providers.get(context) - async def affect_component_will_render(self, component: ComponentType) -> None: + async def affect_component_will_render(self, component: Component) -> None: """The component is about to render""" await self._render_access.acquire() self._scheduled_render = False diff --git a/src/reactpy/core/_thread_local.py b/src/reactpy/core/_thread_local.py index eb582e8e8..9d4bae99c 100644 --- a/src/reactpy/core/_thread_local.py +++ b/src/reactpy/core/_thread_local.py @@ -1,5 +1,6 @@ +from collections.abc import Callable from threading import Thread, current_thread -from typing import Callable, Generic, TypeVar +from typing import Generic, TypeVar from weakref import WeakKeyDictionary _StateType = TypeVar("_StateType") diff --git a/src/reactpy/core/component.py b/src/reactpy/core/component.py index e8b16fae2..1b7ff3c3c 100644 --- a/src/reactpy/core/component.py +++ b/src/reactpy/core/component.py @@ -1,14 +1,15 @@ from __future__ import annotations import inspect +from collections.abc import Callable from functools import wraps -from typing import Any, Callable +from typing import Any -from reactpy.types import ComponentType, VdomDict +from reactpy.types import Component, VdomDict def component( - function: Callable[..., ComponentType | VdomDict | str | None], + function: Callable[..., Component | VdomDict | str | None], ) -> Callable[..., Component]: """A decorator for defining a new component. @@ -29,38 +30,3 @@ def constructor(*args: Any, key: Any | None = None, **kwargs: Any) -> Component: return Component(function, key, args, kwargs, sig) return constructor - - -class Component: - """An object for rending component models.""" - - __slots__ = "__weakref__", "_args", "_func", "_kwargs", "_sig", "key", "type" - - def __init__( - self, - function: Callable[..., ComponentType | VdomDict | str | None], - key: Any | None, - args: tuple[Any, ...], - kwargs: dict[str, Any], - sig: inspect.Signature, - ) -> None: - self.key = key - self.type = function - self._args = args - self._kwargs = kwargs - self._sig = sig - - def render(self) -> ComponentType | VdomDict | str | None: - return self.type(*self._args, **self._kwargs) - - def __repr__(self) -> str: - try: - args = self._sig.bind(*self._args, **self._kwargs).arguments - except TypeError: - return f"{self.type.__name__}(...)" - else: - items = ", ".join(f"{k}={v!r}" for k, v in args.items()) - if items: - return f"{self.type.__name__}({id(self):02x}, {items})" - else: - return f"{self.type.__name__}({id(self):02x})" diff --git a/src/reactpy/core/events.py b/src/reactpy/core/events.py index 266d65ae2..ab7d639f5 100644 --- a/src/reactpy/core/events.py +++ b/src/reactpy/core/events.py @@ -1,13 +1,15 @@ from __future__ import annotations -import asyncio import dis -from collections.abc import Sequence -from typing import Any, Callable, Literal, cast, overload +import inspect +from collections.abc import Callable, Sequence +from functools import lru_cache +from types import CodeType +from typing import Any, Literal, cast, overload from anyio import create_task_group -from reactpy.types import EventHandlerFunc, EventHandlerType +from reactpy.types import BaseEventHandler, EventHandlerFunc @overload @@ -73,7 +75,7 @@ def setup(function: Callable[..., Any]) -> EventHandler: return setup(function) if function is not None else setup -class EventHandler: +class EventHandler(BaseEventHandler): """Turn a function or coroutine into an event handler Parameters: @@ -87,14 +89,6 @@ class EventHandler: A unique identifier for this event handler (auto-generated by default) """ - __slots__ = ( - "__weakref__", - "function", - "prevent_default", - "stop_propagation", - "target", - ) - def __init__( self, function: EventHandlerFunc, @@ -113,26 +107,14 @@ def __init__( while hasattr(func_to_inspect, "__wrapped__"): func_to_inspect = func_to_inspect.__wrapped__ - code = func_to_inspect.__code__ - if code.co_argcount > 0: - event_arg_name = code.co_varnames[0] - last_was_event = False - - for instr in dis.get_instructions(func_to_inspect): - if instr.opname == "LOAD_FAST" and instr.argval == event_arg_name: - last_was_event = True - continue - - if last_was_event and instr.opname in ( - "LOAD_METHOD", - "LOAD_ATTR", - ): - if instr.argval == "preventDefault": - self.prevent_default = True - elif instr.argval == "stopPropagation": - self.stop_propagation = True + found_prevent_default, found_stop_propagation = _inspect_event_handler_code( + func_to_inspect.__code__ + ) - last_was_event = False + if found_prevent_default: + self.prevent_default = True + if found_stop_propagation: + self.stop_propagation = True __hash__ = None # type: ignore @@ -168,7 +150,7 @@ def to_event_handler_function( Whether to pass the event parameters a positional args or as a list. """ if positional_args: - if asyncio.iscoroutinefunction(function): + if inspect.iscoroutinefunction(function): async def wrapper(data: Sequence[Any]) -> None: await function(*data) @@ -182,7 +164,7 @@ async def wrapper(data: Sequence[Any]) -> None: cast(Any, wrapper).__wrapped__ = function return wrapper - elif not asyncio.iscoroutinefunction(function): + elif not inspect.iscoroutinefunction(function): async def wrapper(data: Sequence[Any]) -> None: function(data) @@ -194,8 +176,8 @@ async def wrapper(data: Sequence[Any]) -> None: def merge_event_handlers( - event_handlers: Sequence[EventHandlerType], -) -> EventHandlerType: + event_handlers: Sequence[BaseEventHandler], +) -> BaseEventHandler: """Merge multiple event handlers into one Raises a ValueError if any handlers have conflicting @@ -247,3 +229,46 @@ async def await_all_event_handlers(data: Sequence[Any]) -> None: group.start_soon(func, data) return await_all_event_handlers + + +@lru_cache(maxsize=4096) +def _inspect_event_handler_code(code: CodeType) -> tuple[bool, bool]: + prevent_default = False + stop_propagation = False + + if code.co_argcount > 0: + names = code.co_names + check_prevent_default = "preventDefault" in names + check_stop_propagation = "stopPropagation" in names + + if not (check_prevent_default or check_stop_propagation): + return False, False + + event_arg_name = code.co_varnames[0] + last_was_event = False + + for instr in dis.get_instructions(code): + if ( + instr.opname in ("LOAD_FAST", "LOAD_FAST_BORROW") + and instr.argval == event_arg_name + ): + last_was_event = True + continue + + if last_was_event and instr.opname in ( + "LOAD_METHOD", + "LOAD_ATTR", + ): + if check_prevent_default and instr.argval == "preventDefault": + prevent_default = True + check_prevent_default = False + elif check_stop_propagation and instr.argval == "stopPropagation": + stop_propagation = True + check_stop_propagation = False + + if not (check_prevent_default or check_stop_propagation): + break + + last_was_event = False + + return prevent_default, stop_propagation diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index e7b995273..27fbc1ce4 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -2,31 +2,30 @@ import asyncio import contextlib -from collections.abc import Coroutine, Sequence +import inspect +from collections.abc import Callable, Coroutine, Sequence from logging import getLogger from types import FunctionType from typing import ( TYPE_CHECKING, Any, - Callable, Generic, Protocol, + TypeAlias, TypeVar, cast, overload, ) -from typing_extensions import TypeAlias - from reactpy.config import REACTPY_DEBUG from reactpy.core._life_cycle_hook import HOOK_STACK from reactpy.types import ( Connection, Context, + ContextProvider, Key, Location, State, - VdomDict, ) from reactpy.utils import Ref @@ -146,11 +145,6 @@ def use_effect( Returns: If not function is provided, a decorator. Otherwise ``None``. """ - if asyncio.iscoroutinefunction(function): - raise TypeError( - "`use_effect` does not support async functions. " - "Use `use_async_effect` instead." - ) hook = HOOK_STACK.current_hook() dependencies = _try_to_infer_closure_values(function, dependencies) @@ -158,6 +152,12 @@ def use_effect( cleanup_func: Ref[_EffectCleanFunc | None] = use_ref(None) def decorator(func: _SyncEffectFunc) -> None: + if inspect.iscoroutinefunction(func): + raise TypeError( + "`use_effect` does not support async functions. " + "Use `use_async_effect` instead." + ) + async def effect(stop: asyncio.Event) -> None: # Since the effect is asynchronous, we need to make sure we # always clean up the previous effect's resources @@ -303,8 +303,8 @@ def context( *children: Any, value: _Type = default_value, key: Key | None = None, - ) -> _ContextProvider[_Type]: - return _ContextProvider( + ) -> ContextProvider[_Type]: + return ContextProvider( *children, value=value, key=key, @@ -360,27 +360,6 @@ def use_location() -> Location: return use_connection().location -class _ContextProvider(Generic[_Type]): - def __init__( - self, - *children: Any, - value: _Type, - key: Key | None, - type: Context[_Type], - ) -> None: - self.children = children - self.key = key - self.type = type - self.value = value - - def render(self) -> VdomDict: - HOOK_STACK.current_hook().set_context_provider(self) - return VdomDict(tagName="", children=self.children) - - def __repr__(self) -> str: - return f"ContextProvider({self.type})" - - _ActionType = TypeVar("_ActionType") @@ -515,7 +494,7 @@ def use_memo( # if deps are same length check identity for each item or not all( strictly_equal(current, new) - for current, new in zip(memo.deps, dependencies) + for current, new in zip(memo.deps, dependencies, strict=False) ) ): memo.deps = dependencies @@ -617,7 +596,18 @@ def strictly_equal(x: Any, y: Any) -> bool: getattr(x.__code__, attr) == getattr(y.__code__, attr) for attr in dir(x.__code__) if attr.startswith("co_") - and attr not in {"co_positions", "co_linetable", "co_lines", "co_lnotab"} + and attr + not in { + "co_positions", + "co_linetable", + "co_lines", + "co_lnotab", + "co_branches", + "co_firstlineno", + "co_end_lineno", + "co_col_offset", + "co_end_col_offset", + } ) # Check via the `==` operator if possible diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index 3c9a1bf39..5ed2a204e 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -1,26 +1,26 @@ from __future__ import annotations -import abc from asyncio import ( FIRST_COMPLETED, CancelledError, Queue, Task, create_task, + current_task, get_running_loop, wait, ) from collections import Counter -from collections.abc import Sequence -from contextlib import AsyncExitStack +from collections.abc import Callable +from contextlib import AsyncExitStack, suppress from logging import getLogger from types import TracebackType from typing import ( Any, - Callable, Generic, NamedTuple, NewType, + TypeAlias, TypeVar, cast, ) @@ -28,7 +28,6 @@ from weakref import ref as weakref from anyio import Semaphore -from typing_extensions import TypeAlias from reactpy.config import ( REACTPY_ASYNC_RENDERING, @@ -38,15 +37,16 @@ from reactpy.core._life_cycle_hook import LifeCycleHook from reactpy.core.vdom import validate_vdom_json from reactpy.types import ( - ComponentType, + BaseLayout, + Component, Context, + ContextProvider, Event, EventHandlerDict, Key, LayoutEventMessage, LayoutUpdateMessage, VdomChild, - VdomDict, VdomJson, ) from reactpy.utils import Ref @@ -54,26 +54,11 @@ logger = getLogger(__name__) -class Layout: - """Responsible for "rendering" components. That is, turning them into VDOM.""" - - __slots__: tuple[str, ...] = ( - "_event_handlers", - "_model_states_by_life_cycle_state_id", - "_render_tasks", - "_render_tasks_ready", - "_rendering_queue", - "_root_life_cycle_state_id", - "root", - ) - - if not hasattr(abc.ABC, "__weakref__"): # nocov - __slots__ += ("__weakref__",) - - def __init__(self, root: ComponentType | Context[Any]) -> None: +class Layout(BaseLayout): + def __init__(self, root: Component | Context[Any] | ContextProvider[Any]) -> None: super().__init__() - if not isinstance(root, ComponentType): - msg = f"Expected a ComponentType, not {type(root)!r}." + if not isinstance(root, Component): + msg = f"Expected a ReactPy component, not {type(root)!r}." raise TypeError(msg) self.root = root @@ -81,6 +66,9 @@ async def __aenter__(self) -> Layout: # create attributes here to avoid access before entering context manager self._event_handlers: EventHandlerDict = {} self._render_tasks: set[Task[LayoutUpdateMessage]] = set() + self._render_tasks_by_id: dict[ + _LifeCycleStateId, Task[LayoutUpdateMessage] + ] = {} self._render_tasks_ready: Semaphore = Semaphore(0) self._rendering_queue: _ThreadSafeQueue[_LifeCycleStateId] = _ThreadSafeQueue() root_model_state = _new_root_model_state(self.root, self._schedule_render_task) @@ -98,16 +86,14 @@ async def __aexit__( for t in self._render_tasks: t.cancel() - try: + with suppress(CancelledError): await t - except CancelledError: - pass - await self._unmount_model_states([root_model_state]) # delete attributes here to avoid access after exiting context manager del self._event_handlers del self._rendering_queue + del self._render_tasks_by_id del self._root_life_cycle_state_id del self._model_states_by_life_cycle_state_id @@ -155,20 +141,55 @@ async def _parallel_render(self) -> LayoutUpdateMessage: """Await to fetch the first completed render within our asyncio task group. We use the `asyncio.tasks.wait` API in order to return the first completed task. """ - await self._render_tasks_ready.acquire() - done, _ = await wait(self._render_tasks, return_when=FIRST_COMPLETED) - update_task: Task[LayoutUpdateMessage] = done.pop() - self._render_tasks.remove(update_task) - return update_task.result() + while True: + await self._render_tasks_ready.acquire() + if not self._render_tasks: # nocov + continue + done, _ = await wait(self._render_tasks, return_when=FIRST_COMPLETED) + update_task: Task[LayoutUpdateMessage] = done.pop() + self._render_tasks.discard(update_task) + + for lcs_id, task in list(self._render_tasks_by_id.items()): + if task is update_task: + del self._render_tasks_by_id[lcs_id] + break + + try: + return update_task.result() + except CancelledError: # nocov + continue async def _create_layout_update( self, old_state: _ModelState ) -> LayoutUpdateMessage: - new_state = _copy_component_model_state(old_state) - component = new_state.life_cycle_state.component + component = old_state.life_cycle_state.component + try: + parent: _ModelState | None = old_state.parent + except AttributeError: + parent = None async with AsyncExitStack() as exit_stack: - await self._render_component(exit_stack, old_state, new_state, component) + new_state = await self._render_component( + exit_stack, + old_state, + parent, + old_state.index, + old_state.key, + component, + ) + + if parent is not None: + parent.children_by_key[new_state.key] = new_state + old_parent_model = parent.model.current + old_parent_children = old_parent_model.setdefault("children", []) + parent.model.current = { + **old_parent_model, + "children": [ + *old_parent_children[: new_state.index], + new_state.model.current, + *old_parent_children[new_state.index + 1 :], + ], + } if REACTPY_CHECK_VDOM_SPEC.current: validate_vdom_json(new_state.model.current) @@ -183,14 +204,54 @@ async def _render_component( self, exit_stack: AsyncExitStack, old_state: _ModelState | None, - new_state: _ModelState, - component: ComponentType, - ) -> None: + parent: _ModelState | None, + index: int, + key: Any, + component: Component, + ) -> _ModelState: + if old_state is None: + new_state = _make_component_model_state( + parent, index, key, component, self._schedule_render_task + ) + elif ( + old_state.is_component_state + and old_state.life_cycle_state.component.type != component.type + ): + await self._unmount_model_states([old_state]) + new_state = _make_component_model_state( + parent, index, key, component, self._schedule_render_task + ) + old_state = None + elif not old_state.is_component_state: + await self._unmount_model_states([old_state]) + new_state = _make_component_model_state( + parent, index, key, component, self._schedule_render_task + ) + old_state = None + elif parent is None: + new_state = _copy_component_model_state(old_state) + new_state.life_cycle_state = _update_life_cycle_state( + old_state.life_cycle_state, component + ) + else: + new_state = _update_component_model_state( + old_state, parent, index, component, self._schedule_render_task + ) + life_cycle_state = new_state.life_cycle_state life_cycle_hook = life_cycle_state.hook self._model_states_by_life_cycle_state_id[life_cycle_state.id] = new_state + # If this component is scheduled to render, we can cancel that task since we are + # rendering it now. + if life_cycle_state.id in self._render_tasks_by_id: + task = self._render_tasks_by_id[life_cycle_state.id] + if task is not current_task(): + del self._render_tasks_by_id[life_cycle_state.id] + task.cancel() + self._render_tasks.discard(task) + await life_cycle_hook.affect_component_will_render(component) exit_stack.push_async_callback(life_cycle_hook.affect_layout_did_render) try: @@ -198,8 +259,10 @@ async def _render_component( # wrap the model in a fragment (i.e. tagName="") to ensure components have # a separate node in the model state tree. This could be removed if this # components are given a node in the tree some other way - wrapper_model = VdomDict(tagName="", children=[raw_model]) - await self._render_model(exit_stack, old_state, new_state, wrapper_model) + new_state.model.current = {"tagName": ""} + await self._render_model_children( + exit_stack, old_state, new_state, [raw_model] + ) except Exception as error: logger.exception(f"Failed to render {component}") new_state.model.current = { @@ -211,32 +274,26 @@ async def _render_component( finally: await life_cycle_hook.affect_component_did_render() - try: - parent = new_state.parent - except AttributeError: - pass # only happens for root component - else: - key, index = new_state.key, new_state.index - parent.children_by_key[key] = new_state - # need to add this model to parent's children without mutating parent model - old_parent_model = parent.model.current - old_parent_children = old_parent_model.setdefault("children", []) - parent.model.current = { - **old_parent_model, - "children": [ - *old_parent_children[:index], - new_state.model.current, - *old_parent_children[index + 1 :], - ], - } + return new_state async def _render_model( self, exit_stack: AsyncExitStack, old_state: _ModelState | None, - new_state: _ModelState, + parent: _ModelState, + index: int, + key: Any, raw_model: Any, - ) -> None: + ) -> _ModelState: + if old_state is None: + new_state = _make_element_model_state(parent, index, key) + elif old_state.is_component_state: + await self._unmount_model_states([old_state]) + new_state = _make_element_model_state(parent, index, key) + old_state = None + else: + new_state = _update_element_model_state(old_state, parent, index) + try: new_state.model.current = {"tagName": raw_model["tagName"]} except Exception as e: # nocov @@ -250,6 +307,7 @@ async def _render_model( await self._render_model_children( exit_stack, old_state, new_state, raw_model.get("children", []) ) + return new_state def _render_model_attributes( self, @@ -331,130 +389,48 @@ async def _render_model_children( else: raw_children = [raw_children] - if old_state is None: - if raw_children: - await self._render_model_children_without_old_state( - exit_stack, new_state, raw_children - ) - return None - elif not raw_children: - await self._unmount_model_states(list(old_state.children_by_key.values())) - return None - - children_info = _get_children_info(raw_children) + children_info, new_keys = _get_children_info(raw_children) - new_keys = {k for _, _, k in children_info} - if len(new_keys) != len(children_info): + if new_keys is None: key_counter = Counter(item[2] for item in children_info) duplicate_keys = [key for key, count in key_counter.items() if count > 1] msg = f"Duplicate keys {duplicate_keys} at {new_state.patch_path or '/'!r}" raise ValueError(msg) - old_keys = set(old_state.children_by_key).difference(new_keys) - if old_keys: - await self._unmount_model_states( - [old_state.children_by_key[key] for key in old_keys] - ) + if old_state is not None: + old_keys = set(old_state.children_by_key).difference(new_keys) + if old_keys: + await self._unmount_model_states( + [old_state.children_by_key[key] for key in old_keys] + ) - new_state.model.current["children"] = [] - for index, (child, child_type, key) in enumerate(children_info): - old_child_state = old_state.children_by_key.get(key) - if child_type is _DICT_TYPE: - old_child_state = old_state.children_by_key.get(key) - if old_child_state is None: - new_child_state = _make_element_model_state( - new_state, - index, - key, - ) - elif old_child_state.is_component_state: - await self._unmount_model_states([old_child_state]) - new_child_state = _make_element_model_state( - new_state, - index, - key, - ) - old_child_state = None - else: - new_child_state = _update_element_model_state( - old_child_state, - new_state, - index, - ) - await self._render_model( - exit_stack, old_child_state, new_child_state, child + if raw_children: + new_state.model.current["children"] = [] + for index, (child, child_type, key) in enumerate(children_info): + old_child_state = ( + old_state.children_by_key.get(key) + if old_state is not None + else None ) - new_state.append_child(new_child_state.model.current) - new_state.children_by_key[key] = new_child_state - elif child_type is _COMPONENT_TYPE: - child = cast(ComponentType, child) - old_child_state = old_state.children_by_key.get(key) - if old_child_state is None: - new_child_state = _make_component_model_state( - new_state, - index, - key, - child, - self._schedule_render_task, + if child_type is _DICT_TYPE: + new_child_state = await self._render_model( + exit_stack, old_child_state, new_state, index, key, child ) - elif old_child_state.is_component_state and ( - old_child_state.life_cycle_state.component.type != child.type - ): - await self._unmount_model_states([old_child_state]) - old_child_state = None - new_child_state = _make_component_model_state( - new_state, - index, - key, - child, - self._schedule_render_task, + elif child_type is _COMPONENT_TYPE: + child = cast(Component, child) + new_child_state = await self._render_component( + exit_stack, old_child_state, new_state, index, key, child ) else: - new_child_state = _update_component_model_state( - old_child_state, - new_state, - index, - child, - self._schedule_render_task, - ) - await self._render_component( - exit_stack, old_child_state, new_child_state, child - ) - else: - old_child_state = old_state.children_by_key.get(key) - if old_child_state is not None: - await self._unmount_model_states([old_child_state]) - new_state.append_child(child) + if old_child_state is not None: + await self._unmount_model_states([old_child_state]) + new_child_state = child - async def _render_model_children_without_old_state( - self, - exit_stack: AsyncExitStack, - new_state: _ModelState, - raw_children: list[Any], - ) -> None: - children_info = _get_children_info(raw_children) - - new_keys = {k for _, _, k in children_info} - if len(new_keys) != len(children_info): - key_counter = Counter(k for _, _, k in children_info) - duplicate_keys = [key for key, count in key_counter.items() if count > 1] - msg = f"Duplicate keys {duplicate_keys} at {new_state.patch_path or '/'!r}" - raise ValueError(msg) - - new_state.model.current["children"] = [] - for index, (child, child_type, key) in enumerate(children_info): - if child_type is _DICT_TYPE: - child_state = _make_element_model_state(new_state, index, key) - await self._render_model(exit_stack, None, child_state, child) - new_state.append_child(child_state.model.current) - new_state.children_by_key[key] = child_state - elif child_type is _COMPONENT_TYPE: - child_state = _make_component_model_state( - new_state, index, key, child, self._schedule_render_task - ) - await self._render_component(exit_stack, None, child_state, child) - else: - new_state.append_child(child) + if isinstance(new_child_state, _ModelState): + new_state.append_child(new_child_state.model.current) + new_state.children_by_key[key] = new_child_state + else: + new_state.append_child(new_child_state) async def _unmount_model_states(self, old_states: list[_ModelState]) -> None: to_unmount = old_states[::-1] # unmount in reversed order of rendering @@ -483,7 +459,9 @@ def _schedule_render_task(self, lcs_id: _LifeCycleStateId) -> None: f"{lcs_id!r} - component already unmounted" ) else: - self._render_tasks.add(create_task(self._create_layout_update(model_state))) + task = create_task(self._create_layout_update(model_state)) + self._render_tasks.add(task) + self._render_tasks_by_id[lcs_id] = task self._render_tasks_ready.release() def __repr__(self) -> str: @@ -491,7 +469,7 @@ def __repr__(self) -> str: def _new_root_model_state( - component: ComponentType, schedule_render: Callable[[_LifeCycleStateId], None] + component: Component, schedule_render: Callable[[_LifeCycleStateId], None] ) -> _ModelState: return _ModelState( parent=None, @@ -506,10 +484,10 @@ def _new_root_model_state( def _make_component_model_state( - parent: _ModelState, + parent: _ModelState | None, index: int, key: Any, - component: ComponentType, + component: Component, schedule_render: Callable[[_LifeCycleStateId], None], ) -> _ModelState: return _ModelState( @@ -517,7 +495,7 @@ def _make_component_model_state( index=index, key=key, model=Ref(), - patch_path=f"{parent.patch_path}/children/{index}", + patch_path=f"{parent.patch_path}/children/{index}" if parent else "", children_by_key={}, targets_by_event={}, life_cycle_state=_make_life_cycle_state(component, schedule_render), @@ -547,7 +525,7 @@ def _update_component_model_state( old_model_state: _ModelState, new_parent: _ModelState, new_index: int, - new_component: ComponentType, + new_component: Component, schedule_render: Callable[[_LifeCycleStateId], None], ) -> _ModelState: return _ModelState( @@ -673,7 +651,7 @@ def __repr__(self) -> str: # nocov def _make_life_cycle_state( - component: ComponentType, + component: Component, schedule_render: Callable[[_LifeCycleStateId], None], ) -> _LifeCycleState: life_cycle_state_id = _LifeCycleStateId(uuid4().hex) @@ -686,7 +664,7 @@ def _make_life_cycle_state( def _update_life_cycle_state( old_life_cycle_state: _LifeCycleState, - new_component: ComponentType, + new_component: Component, ) -> _LifeCycleState: return _LifeCycleState( old_life_cycle_state.id, @@ -708,7 +686,7 @@ class _LifeCycleState(NamedTuple): hook: LifeCycleHook """The life cycle hook""" - component: ComponentType + component: Component """The current component instance""" @@ -732,15 +710,20 @@ async def get(self) -> _Type: return value -def _get_children_info(children: list[VdomChild]) -> Sequence[_ChildInfo]: +def _get_children_info( + children: list[VdomChild], +) -> tuple[list[_ChildInfo], set[Key] | None]: infos: list[_ChildInfo] = [] + keys: set[Key] = set() + has_duplicates = False + for index, child in enumerate(children): if child is None: continue elif isinstance(child, dict): child_type = _DICT_TYPE key = child.get("key") - elif isinstance(child, ComponentType): + elif isinstance(child, Component): child_type = _COMPONENT_TYPE key = child.key else: @@ -751,9 +734,13 @@ def _get_children_info(children: list[VdomChild]) -> Sequence[_ChildInfo]: if key is None: key = index + if key in keys: + has_duplicates = True + keys.add(key) + infos.append((child, child_type, key)) - return infos + return infos, None if has_duplicates else keys _ChildInfo: TypeAlias = tuple[Any, "_ElementType", Key] diff --git a/src/reactpy/core/serve.py b/src/reactpy/core/serve.py index 8479b71c9..435cd442c 100644 --- a/src/reactpy/core/serve.py +++ b/src/reactpy/core/serve.py @@ -1,15 +1,15 @@ from __future__ import annotations -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable from logging import getLogger -from typing import Any, Callable +from typing import Any from anyio import create_task_group from anyio.abc import TaskGroup from reactpy.config import REACTPY_DEBUG from reactpy.core._life_cycle_hook import HOOK_STACK -from reactpy.types import LayoutEventMessage, LayoutType, LayoutUpdateMessage +from reactpy.types import BaseLayout, LayoutEventMessage, LayoutUpdateMessage logger = getLogger(__name__) @@ -25,7 +25,7 @@ async def serve_layout( - layout: LayoutType[ + layout: BaseLayout[ LayoutUpdateMessage | dict[str, Any], LayoutEventMessage | dict[str, Any] ], send: SendCoroutine, @@ -39,7 +39,7 @@ async def serve_layout( async def _single_outgoing_loop( - layout: LayoutType[ + layout: BaseLayout[ LayoutUpdateMessage | dict[str, Any], LayoutEventMessage | dict[str, Any] ], send: SendCoroutine, @@ -65,7 +65,7 @@ async def _single_outgoing_loop( async def _single_incoming_loop( task_group: TaskGroup, - layout: LayoutType[ + layout: BaseLayout[ LayoutUpdateMessage | dict[str, Any], LayoutEventMessage | dict[str, Any] ], recv: RecvCoroutine, diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 14db23bf6..5029c1970 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -3,10 +3,9 @@ import json import re -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from typing import ( Any, - Callable, cast, overload, ) @@ -18,11 +17,11 @@ from reactpy.core._f_back import f_module_name from reactpy.core.events import EventHandler, to_event_handler_function from reactpy.types import ( - ComponentType, + BaseEventHandler, + Component, CustomVdomConstructor, EllipsisRepr, EventHandlerDict, - EventHandlerType, ImportSourceDict, InlineJavaScript, InlineJavaScriptDict, @@ -232,13 +231,13 @@ def separate_attributes_handlers_and_inline_javascript( attributes: Mapping[str, Any], ) -> tuple[VdomAttributes, EventHandlerDict, InlineJavaScriptDict]: _attributes: VdomAttributes = {} - _event_handlers: dict[str, EventHandlerType] = {} + _event_handlers: dict[str, BaseEventHandler] = {} _inline_javascript: dict[str, InlineJavaScript] = {} for k, v in attributes.items(): if callable(v): _event_handlers[k] = EventHandler(to_event_handler_function(v)) - elif isinstance(v, EventHandler): + elif isinstance(v, BaseEventHandler): _event_handlers[k] = v elif EVENT_ATTRIBUTE_PATTERN.match(k) and isinstance(v, str): _inline_javascript[k] = InlineJavaScript(v) @@ -276,7 +275,7 @@ def _validate_child_key_integrity(value: Any) -> None: ) else: for child in value: - if isinstance(child, ComponentType) and child.key is None: + if isinstance(child, Component) and child.key is None: warn(f"Key not specified for child in list {child}", UserWarning) elif isinstance(child, Mapping) and "key" not in child: # remove 'children' to reduce log spam diff --git a/src/reactpy/executors/asgi/__init__.py b/src/reactpy/executors/asgi/__init__.py index e43d00cb8..ea363ad14 100644 --- a/src/reactpy/executors/asgi/__init__.py +++ b/src/reactpy/executors/asgi/__init__.py @@ -1,5 +1,10 @@ -from reactpy.executors.asgi.middleware import ReactPyMiddleware -from reactpy.executors.asgi.pyscript import ReactPyCsr -from reactpy.executors.asgi.standalone import ReactPy +try: + from reactpy.executors.asgi.middleware import ReactPyMiddleware + from reactpy.executors.asgi.pyscript import ReactPyCsr + from reactpy.executors.asgi.standalone import ReactPy -__all__ = ["ReactPy", "ReactPyCsr", "ReactPyMiddleware"] + __all__ = ["ReactPy", "ReactPyCsr", "ReactPyMiddleware"] +except ModuleNotFoundError as e: + raise ModuleNotFoundError( + "ASGI executors require the 'reactpy[asgi]' extra to be installed." + ) from e diff --git a/src/reactpy/executors/asgi/middleware.py b/src/reactpy/executors/asgi/middleware.py index 976119c8f..551652fb5 100644 --- a/src/reactpy/executors/asgi/middleware.py +++ b/src/reactpy/executors/asgi/middleware.py @@ -8,13 +8,12 @@ from collections.abc import Iterable from dataclasses import dataclass from pathlib import Path -from typing import Any +from typing import Any, Unpack import orjson from asgi_tools import ResponseText, ResponseWebSocket from asgiref.compatibility import guarantee_single_callable from servestatic import ServeStaticASGI -from typing_extensions import Unpack from reactpy import config from reactpy.core.hooks import ConnectionContext diff --git a/src/reactpy/executors/asgi/pyscript.py b/src/reactpy/executors/asgi/pyscript.py index 80e8b7866..edae4386d 100644 --- a/src/reactpy/executors/asgi/pyscript.py +++ b/src/reactpy/executors/asgi/pyscript.py @@ -4,12 +4,10 @@ import re from collections.abc import Sequence from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from email.utils import formatdate from pathlib import Path -from typing import Any - -from typing_extensions import Unpack +from typing import Any, Unpack from reactpy import html from reactpy.executors.asgi.middleware import ReactPyMiddleware @@ -118,6 +116,4 @@ def render_index_html(self) -> None: "" ) self._etag = f'"{hashlib.md5(self._index_html.encode(), usedforsecurity=False).hexdigest()}"' - self._last_modified = formatdate( - datetime.now(tz=timezone.utc).timestamp(), usegmt=True - ) + self._last_modified = formatdate(datetime.now(tz=UTC).timestamp(), usegmt=True) diff --git a/src/reactpy/executors/asgi/standalone.py b/src/reactpy/executors/asgi/standalone.py index fac9e7ce6..35875f606 100644 --- a/src/reactpy/executors/asgi/standalone.py +++ b/src/reactpy/executors/asgi/standalone.py @@ -2,14 +2,14 @@ import hashlib import re +from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from email.utils import formatdate from logging import getLogger -from typing import Callable, Literal, cast, overload +from typing import Literal, Unpack, cast, overload from asgi_tools import ResponseHTML -from typing_extensions import Unpack from reactpy import html from reactpy.executors.asgi.middleware import ReactPyMiddleware @@ -239,6 +239,4 @@ def render_index_html(self) -> None: "" ) self._etag = f'"{hashlib.md5(self._index_html.encode(), usedforsecurity=False).hexdigest()}"' - self._last_modified = formatdate( - datetime.now(tz=timezone.utc).timestamp(), usegmt=True - ) + self._last_modified = formatdate(datetime.now(tz=UTC).timestamp(), usegmt=True) diff --git a/src/reactpy/executors/asgi/types.py b/src/reactpy/executors/asgi/types.py index 82a87d4f8..0ba3a59a6 100644 --- a/src/reactpy/executors/asgi/types.py +++ b/src/reactpy/executors/asgi/types.py @@ -2,8 +2,8 @@ from __future__ import annotations -from collections.abc import Awaitable, MutableMapping -from typing import Any, Callable, Protocol +from collections.abc import Awaitable, Callable, MutableMapping +from typing import Any, Protocol from asgiref import typing as asgi_types diff --git a/src/reactpy/pyscript/components.py b/src/reactpy/pyscript/components.py index 5709bd7ca..8fb769eca 100644 --- a/src/reactpy/pyscript/components.py +++ b/src/reactpy/pyscript/components.py @@ -5,7 +5,7 @@ from reactpy import component, hooks from reactpy.pyscript.utils import pyscript_component_html -from reactpy.types import ComponentType, Key +from reactpy.types import Component, Key from reactpy.utils import string_to_reactpy if TYPE_CHECKING: @@ -39,10 +39,10 @@ def _pyscript_component( def pyscript_component( *file_paths: str | Path, - initial: str | VdomDict | ComponentType = "", + initial: str | VdomDict | Component = "", root: str = "root", key: Key | None = None, -) -> ComponentType: +) -> Component: """ Args: file_paths: File path to your client-side ReactPy component. If multiple paths are \ diff --git a/src/reactpy/pyscript/utils.py b/src/reactpy/pyscript/utils.py index 857679898..ca5e3fccc 100644 --- a/src/reactpy/pyscript/utils.py +++ b/src/reactpy/pyscript/utils.py @@ -1,4 +1,4 @@ -# ruff: noqa: S603, S607 +# ruff: noqa: S607 from __future__ import annotations import functools @@ -11,6 +11,7 @@ from logging import getLogger from pathlib import Path from typing import TYPE_CHECKING, Any +from urllib import request from uuid import uuid4 import reactpy @@ -110,8 +111,6 @@ def extend_pyscript_config( extra_js: dict[str, str] | str, config: dict[str, Any] | str, ) -> str: - import orjson - # Extends ReactPy's default PyScript config with user provided values. pyscript_config: dict[str, Any] = { "packages": [reactpy_version_string(), "jsonpointer==3.*", "ssl"], @@ -120,10 +119,13 @@ def extend_pyscript_config( f"{REACTPY_PATH_PREFIX.current}static/morphdom/morphdom-esm.js": "morphdom" } }, - "packages_cache": "never", } 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"]): + 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)) @@ -135,7 +137,7 @@ def extend_pyscript_config( pyscript_config.update(json.loads(config)) elif config and isinstance(config, dict): pyscript_config.update(config) - return orjson.dumps(pyscript_config).decode("utf-8") + return json.dumps(pyscript_config) def reactpy_version_string() -> str: # nocov @@ -144,10 +146,10 @@ def reactpy_version_string() -> str: # nocov local_version = reactpy.__version__ # Get a list of all versions via `pip index versions` - result = cached_pip_index_versions("reactpy") + result = get_reactpy_versions() # Check if the command failed - if result.returncode != 0: + if not result: _logger.warning( "Failed to verify what versions of ReactPy exist on PyPi. " "PyScript functionality may not work as expected.", @@ -155,16 +157,8 @@ def reactpy_version_string() -> str: # nocov return f"reactpy=={local_version}" # Have `pip` tell us what versions are available - available_version_symbol = "Available versions: " - latest_version_symbol = "LATEST: " - known_versions: list[str] = [] - latest_version: str = "" - for line in result.stdout.splitlines(): - if line.startswith(available_version_symbol): - known_versions.extend(line[len(available_version_symbol) :].split(", ")) - elif latest_version_symbol in line: - symbol_postion = line.index(latest_version_symbol) - latest_version = line[symbol_postion + len(latest_version_symbol) :].strip() + 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: @@ -173,8 +167,8 @@ def reactpy_version_string() -> str: # nocov # We are now determining an alternative method of installing ReactPy for PyScript if not GITHUB_ACTIONS: _logger.warning( - "Your current version of ReactPy isn't available on PyPi. Since a packaged version " - "of ReactPy is required for PyScript, we are attempting to find an alternative method..." + "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 @@ -189,42 +183,51 @@ def reactpy_version_string() -> str: # nocov check=False, cwd=Path(reactpy.__file__).parent.parent.parent, ) - wheel_glob = glob(str(dist_dir / f"reactpy-{local_version}-*.whl")) + wheel_glob = glob(str(dist_dir / f"reactpy-{local_version}-*.whl")) - # Building a local wheel failed, try our best to give the user any possible version. - if not wheel_glob: - if latest_version: + # 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( - "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." + "PyScript will utilize local wheel '%s'.", + wheel_file.name, ) - 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}" + shutil.copy(wheel_file, new_path) + return f"{REACTPY_PATH_PREFIX.current}modules/{wheel_file.name}" - # Move the local wheel file to the web modules directory, if needed - wheel_file = Path(wheel_glob[0]) - new_path = REACTPY_WEB_MODULES_DIR.current / wheel_file.name - if not new_path.exists(): + # Building a local wheel failed, try our best to give the user any version. + if latest_version: _logger.warning( - "PyScript will utilize local wheel '%s'.", - wheel_file.name, + "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." ) - shutil.copy(wheel_file, new_path) - return f"{REACTPY_PATH_PREFIX.current}modules/{wheel_file.name}" + 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 cached_pip_index_versions(package_name: str) -> subprocess.CompletedProcess[str]: - return subprocess.run( - ["pip", "index", "versions", package_name], - capture_output=True, - text=True, - check=False, - ) +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 diff --git a/src/reactpy/testing/backend.py b/src/reactpy/testing/backend.py index 439be4f0d..8cd1840ea 100644 --- a/src/reactpy/testing/backend.py +++ b/src/reactpy/testing/backend.py @@ -2,9 +2,10 @@ import asyncio import logging +from collections.abc import Callable from contextlib import AsyncExitStack from types import TracebackType -from typing import Any, Callable +from typing import Any from urllib.parse import urlencode, urlunparse import uvicorn diff --git a/src/reactpy/testing/common.py b/src/reactpy/testing/common.py index a0aec3527..276d2d4a9 100644 --- a/src/reactpy/testing/common.py +++ b/src/reactpy/testing/common.py @@ -5,14 +5,12 @@ import os import shutil import time -from collections.abc import Awaitable, Coroutine +from collections.abc import Awaitable, Callable, Coroutine from functools import wraps -from typing import Any, Callable, Generic, TypeVar, cast +from typing import Any, Generic, ParamSpec, TypeVar, cast from uuid import uuid4 from weakref import ref -from typing_extensions import ParamSpec - from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT, REACTPY_WEB_MODULES_DIR from reactpy.core._life_cycle_hook import HOOK_STACK, LifeCycleHook from reactpy.core.events import EventHandler, to_event_handler_function @@ -71,7 +69,7 @@ async def until( break elif (time.time() - started_at) > timeout: # nocov msg = f"Expected {description} after {timeout} seconds - last value was {result!r}" - raise asyncio.TimeoutError(msg) + raise TimeoutError(msg) async def until_is( self, diff --git a/src/reactpy/types.py b/src/reactpy/types.py index b10e67350..b55123cf8 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -1,22 +1,24 @@ from __future__ import annotations -from collections.abc import Awaitable, Mapping, Sequence +import inspect +from collections.abc import Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass from pathlib import Path from types import TracebackType from typing import ( Any, - Callable, Generic, Literal, + NamedTuple, + NotRequired, Protocol, + TypeAlias, + TypedDict, TypeVar, + Unpack, overload, - runtime_checkable, ) -from typing_extensions import NamedTuple, NotRequired, TypeAlias, TypedDict, Unpack - CarrierType = TypeVar("CarrierType") _Type = TypeVar("_Type") @@ -26,54 +28,84 @@ class State(NamedTuple, Generic[_Type]): set_value: Callable[[_Type | Callable[[_Type], _Type]], None] -ComponentConstructor = Callable[..., "ComponentType"] +ComponentConstructor = Callable[..., "Component"] """Simple function returning a new component""" -RootComponentConstructor = Callable[[], "ComponentType"] +RootComponentConstructor = Callable[[], "Component"] """The root component should be constructed by a function accepting no arguments.""" Key: TypeAlias = str | int -@runtime_checkable -class ComponentType(Protocol): - """The expected interface for all component-like objects""" - - key: Key | None - """An identifier which is unique amongst a component's immediate siblings""" +class Component: + """An object for rending component models.""" - type: Any - """The function or class defining the behavior of this component + __slots__ = "__weakref__", "_args", "_func", "_kwargs", "_sig", "key", "type" - This is used to see if two component instances share the same definition. - """ + def __init__( + self, + function: Callable[..., Component | VdomDict | str | None], + key: Any | None, + args: tuple[Any, ...], + kwargs: dict[str, Any], + sig: inspect.Signature, + ) -> None: + self.key = key + self.type = function + self._args = args + self._kwargs = kwargs + self._sig = sig + + def render(self) -> Component | VdomDict | str | None: + return self.type(*self._args, **self._kwargs) - def render(self) -> VdomDict | ComponentType | str | None: - """Render the component's view model.""" + def __repr__(self) -> str: + try: + args = self._sig.bind(*self._args, **self._kwargs).arguments + except TypeError: + return f"{self.type.__name__}(...)" + else: + items = ", ".join(f"{k}={v!r}" for k, v in args.items()) + if items: + return f"{self.type.__name__}({id(self):02x}, {items})" + else: + return f"{self.type.__name__}({id(self):02x})" _Render_co = TypeVar("_Render_co", covariant=True) _Event_contra = TypeVar("_Event_contra", contravariant=True) -@runtime_checkable -class LayoutType(Protocol[_Render_co, _Event_contra]): - """Renders and delivers, updates to views and events to handlers, respectively""" +class BaseLayout(Protocol[_Render_co, _Event_contra]): + """Renders and delivers views, and submits events to handlers.""" + + __slots__: tuple[str, ...] = ( + "__weakref__", + "_event_handlers", + "_model_states_by_life_cycle_state_id", + "_render_tasks", + "_render_tasks_ready", + "_rendering_queue", + "_root_life_cycle_state_id", + "root", + ) async def render( self, - ) -> _Render_co: ... # Render an update to a view + ) -> _Render_co: + """Render an update to a view""" + ... - async def deliver( - self, event: _Event_contra - ) -> None: ... # Relay an event to its respective handler + async def deliver(self, event: _Event_contra) -> None: + """Relay an event to its respective handler""" + ... async def __aenter__( self, - ) -> LayoutType[ - _Render_co, _Event_contra - ]: ... # Prepare the layout for its first render + ) -> BaseLayout[_Render_co, _Event_contra]: + """Prepare the layout for its first render""" + ... async def __aexit__( self, @@ -82,6 +114,7 @@ async def __aexit__( traceback: TracebackType, ) -> bool | None: """Clean up the view after its final render""" + ... class CssStyleTypeDict(TypedDict, total=False): @@ -787,7 +820,7 @@ class VdomTypeDict(TypedDict): tagName: str key: NotRequired[Key | None] - children: NotRequired[Sequence[ComponentType | VdomChild]] + children: NotRequired[Sequence[Component | VdomChild]] attributes: NotRequired[VdomAttributes] eventHandlers: NotRequired[EventHandlerDict] inlineJavaScript: NotRequired[InlineJavaScriptDict] @@ -815,7 +848,7 @@ def __getitem__(self, key: Literal["key"]) -> Key | None: ... @overload def __getitem__( self, key: Literal["children"] - ) -> Sequence[ComponentType | VdomChild]: ... + ) -> Sequence[Component | VdomChild]: ... @overload def __getitem__(self, key: Literal["attributes"]) -> VdomAttributes: ... @overload @@ -833,7 +866,7 @@ def __setitem__(self, key: Literal["tagName"], value: str) -> None: ... def __setitem__(self, key: Literal["key"], value: Key | None) -> None: ... @overload def __setitem__( - self, key: Literal["children"], value: Sequence[ComponentType | VdomChild] + self, key: Literal["children"], value: Sequence[Component | VdomChild] ) -> None: ... @overload def __setitem__( @@ -857,7 +890,7 @@ def __setitem__(self, key: VdomDictKeys, value: Any) -> None: super().__setitem__(key, value) -VdomChild: TypeAlias = ComponentType | VdomDict | str | None | Any +VdomChild: TypeAlias = Component | VdomDict | str | None | Any """A single child element of a :class:`VdomDict`""" VdomChildren: TypeAlias = Sequence[VdomChild] | VdomChild @@ -907,19 +940,26 @@ class EventHandlerFunc(Protocol): async def __call__(self, data: Sequence[Any]) -> None: ... -@runtime_checkable -class EventHandlerType(Protocol): +class BaseEventHandler: """Defines a handler for some event""" + __slots__ = ( + "__weakref__", + "function", + "prevent_default", + "stop_propagation", + "target", + ) + + function: EventHandlerFunc + """A coroutine which can respond to an event and its data""" + prevent_default: bool """Whether to block the event from propagating further up the DOM""" stop_propagation: bool """Stops the default action associate with the event from taking place.""" - function: EventHandlerFunc - """A coroutine which can respond to an event and its data""" - target: str | None """Typically left as ``None`` except when a static target is useful. @@ -932,10 +972,10 @@ class EventHandlerType(Protocol): """ -EventHandlerMapping = Mapping[str, EventHandlerType] +EventHandlerMapping = Mapping[str, BaseEventHandler] """A generic mapping between event names to their handlers""" -EventHandlerDict: TypeAlias = dict[str, EventHandlerType] +EventHandlerDict: TypeAlias = dict[str, BaseEventHandler] """A dict mapping between event names to their handlers""" InlineJavaScriptMapping = Mapping[str, InlineJavaScript] @@ -991,17 +1031,30 @@ def __call__( *children: Any, value: _Type = ..., key: Key | None = ..., - ) -> ContextProviderType[_Type]: ... + ) -> ContextProvider[_Type]: ... -class ContextProviderType(ComponentType, Protocol[_Type]): - """A component which provides a context value to its children""" +class ContextProvider(Component, Generic[_Type]): + def __init__( + self, + *children: Any, + value: _Type, + key: Key | None, + type: Context[_Type], + ) -> None: + self.children = children + self.key = key + self.type = type + self.value = value - type: Context[_Type] - """The context type""" + def render(self) -> VdomDict: + from reactpy.core.hooks import HOOK_STACK - @property - def value(self) -> _Type: ... # Current context value + HOOK_STACK.current_hook().set_context_provider(self) + return VdomDict(tagName="", children=self.children) + + def __repr__(self) -> str: + return f"ContextProvider({self.type})" @dataclass diff --git a/src/reactpy/utils.py b/src/reactpy/utils.py index 315413845..7430a45d2 100644 --- a/src/reactpy/utils.py +++ b/src/reactpy/utils.py @@ -1,17 +1,17 @@ from __future__ import annotations import re -from collections.abc import Iterable +from collections.abc import Callable, Iterable from importlib import import_module from itertools import chain -from typing import Any, Callable, Generic, TypeVar, cast +from typing import Any, Generic, TypeVar, cast from lxml import etree from lxml.html import fromstring from reactpy import html from reactpy.transforms import RequiredTransforms, attributes_to_reactjs -from reactpy.types import ComponentType, VdomDict +from reactpy.types import Component, VdomDict _RefValue = TypeVar("_RefValue") _ModelTransform = Callable[[VdomDict], Any] @@ -63,7 +63,7 @@ def __repr__(self) -> str: return f"{type(self).__name__}({current})" -def reactpy_to_string(root: VdomDict | ComponentType) -> str: +def reactpy_to_string(root: VdomDict | Component) -> str: """Convert a ReactPy component or `reactpy.html` element into an HTML string. Parameters: @@ -186,7 +186,7 @@ def _add_vdom_to_etree(parent: etree._Element, vdom: VdomDict | dict[str, Any]) for c in vdom.get("children", []): if hasattr(c, "render"): - c = component_to_vdom(cast(ComponentType, c)) + c = component_to_vdom(cast(Component, c)) if isinstance(c, dict): _add_vdom_to_etree(element, c) @@ -232,14 +232,14 @@ def _generate_vdom_children( ) -def component_to_vdom(component: ComponentType) -> VdomDict: +def component_to_vdom(component: Component) -> VdomDict: """Convert the first render of a component into a VDOM dictionary""" result = component.render() if isinstance(result, dict): return result if hasattr(result, "render"): - return component_to_vdom(cast(ComponentType, result)) + return component_to_vdom(cast(Component, result)) elif isinstance(result, str): return html.div(result) return html.fragment() diff --git a/src/reactpy/web/module.py b/src/reactpy/web/module.py index d825074a5..e39be636d 100644 --- a/src/reactpy/web/module.py +++ b/src/reactpy/web/module.py @@ -1,6 +1,7 @@ from __future__ import annotations import filecmp +import hashlib import logging import shutil from dataclasses import dataclass @@ -106,9 +107,9 @@ def reactjs_component_from_url( @overload def reactjs_component_from_file( - name: str, file: str | Path, import_names: str, + name: str = "", fallback: Any | None = ..., resolve_imports: bool | None = ..., resolve_imports_depth: int = ..., @@ -120,9 +121,9 @@ def reactjs_component_from_file( @overload def reactjs_component_from_file( - name: str, file: str | Path, import_names: list[str] | tuple[str, ...], + name: str = "", fallback: Any | None = ..., resolve_imports: bool | None = ..., resolve_imports_depth: int = ..., @@ -133,9 +134,9 @@ def reactjs_component_from_file( def reactjs_component_from_file( - name: str, file: str | Path, import_names: str | list[str] | tuple[str, ...], + name: str = "", fallback: Any | None = None, resolve_imports: bool | None = None, resolve_imports_depth: int = 5, @@ -146,14 +147,14 @@ def reactjs_component_from_file( """Import a component from a file. Parameters: - name: - The name of the package 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. + name: + The human-readable name of the ReactJS package fallback: What to temporarily display while the module is being loaded. resolve_imports: @@ -170,6 +171,7 @@ def reactjs_component_from_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_WEB_MODULE_CACHE: module = _FILE_WEB_MODULE_CACHE[key] @@ -189,9 +191,9 @@ def reactjs_component_from_file( @overload def reactjs_component_from_string( - name: str, content: str, import_names: str, + name: str = "", fallback: Any | None = ..., resolve_imports: bool | None = ..., resolve_imports_depth: int = ..., @@ -202,9 +204,9 @@ def reactjs_component_from_string( @overload def reactjs_component_from_string( - name: str, content: str, import_names: list[str] | tuple[str, ...], + name: str = "", fallback: Any | None = ..., resolve_imports: bool | None = ..., resolve_imports_depth: int = ..., @@ -214,9 +216,9 @@ def reactjs_component_from_string( def reactjs_component_from_string( - name: str, content: str, import_names: str | list[str] | tuple[str, ...], + name: str = "", fallback: Any | None = None, resolve_imports: bool | None = None, resolve_imports_depth: int = 5, @@ -226,14 +228,14 @@ def reactjs_component_from_string( """Import a component from a string. Parameters: - name: - The name of the package 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. + name: + The human-readable name of the ReactJS package fallback: What to temporarily display while the module is being loaded. resolve_imports: @@ -248,6 +250,7 @@ def reactjs_component_from_string( 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_WEB_MODULE_CACHE: module = _STRING_WEB_MODULE_CACHE[key] diff --git a/src/reactpy/widgets.py b/src/reactpy/widgets.py index b91f52a9f..ef9c6efaf 100644 --- a/src/reactpy/widgets.py +++ b/src/reactpy/widgets.py @@ -1,8 +1,8 @@ from __future__ import annotations from base64 import b64encode -from collections.abc import Sequence -from typing import Any, Callable, Protocol, TypeVar +from collections.abc import Callable, Sequence +from typing import Any, Protocol, TypeVar import reactpy from reactpy._html import html diff --git a/tests/conftest.py b/tests/conftest.py index 368078e74..1d133fd6b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -101,14 +101,6 @@ async def browser(pytestconfig: Config): ) -@pytest.fixture(scope="session") -def event_loop_policy(): - if os.name == "nt": # nocov - return asyncio.WindowsProactorEventLoopPolicy() - else: - return asyncio.DefaultEventLoopPolicy() - - @pytest.fixture(autouse=True) def clear_web_modules_dir_after_test(): clear_reactpy_web_modules_dir() diff --git a/tests/test_asgi/test_init.py b/tests/test_asgi/test_init.py new file mode 100644 index 000000000..fd6124394 --- /dev/null +++ b/tests/test_asgi/test_init.py @@ -0,0 +1,22 @@ +import sys +from unittest import mock + +import pytest + + +def test_asgi_import_error(): + # Remove the module if it's already loaded so we can trigger the import logic + if "reactpy.executors.asgi" in sys.modules: + del sys.modules["reactpy.executors.asgi"] + + # Mock one of the required modules to be missing (None in sys.modules causes ModuleNotFoundError) + with mock.patch.dict(sys.modules, {"reactpy.executors.asgi.middleware": None}): + with pytest.raises( + ModuleNotFoundError, + match=r"ASGI executors require the 'reactpy\[asgi\]' extra to be installed", + ): + import reactpy.executors.asgi + + # Clean up + if "reactpy.executors.asgi" in sys.modules: + del sys.modules["reactpy.executors.asgi"] diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index 401104473..c3330d882 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -1,4 +1,5 @@ import asyncio +import contextlib import gc import random import re @@ -56,9 +57,9 @@ def MyComponent(): ... def test_layout_expects_abstract_component(): - with pytest.raises(TypeError, match="Expected a ComponentType"): + with pytest.raises(TypeError, match="Expected a ReactPy component"): Layout(None) - with pytest.raises(TypeError, match="Expected a ComponentType"): + with pytest.raises(TypeError, match="Expected a ReactPy component"): Layout(reactpy.html.div()) @@ -1341,3 +1342,178 @@ def effect(): toggle_condition.current() await runner.render() assert effect_run_count.current == 1 + + +async def test_deduplicate_async_renders(): + # Force async rendering + with patch.object(REACTPY_ASYNC_RENDERING, "current", True): + parent_render_count = 0 + child_render_count = 0 + + set_parent_state = Ref(None) + set_child_state = Ref(None) + + @component + def Child(): + nonlocal child_render_count + child_render_count += 1 + state, set_state = use_state(0) + set_child_state.current = set_state + return html.div(f"Child {state}") + + @component + def Parent(): + nonlocal parent_render_count + parent_render_count += 1 + state, set_state = use_state(0) + set_parent_state.current = set_state + return html.div(f"Parent {state}", Child()) + + async with Layout(Parent()) as layout: + await layout.render() # Initial render + + assert parent_render_count == 1 + assert child_render_count == 1 + + # Trigger both updates + set_parent_state.current(1) + set_child_state.current(1) + + # Wait for renders + await layout.render() + + # Wait a bit to ensure tasks are processed/scheduled + await asyncio.sleep(0.1) + + # Check if there are pending tasks + assert len(layout._render_tasks) == 0 + + # Check render counts + # Parent should render twice (Initial + Update) + # Child should render twice (Initial + Parent Update) + # The separate Child update should be deduplicated + assert parent_render_count == 2 + assert child_render_count == 2 + + +async def test_deduplicate_async_renders_nested(): + # Force async rendering + with patch.object(REACTPY_ASYNC_RENDERING, "current", True): + root_render_count = Ref(0) + parent_render_count = Ref(0) + child_render_count = Ref(0) + + set_root_state = Ref(None) + set_parent_state = Ref(None) + set_child_state = Ref(None) + + @component + def Child(): + child_render_count.current += 1 + state, set_state = use_state(0) + set_child_state.current = set_state + return html.div(f"Child {state}") + + @component + def Parent(): + parent_render_count.current += 1 + state, set_state = use_state(0) + set_parent_state.current = set_state + return html.div(f"Parent {state}", Child()) + + @component + def Root(): + root_render_count.current += 1 + state, set_state = use_state(0) + set_root_state.current = set_state + return html.div(f"Root {state}", Parent()) + + async with Layout(Root()) as layout: + await layout.render() + + assert root_render_count.current == 1 + assert parent_render_count.current == 1 + assert child_render_count.current == 1 + + # Scenario 1: Parent then Child + set_parent_state.current(1) + set_child_state.current(1) + + # Drain all renders + # We loop because multiple tasks might be scheduled. + # We use a timeout to prevent infinite loops if logic is broken. + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(layout.render(), timeout=1.0) + # If there are more tasks, keep rendering + while layout._render_tasks: + await asyncio.wait_for(layout.render(), timeout=1.0) + # Parent should render (2) + # Child should render (2) - triggered by Parent + # Child's own update should be deduplicated (cancelled by Parent render) + assert parent_render_count.current == 2 + assert child_render_count.current == 2 + + # Scenario 2: Child then Parent + set_child_state.current(2) + set_parent_state.current(2) + + # Drain all renders + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(layout.render(), timeout=1.0) + while layout._render_tasks: + await asyncio.wait_for(layout.render(), timeout=1.0) + assert parent_render_count.current == 3 + # Child: 1 (init) + 1 (scen1) + 2 (scen2: Child task + Parent task) = 4 + # We expect 4 because Child task runs first and isn't cancelled. + assert child_render_count.current == 4 + + # Scenario 3: Root, Parent, Child all update + set_root_state.current(1) + set_parent_state.current(3) + set_child_state.current(3) + + # Drain all renders + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(layout.render(), timeout=1.0) + while layout._render_tasks: + await asyncio.wait_for(layout.render(), timeout=1.0) + assert root_render_count.current == 2 + assert parent_render_count.current == 4 + # Child: 4 (prev) + 1 (Root->Parent->Child) = 5 + # Root update triggers Parent update. + # Parent update triggers Child update. + # The explicit Parent and Child updates should be cancelled/deduplicated. + # NOTE: In some cases, if the Child update is processed before the Parent update + # (which is triggered by Root), it might not be cancelled in time. + # However, with proper deduplication, we aim for 5. + # If it is 6, it means one of the updates slipped through. + # Given the current implementation, let's assert <= 6 and ideally 5. + assert child_render_count.current <= 6 + + +async def test_deduplicate_async_renders_rapid(): + with patch.object(REACTPY_ASYNC_RENDERING, "current", True): + render_count = Ref(0) + set_state_ref = Ref(None) + + @component + def Comp(): + render_count.current += 1 + state, set_state = use_state(0) + set_state_ref.current = set_state + return html.div(f"Count {state}") + + async with Layout(Comp()) as layout: + await layout.render() + assert render_count.current == 1 + + # Fire 10 updates rapidly + for i in range(10): + set_state_ref.current(i) + + await layout.render() + await asyncio.sleep(0.1) + + # Should not be 1 + 10 = 11. + # Likely 1 + 1 (or maybe 1 + 2 if timing is loose). + assert render_count.current < 5 diff --git a/tests/test_pyscript/test_utils.py b/tests/test_pyscript/test_utils.py index 768067094..4ae89d841 100644 --- a/tests/test_pyscript/test_utils.py +++ b/tests/test_pyscript/test_utils.py @@ -1,4 +1,6 @@ from pathlib import Path +from unittest import mock +from urllib.error import URLError from uuid import uuid4 import orjson @@ -57,3 +59,51 @@ def test_extend_pyscript_config_string_values(): # Check whether `packages_cache` has been overridden assert result["packages_cache"] == "always" + + +def test_get_reactpy_versions_https_fail_http_success(): + utils.get_reactpy_versions.cache_clear() + + mock_response = mock.Mock() + mock_response.status = 200 + + # Mock json.load to return data when called with mock_response + with ( + mock.patch("reactpy.pyscript.utils.request.urlopen") as mock_urlopen, + mock.patch("reactpy.pyscript.utils.json.load") as mock_json_load, + ): + + def side_effect(url, timeout): + if url.startswith("https"): + raise URLError("Fail") + return mock_response + + mock_urlopen.side_effect = side_effect + mock_json_load.return_value = { + "releases": {"1.0.0": []}, + "info": {"version": "1.0.0"}, + } + + versions = utils.get_reactpy_versions() + assert versions == {"versions": ["1.0.0"], "latest": "1.0.0"} + + # Verify both calls were made + assert mock_urlopen.call_count == 2 + assert mock_urlopen.call_args_list[0][0][0].startswith("https") + assert mock_urlopen.call_args_list[1][0][0].startswith("http") + + +def test_get_reactpy_versions_all_fail(): + utils.get_reactpy_versions.cache_clear() + + with ( + mock.patch("reactpy.pyscript.utils.request.urlopen") as mock_urlopen, + mock.patch("reactpy.pyscript.utils._logger") as mock_logger, + ): + mock_urlopen.side_effect = URLError("Fail") + + versions = utils.get_reactpy_versions() + assert versions == {} + + # Verify exception was logged + assert mock_logger.exception.called diff --git a/tests/test_web/js_fixtures/generic-module.js b/tests/test_web/js_fixtures/generic-module.js new file mode 100644 index 000000000..634b057cf --- /dev/null +++ b/tests/test_web/js_fixtures/generic-module.js @@ -0,0 +1,5 @@ +import { h } from "https://unpkg.com/preact?module"; + +export function GenericComponent(props) { + return h("div", { id: props.id }, props.text); +} diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index a0083bc6a..85116007c 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -422,12 +422,12 @@ def App(): def test_reactjs_component_from_string(): reactpy.web.reactjs_component_from_string( - "temp", "old", "Component", resolve_imports=False + "old", "Component", resolve_imports=False, name="temp" ) reactpy.web.module._STRING_WEB_MODULE_CACHE.clear() with assert_reactpy_did_log(r"Existing web module .* will be replaced with"): reactpy.web.reactjs_component_from_string( - "temp", "new", "Component", resolve_imports=False + "new", "Component", resolve_imports=False, name="temp" ) @@ -452,7 +452,7 @@ def ShowSimpleButton(): async def test_reactjs_component_from_file(display: DisplayFixture): SimpleButton = reactpy.web.reactjs_component_from_file( - "simple-button", JS_FIXTURES_DIR / "simple-button.js", "SimpleButton" + JS_FIXTURES_DIR / "simple-button.js", "SimpleButton", name="simple-button" ) is_clicked = reactpy.Ref(False) @@ -493,13 +493,13 @@ def test_reactjs_component_from_file_caching(tmp_path): name = "test-file-module" reactpy.web.module._FILE_WEB_MODULE_CACHE.clear() - reactpy.web.reactjs_component_from_file(name, file, "Component") + reactpy.web.reactjs_component_from_file(file, "Component", name=name) key = next(x for x in reactpy.web.module._FILE_WEB_MODULE_CACHE.keys() if name in x) module1 = reactpy.web.module._FILE_WEB_MODULE_CACHE[key] assert module1 initial_length = len(reactpy.web.module._FILE_WEB_MODULE_CACHE) - reactpy.web.reactjs_component_from_file(name, file, "Component") + reactpy.web.reactjs_component_from_file(file, "Component", name=name) assert len(reactpy.web.module._FILE_WEB_MODULE_CACHE) == initial_length @@ -508,7 +508,7 @@ def test_reactjs_component_from_string_caching(): content = "export function Component() {}" reactpy.web.module._STRING_WEB_MODULE_CACHE.clear() - reactpy.web.reactjs_component_from_string(name, content, "Component") + reactpy.web.reactjs_component_from_string(content, "Component", name=name) key = next( x for x in reactpy.web.module._STRING_WEB_MODULE_CACHE.keys() if name in x ) @@ -516,5 +516,34 @@ def test_reactjs_component_from_string_caching(): assert module1 initial_length = len(reactpy.web.module._STRING_WEB_MODULE_CACHE) - reactpy.web.reactjs_component_from_string(name, content, "Component") + reactpy.web.reactjs_component_from_string(content, "Component", name=name) assert len(reactpy.web.module._STRING_WEB_MODULE_CACHE) == initial_length + + +def test_reactjs_component_from_string_with_no_name(): + content = "export function Component() {}" + reactpy.web.module._STRING_WEB_MODULE_CACHE.clear() + + reactpy.web.reactjs_component_from_string(content, "Component") + initial_length = len(reactpy.web.module._STRING_WEB_MODULE_CACHE) + + reactpy.web.reactjs_component_from_string(content, "Component") + assert len(reactpy.web.module._STRING_WEB_MODULE_CACHE) == initial_length + + +async def test_module_without_bind(display: DisplayFixture): + GenericComponent = reactpy.web.module._vdom_from_web_module( + reactpy.web.module._module_from_file( + "generic-module", JS_FIXTURES_DIR / "generic-module.js" + ), + "GenericComponent", + ) + + await display.show( + lambda: GenericComponent({"id": "my-generic-component", "text": "Hello World"}) + ) + + element = await display.page.wait_for_selector( + "#my-generic-component", state="attached" + ) + assert await element.inner_text() == "Hello World"