Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

* In Express, `@output_args()` now accepts every argument of the output's UI function, with matching types. `@render.ui` now takes `inline`, `container`, `fill`, `fillable`, and HTML attributes, instead of raising a `TypeError`. `@render.text` now takes `container`. `@render.plot` and `@render.image` now declare `inline`, `click`, `dblclick`, `hover`, `brush`, and `fill` explicitly (and `width` and `height` for `@render.image`), instead of accepting any keyword. `@render.download_button()` and `@render.download_link()` now take `label`. (#2539)

* On Python 3.15+, `import shiny` defers `narwhals`, `shinychat`, and the CLI/server stack (uvicorn, click, questionary, prompt_toolkit, watchfiles) until first use, via PEP 810 `__lazy_modules__` declarations. In a warm-cache benchmark on CPython 3.15.0, `import shiny` drops from ~310 ms to ~140 ms. The declarations are ignored on Python 3.11-3.14, where behavior is unchanged. (#2564)

### Bug fixes

* `Jsonifiable` now uses `Sequence` and `Mapping` for its container arms instead of `List` and `Dict`, which are invariant. Values like `dict[str, int]` and `list[str]` now type-check as `Jsonifiable`, including as the return type of a function passed to a `Renderer[Jsonifiable]`. As a side effect, type checkers also accept other sequences and mappings, such as `range`, `deque`, and `bytes`, even though only `list`, `tuple`, and `dict` serialize. Nothing changes at runtime. (Thanks, @wolfgang-aura!) (#2511)
Expand All @@ -39,6 +41,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

* `near_points()` and `brushed_points()` now map a non-string categorical column (e.g. one of integers) to its category codes, instead of raising `ValueError: to_float: unsupported dtype for x`. `to_float()` tested the Series itself against `CategoricalDtype`, which no Series ever is, so the categorical branch never ran. (#2505)

* Text files that Shiny reads or writes (theme CSS/SCSS, `shiny create` templates, bundled Agent Skills, and the pytest generator's data files) are now explicitly read and written as UTF-8 instead of using the locale encoding, so non-ASCII content behaves the same on all supported Python versions. (#2564)

## [1.8.0] - 2026-09-12

### Deprecations
Expand Down
56 changes: 56 additions & 0 deletions scripts/benchmark_startup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Measure startup scenarios for `import shiny` on a given interpreter.

Each scenario runs in a fresh subprocess `--runs` times; we report the min and
median wall-clock time in milliseconds. Interpreter startup (~25-40ms) is
included, since that is what users actually pay per process (dev reloads, CLI,
test workers).

Usage:
python benchmark_startup.py [--python /path/to/python] [--runs 10]
"""

import argparse
import statistics
import subprocess
import sys
import time

SCENARIOS = {
"interpreter only": "pass",
"import shiny": "import shiny",
"from shiny import App, ui": "from shiny import App, ui",
"app boot (App + page)": (
"from shiny import App, ui\n" "app = App(ui.page_fluid(ui.h1('hi')), None)"
),
}


def time_scenario(python: str, code: str, runs: int) -> tuple[float, float]:
times: list[float] = []
for _ in range(runs):
start = time.perf_counter()
result = subprocess.run([python, "-c", code], capture_output=True, text=True)
elapsed_ms = (time.perf_counter() - start) * 1000
if result.returncode != 0:
raise RuntimeError(
f"Scenario failed on {python!r}: {code!r}\n{result.stderr}"
)
times.append(elapsed_ms)
return min(times), statistics.median(times)


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--python", default=sys.executable)
parser.add_argument("--runs", type=int, default=10)
args = parser.parse_args()

print(f"python: {args.python} (runs: {args.runs})")
print(f"{'scenario':<26} {'min':>9} {'median':>9}")
for label, code in SCENARIOS.items():
fastest, median = time_scenario(args.python, code, args.runs)
print(f"{label:<26} {fastest:>7.0f}ms {median:>7.0f}ms")


if __name__ == "__main__":
main()
6 changes: 6 additions & 0 deletions shiny/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
"""A package for building reactive web applications."""

# On Python 3.15+, these imports are deferred until first use (PEP 810); on
# older versions the declaration is ignored. Deferring `shiny._main` keeps the
# CLI/server stack (uvicorn, click, questionary, prompt_toolkit, watchfiles)
# out of `import shiny` for library, notebook, and test usage.
__lazy_modules__ = {"shiny._main"}

from ._version import __version__

from ._shinyenv import is_pyodide as _is_pyodide
Expand Down
2 changes: 1 addition & 1 deletion shiny/_main/_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ def find_templates(path: Path | str = ".") -> list[ShinyTemplate]:

template_files = sorted(path.glob("**/_template.json"))
for tf in template_files:
with tf.open() as f:
with tf.open(encoding="utf-8") as f:
try:
template = json.load(f)
except json.JSONDecodeError as err:
Expand Down
2 changes: 1 addition & 1 deletion shiny/_main/_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def _available_skills() -> list[Path]:

def _skill_description(skill_dir: Path) -> str:
"""Extract the one-line `description:` from a SKILL.md's YAML frontmatter."""
text = (skill_dir / "SKILL.md").read_text()
text = (skill_dir / "SKILL.md").read_text(encoding="utf-8")
if not text.startswith("---\n"):
return ""
frontmatter = text.split("---", 2)[1]
Expand Down
4 changes: 4 additions & 0 deletions shiny/express/ui/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
from __future__ import annotations

# Deferred on Python 3.15+ (PEP 810) so that `shinychat` is not imported unless
# the chat or markdown-stream components are used.
__lazy_modules__ = {"shiny.ui._chat", "shiny.ui._markdown_stream"}

from htmltools import (
HTML,
Tag,
Expand Down
4 changes: 2 additions & 2 deletions shiny/pytest/_generate/_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ def _load_documentation(self) -> str:
/ "_data"
/ "testing-documentation.json"
)
with doc_path.open("r") as f:
with doc_path.open("r", encoding="utf-8") as f:
return f.read()
except FileNotFoundError:
raise FileNotFoundError(
Expand All @@ -153,7 +153,7 @@ def _read_system_prompt(self) -> str:
/ "_data"
/ "testing-SYSTEM_PROMPT.md"
)
with prompt_path.open("r") as f:
with prompt_path.open("r", encoding="utf-8") as f:
system_prompt_file = f.read()
except FileNotFoundError:
raise FileNotFoundError(
Expand Down
9 changes: 9 additions & 0 deletions shiny/render/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@
Tools for reactively rendering output for the user interface.
"""

# Deferred on Python 3.15+ (PEP 810) so that `narwhals` is not imported at
# `import shiny` time; these modules load on first use of a renderer.
__lazy_modules__ = {
"shiny.render._data_frame",
"shiny.render._data_frame_utils._selection",
"shiny.render._data_frame_utils._types",
"shiny.render._render",
}

from . import ( # noqa: F401
transformer, # pyright: ignore[reportUnusedImport]
)
Expand Down
4 changes: 4 additions & 0 deletions shiny/ui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
layout helpers, page-level containers, and more.
"""

# Deferred on Python 3.15+ (PEP 810) so that `shinychat` is not imported unless
# the chat or markdown-stream components are used.
__lazy_modules__ = {"shiny.ui._chat", "shiny.ui._markdown_stream"}

from htmltools import (
HTML,
Tag,
Expand Down
8 changes: 4 additions & 4 deletions shiny/ui/_theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ class Theme:
)
)

with open(Path(__file__).parent / "my_theme.css", "w") as f:
with open(Path(__file__).parent / "my_theme.css", "w", encoding="utf-8") as f:
f.write(my_theme.to_css())
```

Expand Down Expand Up @@ -413,7 +413,7 @@ def add_sass_layer_file(self: T, path: str | pathlib.Path) -> T:
ValueError
If the `.scss` file doesn't contain at least one valid region decorator.
"""
with open(path, "r") as file:
with open(path, "r", encoding="utf-8") as file:
src = file.readlines()

layer_keys = ["uses", "functions", "defaults", "mixins", "rules"]
Expand Down Expand Up @@ -557,7 +557,7 @@ def _dep_css_precompiled_path(self) -> str | pathlib.Path:

def _read_precompiled_css(self) -> str:
path = self._dep_css_precompiled_path()
with open(path, "r") as f:
with open(path, "r", encoding="utf-8") as f:
return f.read()

def _dep_create(self, css_path: str | pathlib.Path) -> HTMLDependency:
Expand Down Expand Up @@ -601,7 +601,7 @@ def _html_dependencies(self) -> list[HTMLDependency]:
css_path = os.path.join(self._get_css_tempdir(), css_name)

if not os.path.exists(css_path):
with open(css_path, "w") as css_file:
with open(css_path, "w", encoding="utf-8") as css_file:
css_file.write(self.to_css())

return [self._dep_create(css_path)]
Expand Down
80 changes: 80 additions & 0 deletions tests/pytest/test_lazy_imports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Guard the PEP 810 lazy-import declarations for Python 3.15+.

``__lazy_modules__`` is ignored on Python 3.14 and older, so startup-heavy
modules (``shinychat``, ``narwhals``, the CLI stack) are only deferred when the
interpreter supports PEP 810. These tests pin both halves of the contract: the
deferral, and reification on first use. If someone adds an eager import back
into one of these import paths, the deferral test fails here instead of
silently costing every Python 3.15 user the load time.

Each check runs in a fresh subprocess, because ``sys.modules`` in the test
process is already polluted by earlier tests.
"""

from __future__ import annotations

import subprocess
import sys

import pytest

pytestmark = pytest.mark.skipif(
sys.version_info < (3, 15),
reason="Lazy imports (PEP 810) require Python 3.15+",
)


def run_python(code: str) -> None:
result = subprocess.run(
[sys.executable, "-c", code], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr


def test_import_shiny_defers_heavy_modules() -> None:
run_python("""
import sys
import shiny

heavy = [
"shiny._main",
"shinychat",
"narwhals",
"questionary",
"prompt_toolkit",
"uvicorn",
"watchfiles",
]
loaded = [name for name in heavy if name in sys.modules]
assert not loaded, f"eagerly imported: {loaded}"
""")


def test_heavy_modules_reify_on_first_use() -> None:
run_python("""
import sys
import shiny

assert "shinychat" not in sys.modules
_ = shiny.ui.Chat
assert "shinychat" in sys.modules

assert "narwhals" not in sys.modules
_ = shiny.render.text
assert "narwhals" in sys.modules

assert "shiny._main" not in sys.modules
_ = shiny.run_app
assert "shiny._main" in sys.modules
""")


def test_express_ui_defers_shinychat() -> None:
run_python("""
import sys
from shiny.express import ui

assert "shinychat" not in sys.modules
_ = ui.Chat
assert "shinychat" in sys.modules
""")
Loading