Skip to content
Merged
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
11 changes: 11 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,11 @@ R has no e2e suite, so no `(e2e)` leaf covers R (issue #194).
never a #184 parity question
- `[py]` accepted types are whatever `Jsonifiable` admits: `dict`, `list`,
`tuple`, `str`, `int`, `float`, `bool`, `None`
- a `dict` return does not **type-check** against `Jsonifiable`, whose
`dict` / `list` arms are invariant in their element types — upstream
py-shiny#2497, suppressed per call site with a
`# pyright: ignore[reportArgumentType]` naming the issue
- runtime behavior is unaffected; it is an annotation bug only
- `[py]` a `str` return is sent as a JSON string, not a text node
- `[py]` `auto_output_ui()` returns `None`, inherited from `Renderer` — there
is no placeholder element to emit
Expand Down Expand Up @@ -859,6 +864,12 @@ the shinyreact bundle dependency and the `#shinyreact-config` tag — except
- it works as `shiny.App(ui=...)`, but only `ReactApp` mounts the
document's directory — under plain `shiny.App` the sibling `ui.js` is
not served
- the declared return type is py-shiny's `PageHtmlDocument`, which is
what `App(ui=)` accepts; its `HTMLTextDocument` base is **not**
accepted, so declaring the base would make that documented path a type
error
- `PageHtmlDocument` is imported from `shiny.ui._page`: py-shiny exports
`page_html()` but not its class
- `shiny.ui.page_html()` arrives with py-shiny#2475, consumed as a git
dependency on py-shiny `main` until it releases
- `[r]` used directly as `shinyApp(ui = page_react_html())`, implemented by
Expand Down
12 changes: 9 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -203,16 +203,22 @@ py-check-types: ## [py] Run python type checks
@echo "📝 Checking types with pyright"
uv run pyright

# Both targets cover `examples/` as well as `pkg-py/`: the example apps and
# their tests are shipped, read as documentation, and run by the default pytest
# invocation, so they are held to the same bar as the package.
PATHS_PY = pkg-py examples

.PHONY: py-check-format
py-check-format: ## [py] Check python formatting
@echo ""
@echo "📐 Checking format with ruff"
uv run ruff check pkg-py --config pyproject.toml
uv run ruff check $(PATHS_PY) --config pyproject.toml
uv run ruff format --check $(PATHS_PY) --config pyproject.toml

.PHONY: py-format
py-format: ## [py] Format python code
uv run ruff check --fix pkg-py --config pyproject.toml
uv run ruff format pkg-py --config pyproject.toml
uv run ruff check --fix $(PATHS_PY) --config pyproject.toml
uv run ruff format $(PATHS_PY) --config pyproject.toml

.PHONY: py-install-e2e
py-install-e2e: ## [py] Install Playwright browsers for e2e tests
Expand Down
4 changes: 3 additions & 1 deletion examples/01-hello/app-core.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@


def server(input: Inputs, output: Outputs, session: Session):
@reactive_output
# py-shiny#2497: `Jsonifiable`'s `dict`/`list` arms are invariant, so a
# `dict` return is not assignable to it. Drop the ignore when that lands.
@reactive_output # pyright: ignore[reportArgumentType]
def dist_data():
return histogram(waiting, input.bins())

Expand Down
4 changes: 3 additions & 1 deletion examples/01-hello/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
set_react_page()


@reactive_output
# py-shiny#2497: `Jsonifiable`'s `dict`/`list` arms are invariant, so a
# `dict` return is not assignable to it. Drop the ignore when that lands.
@reactive_output # pyright: ignore[reportArgumentType]
def dist_data():
return histogram(waiting, input.bins())

Expand Down
4 changes: 3 additions & 1 deletion examples/02-columns/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ def _handle_move():
columns.set(data)


@reactive_output
# py-shiny#2497: `Jsonifiable`'s `dict`/`list` arms are invariant, so a
# `dict` return is not assignable to it. Drop the ignore when that lands.
@reactive_output # pyright: ignore[reportArgumentType]
def column_data():
return columns()
4 changes: 3 additions & 1 deletion examples/03-columns-shadcn/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ def _handle_move():
data[to_col].append(item)
columns.set(data)

@reactive_output
# py-shiny#2497: `Jsonifiable`'s `dict`/`list` arms are invariant, so a
# `dict` return is not assignable to it. Drop the ignore when that lands.
@reactive_output # pyright: ignore[reportArgumentType]
def column_data():
return columns()

Expand Down
4 changes: 3 additions & 1 deletion examples/04-shadcn/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@

@reactive_output
def scatter_data():
return sample_data[["age", "score"]].to_dict(orient="list")
# `.loc[:, [...]]`, not `[[...]]`: pandas types the latter as
# `Series | Unknown`, and `Series.to_dict()` takes no `orient=`.
return sample_data.loc[:, ["age", "score"]].to_dict(orient="list")


@reactive_output
Expand Down
4 changes: 3 additions & 1 deletion examples/11-npm-local/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ def histogram(values: list[float], bins: int) -> dict[str, list[float] | list[in


def server(input: Inputs, output: Outputs, session: Session):
@reactive_output
# py-shiny#2497: `Jsonifiable`'s `dict`/`list` arms are invariant, so a
# `dict` return is not assignable to it. Drop the ignore when that lands.
@reactive_output # pyright: ignore[reportArgumentType]
def dist_data():
return histogram(waiting, input.bins())

Expand Down
14 changes: 11 additions & 3 deletions pkg-py/src/shinyreact/_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, cast

from htmltools import HTML, HTMLDependency, HTMLTextDocument, Tag, TagChild, TagList
from htmltools import HTML, HTMLDependency, Tag, TagChild, TagList
from shiny.express.ui import page_opts
from shiny.render.renderer import Renderer
from shiny.session import get_current_session
Expand All @@ -19,6 +19,14 @@
# Private, but it is the only name for HTMLDependency's stylesheet entry.
from htmltools._core import ScriptItem, StylesheetItem

# The class `page_html()` returns. `App(ui=)` accepts it but not its
# `HTMLTextDocument` base, so declaring the base as our return type would
# make the documented `App(page_react_html(...), server)` fail to
# type-check. py-shiny exports the function but not the class -- hence the
# private import, kept type-only so a rename upstream is a pyright error,
# not an ImportError at app startup.
from shiny.ui._page import PageHtmlDocument


def page_bare(
*args: TagChild,
Expand Down Expand Up @@ -437,7 +445,7 @@ def page_react_html(
*,
extra_deps: list[HTMLDependency] | None = None,
shinyreact_js: ShinyreactJs = "server",
) -> HTMLTextDocument:
) -> PageHtmlDocument:
"""Serve a React ``index.html`` document (the ui.tsx pattern, Core API).

Reads a complete HTML document — the kind a Vite build emits — and injects
Expand Down Expand Up @@ -507,7 +515,7 @@ def page_react_html(
*(extra_deps or []),
],
)
# Tagged, not subclassed: py-shiny exports page_html() but not its class.
# Tagged, not subclassed: py-shiny does not export the class publicly.
# ReactApp reads this to mount the document's directory at "/".
setattr(doc, SRC_DIR_ATTR, index_path.parent)
return doc
Expand Down
4 changes: 1 addition & 3 deletions pkg-py/tests/playwright/test_output_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,6 @@ def test_empty_message_error_is_not_logged_to_the_console(

# A real error does log, so the assertion above has teeth.
page.locator("[data-test=input]").fill("0")
expect(page.locator("[data-test=error]")).to_have_text(
"invalid number of 'breaks'"
)
expect(page.locator("[data-test=error]")).to_have_text("invalid number of 'breaks'")
expect(page.locator("[data-test=status]")).to_have_text("error")
assert [m for m in logged if "Error for answer:" in m] != []
5 changes: 4 additions & 1 deletion pkg-py/tests/test_bookmark_restore.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
import re
from pathlib import Path
from typing import Mapping

import pytest
from htmltools import HTMLDependency, TagList
Expand Down Expand Up @@ -70,7 +71,9 @@ def _extract_restore_payload(head_html: str) -> object:
return config["restore"]


def _config_html(values: dict[str, object]) -> str:
# `Mapping`, not `dict`: `dict` is invariant in its value type, so a caller's
# `dict[str, str]` literal would not be assignable.
def _config_html(values: Mapping[str, object]) -> str:
ctx = RestoreContext()
ctx.input = RestoreInputSet(dict(values))
with restore_context_cm(ctx):
Expand Down
54 changes: 36 additions & 18 deletions pkg-py/tests/test_dep.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,38 @@
import warnings
from pathlib import Path
from typing import cast

import pytest
import shinyreact._dep as _dep_mod
from htmltools import HTMLDependency
from htmltools._core import HTMLDependencySource, ScriptItem, StylesheetItem
from shinyreact._dep import _SHINYREACT_JS_PATH, _dep
from shinyreact._page import page_react_dep


# `HTMLDependency` normalizes `script=` / `stylesheet=` to a list and `source=`
# to one of two TypedDicts, but the attributes stay declared as the wider
# argument types. These narrow once here so the assertions below read as
# assertions rather than as type gymnastics.
def dep_source(dep: HTMLDependency) -> HTMLDependencySource:
source = dep.source
assert source is not None
assert "subdir" in source, "a local dep, not an href one"
return cast(HTMLDependencySource, source)


def first_script(dep: HTMLDependency) -> ScriptItem:
script = dep.script
assert script is not None
return script[0] if isinstance(script, list) else script


def first_stylesheet(dep: HTMLDependency) -> StylesheetItem:
stylesheet = dep.stylesheet
assert stylesheet is not None
return stylesheet[0] if isinstance(stylesheet, list) else stylesheet


def test_dep_version_tracks_bundle_mtime():
"""The shinyreact HTMLDependency version reflects the bundle's mtime.

Expand All @@ -21,11 +46,7 @@ def test_dep_version_tracks_bundle_mtime():


def test_dep_script_has_defer():
script = _dep().script
assert script is not None
if isinstance(script, list):
script = script[0]
assert script.get("defer") == ""
assert first_script(_dep()).get("defer") == ""


# --- page_react_dep tests ---
Expand Down Expand Up @@ -57,7 +78,7 @@ def test_page_react_dep_returns_htmldependency(tmp_path):

dep = _run_page_react_dep(tmp_path)
assert isinstance(dep, HTMLDependency)
assert dep.source["subdir"] == str(tmp_path)
assert dep_source(dep)["subdir"] == str(tmp_path)
assert dep.name == tmp_path.name


Expand Down Expand Up @@ -133,7 +154,7 @@ def test_page_react_dep_attaches_script_when_js_present(tmp_path):
(tmp_path / "ui.js").write_text("// app")

dep = page_react_dep(src_dir=tmp_path)
script = dep.script if isinstance(dep.script, dict) else dep.script[0]
script = first_script(dep)
assert script["src"] == "ui.js"
assert script.get("type") == "module"

Expand All @@ -143,10 +164,8 @@ def test_page_react_dep_custom_filenames(tmp_path):
(tmp_path / "app.css").write_text("/* styles */")

dep = _run_page_react_dep(tmp_path, js_file="app.js", css_file="app.css")
script = dep.script if isinstance(dep.script, dict) else dep.script[0]
stylesheet = (
dep.stylesheet if isinstance(dep.stylesheet, dict) else dep.stylesheet[0]
)
script = first_script(dep)
stylesheet = first_stylesheet(dep)
assert script["src"] == "app.js"
assert stylesheet["href"] == "app.css"

Expand All @@ -156,7 +175,7 @@ def test_page_react_dep_script_type_module(tmp_path):
(tmp_path / "ui.css").write_text("/* styles */")

dep = _run_page_react_dep(tmp_path)
script = dep.script if isinstance(dep.script, dict) else dep.script[0]
script = first_script(dep)
assert script.get("type") == "module"


Expand All @@ -169,8 +188,7 @@ def test_page_react_dep_explicit_src_dir_and_name(tmp_path):
(tmp_path / "ui.js").write_text("// app")

dep = page_react_dep(src_dir=tmp_path, name="my-app")
assert dep.source is not None
assert dep.source["subdir"] == str(tmp_path)
assert dep_source(dep)["subdir"] == str(tmp_path)
assert dep.name == "my-app"
assert str(dep.version) == str(int((tmp_path / "ui.js").stat().st_mtime))

Expand All @@ -188,17 +206,17 @@ def test_page_react_dep_attaches_stylesheet_when_css_present(tmp_path):
(tmp_path / "ui.js").write_text("// app")
(tmp_path / "ui.css").write_text("/* styles */")

stylesheet = page_react_dep(src_dir=tmp_path).stylesheet
assert stylesheet is not None
entry = stylesheet if isinstance(stylesheet, dict) else stylesheet[0]
entry = first_stylesheet(page_react_dep(src_dir=tmp_path))
assert entry["href"] == "ui.css"


def test_dep_stylesheet_attached_unconditionally() -> None:
# No existence check on the CSS, unlike page_react_dep()'s. Mirrors R's
# "shinyreact_dep() attaches the stylesheet unconditionally".
dep = _dep()
assert [s["href"] for s in dep.stylesheet] == ["shinyreact.css"]
assert [s["href"] for s in cast("list[StylesheetItem]", dep.stylesheet)] == [
"shinyreact.css"
]


def test_dep_version_falls_back_when_bundle_missing(
Expand Down
13 changes: 9 additions & 4 deletions pkg-py/tests/test_dep_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@
from __future__ import annotations

import asyncio
from typing import Any, Callable
from typing import Any, Callable, cast

from htmltools import HTMLDependency, TagChild, TagList, div
from shiny.express._stub_session import ExpressStubSession
from shiny.input_handler import input_handlers
from shiny.module import ResolvedId
from shiny.render.renderer import Renderer
from shiny.session import session_context
from shinyreact._dep_discovery import install_dep_discovery
Expand Down Expand Up @@ -119,19 +120,23 @@ def test_install_is_idempotent_per_session() -> None:
assert len(session.messages) == 1


# The input id a handler is given; none of these handlers look at it.
ANY_NAME = cast(ResolvedId, "x")


def test_install_no_ops_without_a_real_session() -> None:
assert install_dep_discovery(None) is False
assert install_dep_discovery(object()) is False # type: ignore[arg-type]


def test_init_handler_installs_discovery() -> None:
session = _FakeSession()
assert input_handlers["shinyreact.init"](1, "x", session) == 1
assert input_handlers["shinyreact.init"](1, ANY_NAME, session) == 1
assert session.flush_callbacks != []


def test_value_handlers_do_not_install_discovery() -> None:
session = _FakeSession()
input_handlers["shinyreact.default"]([], "x", session)
input_handlers["shinyreact.asis"](1, "x", session)
input_handlers["shinyreact.default"]([], ANY_NAME, session)
input_handlers["shinyreact.asis"](1, ANY_NAME, session)
assert session.flush_callbacks == []
Loading