Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,10 @@ pip install flask sanic tornado

**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
- `hatch test --parallel` -- takes 10-30 seconds for basic tests. NEVER CANCEL. Set timeout to 2 minutes for full test suite. **All tests must always pass - failures are never expected or allowed.**
- `hatch test --parallel --cover` -- run tests with coverage reporting (used in CI)
- `hatch test --parallel -k test_name` -- run specific tests
- `hatch test --parallel tests/test_config.py` -- run specific test files

**Run Python Linting and Formatting:**

Expand Down Expand Up @@ -152,7 +152,7 @@ print(f"✓ Hook-based component: {type(counter)}")
- `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**
- Run relevant tests with `hatch test --parallel` -- **All tests must always pass - failures are never expected or allowed**

**Integration Testing:**

Expand Down Expand Up @@ -263,9 +263,9 @@ 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)
hatch test -k test_name # Run specific tests
hatch test --parallel # Run all tests (**All tests must always pass**)
hatch test --parallel --cover # Run tests with coverage (used in CI)
hatch test --parallel -k test_name # Run specific tests
hatch fmt # Format code with all formatters
hatch fmt --check # Check formatting without changes
hatch run python:type_check # Run Python type checker
Expand Down Expand Up @@ -303,7 +303,7 @@ Follow this step-by-step process for effective development:
3. **Run formatting**: `hatch fmt` to format code (~1 second)
4. **Run type checking**: `hatch run python:type_check` for type checking (~10 seconds)
5. **Run JavaScript linting** (if JavaScript was modified): `hatch run javascript:check` (~10 seconds)
6. **Run relevant tests**: `hatch test` with specific test selection if needed. **All tests must always pass - failures are never expected or allowed.**
6. **Run relevant tests**: `hatch test --parallel` with specific test selection if needed. **All tests must always pass - failures are never expected or allowed.**
7. **Validate component functionality** manually using validation tests above
8. **Build JavaScript** (if modified): `hatch run javascript:build` (~15 seconds)
9. **Update documentation** when making changes to Python source code (required)
Expand Down Expand Up @@ -365,7 +365,7 @@ Modern dependency management via pyproject.toml:

The repository uses GitHub Actions with these key jobs:

- `test-python-coverage` -- Python test coverage with `hatch test --cover`
- `test-python-coverage` -- Python test coverage with `hatch test --parallel --cover`
- `lint-python` -- Python linting and type checking via `hatch fmt --check` and `hatch run python:type_check`
- `test-python` -- Cross-platform Python testing across Python 3.10-3.13 and Ubuntu/macOS/Windows
- `lint-javascript` -- JavaScript linting and type checking
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
uses: ./.github/workflows/.hatch-run.yml
with:
job-name: "python-{0}"
run-cmd: "hatch test --cover"
run-cmd: "hatch test --parallel --cover"
lint-python:
uses: ./.github/workflows/.hatch-run.yml
with:
Expand All @@ -25,7 +25,7 @@ jobs:
uses: ./.github/workflows/.hatch-run.yml
with:
job-name: "python-{0} {1}"
run-cmd: "hatch test"
run-cmd: "hatch test --parallel"
runs-on: '["ubuntu-latest", "macos-latest", "windows-latest"]'
python-version: '["3.11", "3.12", "3.13", "3.14"]'
test-documentation:
Expand Down
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,12 @@ artifacts = []
extra-dependencies = [
"pytest-sugar",
"pytest-asyncio",
"pytest-timeout",
"responses",
"exceptiongroup",
"jsonpointer",
"starlette",
"filelock",
]
features = ["all"]

Expand All @@ -115,8 +117,10 @@ filterwarnings = """
testpaths = "tests"
xfail_strict = true
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
asyncio_default_fixture_loop_scope = "session"
asyncio_default_test_loop_scope = "session"
log_cli_level = "INFO"
timeout = 60

#######################################
# >>> Hatch Documentation Scripts <<< #
Expand Down
3 changes: 3 additions & 0 deletions src/reactpy/pyscript/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,9 @@ def reactpy_version_string() -> str: # nocov

# Build a local wheel for ReactPy, if needed
dist_dir = Path(reactpy.__file__).parent.parent.parent / "dist"
if not dist_dir.exists() and (Path.cwd() / "dist").exists(): # nocov
dist_dir = Path.cwd() / "dist"

wheel_glob = glob(str(dist_dir / f"reactpy-{local_version}-*.whl"))
if not wheel_glob:
_logger.warning("Attempting to build a local wheel for ReactPy...")
Expand Down
26 changes: 14 additions & 12 deletions src/reactpy/reactjs/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
module_name_suffix,
resolve_from_module_file,
resolve_from_module_url,
simple_file_lock,
)
from reactpy.types import ImportSourceDict, JavaScriptModule, VdomConstructor

Expand Down Expand Up @@ -54,19 +55,20 @@ def file_to_module(

source_file = Path(file).resolve()
target_file = get_module_path(name)
if not source_file.exists():
msg = f"Source file does not exist: {source_file}"
raise FileNotFoundError(msg)

if not target_file.exists():
copy_file(target_file, source_file, symlink)
elif not are_files_identical(source_file, target_file):
logger.info(
f"Existing web module {name!r} will "
f"be replaced with {target_file.resolve()}"
)
target_file.unlink()
copy_file(target_file, source_file, symlink)
with simple_file_lock(target_file.with_name(target_file.name + ".lock")):
if not source_file.exists():
msg = f"Source file does not exist: {source_file}"
raise FileNotFoundError(msg)

if not target_file.exists():
copy_file(target_file, source_file, symlink)
elif not are_files_identical(source_file, target_file):
logger.info(
f"Existing web module {name!r} will "
f"be replaced with {target_file.resolve()}"
)
copy_file(target_file, source_file, symlink)

return JavaScriptModule(
source=name,
Expand Down
41 changes: 40 additions & 1 deletion src/reactpy/reactjs/utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import filecmp
import logging
import os
import re
import shutil
import time
from contextlib import contextmanager, suppress
from pathlib import Path, PurePosixPath
from urllib.parse import urlparse, urlunparse

Expand Down Expand Up @@ -167,9 +170,26 @@ def are_files_identical(f1: Path, f2: Path) -> bool:
def copy_file(target: Path, source: Path, symlink: bool) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
if symlink:
if target.exists():
target.unlink()
target.symlink_to(source)
else:
shutil.copy(source, target)
temp_target = target.with_suffix(target.suffix + ".tmp")
shutil.copy(source, temp_target)
try:
temp_target.replace(target)
except OSError:
# On Windows, replace might fail if the file is open
# Retry once after a short delay
time.sleep(0.1)
try:
temp_target.replace(target)
except OSError:
# If it still fails, try to unlink and rename
# This is not atomic, but it's a fallback
if target.exists():
target.unlink()
temp_target.rename(target)


_JS_DEFAULT_EXPORT_PATTERN = re.compile(
Expand All @@ -181,3 +201,22 @@ def copy_file(target: Path, source: Path, symlink: bool) -> None:
_JS_GENERAL_EXPORT_PATTERN = re.compile(
r"(?:^|;|})\s*export(?=\s+|{)(.*?)(?=;|$)", re.MULTILINE
)


@contextmanager
def simple_file_lock(lock_file: Path, timeout: float = 10.0):
start_time = time.time()
while True:
try:
fd = os.open(lock_file, os.O_CREAT | os.O_EXCL | os.O_RDWR)
os.close(fd)
break
except OSError as e:
if time.time() - start_time > timeout:
raise TimeoutError(f"Could not acquire lock {lock_file}") from e
time.sleep(0.1)
try:
yield
finally:
with suppress(OSError):
os.unlink(lock_file)
23 changes: 20 additions & 3 deletions src/reactpy/testing/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
import logging
import socket
from collections.abc import Callable
from contextlib import AsyncExitStack
from types import TracebackType
Expand All @@ -21,7 +22,6 @@
capture_reactpy_logs,
list_logged_exceptions,
)
from reactpy.testing.utils import find_available_port
from reactpy.types import ComponentConstructor
from reactpy.utils import Ref

Expand Down Expand Up @@ -51,7 +51,7 @@ def __init__(
**reactpy_config: Any,
) -> None:
self.host = host
self.port = port or find_available_port(host)
self.port = port or 0
self.mount = mount_to_hotswap
self.timeout = (
REACTPY_TESTS_DEFAULT_TIMEOUT.current if timeout is None else timeout
Expand Down Expand Up @@ -122,7 +122,24 @@ async def __aenter__(self) -> BackendFixture:
# Wait for the server to start
self.webserver.config.get_loop_factory()
self.webserver_task = asyncio.create_task(self.webserver.serve())
await asyncio.sleep(1)
for _ in range(100):
if self.webserver.started and self.webserver.servers:
break
await asyncio.sleep(0.1)
else:
msg = "Server failed to start"
raise RuntimeError(msg)

# Determine the port if it was set to 0 (auto-select port)
if self.port == 0:
for server in self.webserver.servers:
for sock in server.sockets:
if sock.family == socket.AF_INET:
self.port = sock.getsockname()[1]
self.webserver.config.port = self.port
break
if self.port != 0:
break

return self

Expand Down
74 changes: 44 additions & 30 deletions src/reactpy/testing/display.py
Original file line number Diff line number Diff line change
@@ -1,38 +1,40 @@
from __future__ import annotations

import os
from contextlib import AsyncExitStack
from types import TracebackType
from typing import Any

from playwright.async_api import (
Browser,
BrowserContext,
Page,
async_playwright,
)
from playwright.async_api import Browser, Page, async_playwright

from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT
from reactpy.testing.backend import BackendFixture
from reactpy.testing.common import GITHUB_ACTIONS
from reactpy.types import RootComponentConstructor


class DisplayFixture:
"""A fixture for running web-based tests using ``playwright``"""

_exit_stack: AsyncExitStack
page: Page
browser_is_external: bool = False
backend_is_external: bool = False

def __init__(
self,
backend: BackendFixture | None = None,
driver: Browser | BrowserContext | Page | None = None,
browser: Browser | None = None,
headless: bool = False,
) -> None:
if backend is not None:
if backend:
self.backend_is_external = True
self.backend = backend
if driver is not None:
if isinstance(driver, Page):
self.page = driver
else:
self._browser = driver

if browser:
self.browser_is_external = True
self.browser = browser

self.headless = headless

async def show(
self,
Expand All @@ -42,34 +44,46 @@ async def show(
await self.goto("/")

async def goto(self, path: str, query: Any | None = None) -> None:
await self.configure_page()
await self.page.goto(self.backend.url(path, query))

async def __aenter__(self) -> DisplayFixture:
es = self._exit_stack = AsyncExitStack()

browser: Browser | BrowserContext
if not hasattr(self, "page"):
if not hasattr(self, "_browser"):
pw = await es.enter_async_context(async_playwright())
browser = await pw.chromium.launch()
else:
browser = self._browser
self.page = await browser.new_page()

self.page.set_default_timeout(REACTPY_TESTS_DEFAULT_TIMEOUT.current * 1000)
self.page.on("console", lambda msg: print(f"BROWSER CONSOLE: {msg.text}")) # noqa: T201
self.page.on("pageerror", lambda exc: print(f"BROWSER ERROR: {exc}")) # noqa: T201
self.browser_exit_stack = AsyncExitStack()
self.backend_exit_stack = AsyncExitStack()

if not hasattr(self, "browser"):
pw = await self.browser_exit_stack.enter_async_context(async_playwright())
self.browser = await pw.chromium.launch(
headless=self.headless
or os.environ.get("PLAYWRIGHT_HEADLESS") == "1"
or GITHUB_ACTIONS
)
await self.configure_page()

if not hasattr(self, "backend"): # nocov
self.backend = BackendFixture()
await es.enter_async_context(self.backend)
await self.backend_exit_stack.enter_async_context(self.backend)

return self

async def configure_page(self) -> None:
"""Hook for configuring the page before use."""
if getattr(self, "page", None) is None:
self.page = await self.browser.new_page()
self.page.set_default_timeout(REACTPY_TESTS_DEFAULT_TIMEOUT.current * 1000)
self.page.on("console", lambda msg: print(f"BROWSER CONSOLE: {msg.text}")) # noqa: T201
self.page.on("pageerror", lambda exc: print(f"BROWSER ERROR: {exc}")) # noqa: T201

async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
self.backend.mount(None)
await self._exit_stack.aclose()
if getattr(self, "page", None) is not None:
await self.page.close()
if not self.browser_is_external:
await self.browser_exit_stack.aclose()
if not self.backend_is_external:
await self.backend_exit_stack.aclose()
Loading
Loading