Skip to content

Commit 0cb99aa

Browse files
authored
Merge branch 'main' into chat-inject-stream
2 parents 0c2e9ec + 51693b8 commit 0cb99aa

File tree

128 files changed

+8052
-76
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

128 files changed

+8052
-76
lines changed

.github/workflows/pytest.yaml

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,6 @@ jobs:
187187
- name: Install node.js
188188
uses: actions/setup-node@v4
189189
with:
190-
node-version: "18"
191190
cache: npm
192191
cache-dependency-path: examples/brownian/shinymediapipe/package-lock.json
193192
- name: Install node.js package
@@ -206,6 +205,49 @@ jobs:
206205
path: test-results/
207206
retention-days: 5
208207

208+
playwright-ai:
209+
if: github.event_name != 'release'
210+
runs-on: ubuntu-latest
211+
strategy:
212+
matrix:
213+
python-version: ["3.12", "3.11", "3.10", "3.9"]
214+
browser: ["chromium", "firefox", "webkit"]
215+
exclude:
216+
- python-version: ${{ github.event.pull_request.draft && '3.11' }}
217+
- python-version: ${{ github.event.pull_request.draft && '3.10' }}
218+
- python-version: ${{ github.event.pull_request.draft && '3.9' }}
219+
- browser: ${{ github.event.pull_request.draft && 'firefox' }}
220+
- browser: ${{ github.event.pull_request.draft && 'webkit' }}
221+
fail-fast: false
222+
223+
steps:
224+
- uses: actions/checkout@v4
225+
with:
226+
fetch-depth: 0
227+
- name: Setup py-shiny
228+
uses: ./.github/py-shiny/setup
229+
with:
230+
python-version: ${{ matrix.python-version }}
231+
- name: Determine browsers for testing
232+
uses: ./.github/py-shiny/pytest-browsers
233+
id: browsers
234+
with:
235+
browser: ${{ matrix.browser }}
236+
# If anything other than `true`, it will heavily reduce webkit performance
237+
# Related: https://github.com/microsoft/playwright/issues/18119
238+
disable-playwright-diagnostics: ${{ matrix.browser == 'webkit' || matrix.browser == 'firefox' }}
239+
240+
- name: Run playwright tests for AI generated apps
241+
timeout-minutes: 60
242+
run: |
243+
make playwright-ai SUB_FILE=". --numprocesses 3 ${{ steps.browsers.outputs.playwright-diagnostic-args }}" ${{ steps.browsers.outputs.browsers }}
244+
- uses: actions/upload-artifact@v4
245+
if: failure() && steps.browsers.outputs.has-playwright-diagnostics
246+
with:
247+
name: "playright-ai-${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.browser }}-results"
248+
path: test-results/
249+
retention-days: 5
250+
209251
playwright-deploys-precheck:
210252
if: github.event_name != 'release'
211253
runs-on: ubuntu-latest

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ docs/source/reference/
115115
_dev/
116116
tests/playwright/deploys/**/requirements.txt
117117
test-results/
118+
shiny_bookmarks/
118119

119120
# setuptools_scm
120121
shiny/_version.py

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2323

2424
* Fixed an issue where the `.update_user_input()` method on `ui.Chat()` isn't working in shinylive. (#1891)
2525

26+
* Fixed an issue with the `.click()` method on InputActionButton controllers in `shiny.playwright.controllers` where the method would not work as expected. (#1886)
27+
2628
## [1.3.0] - 2025-03-03
2729

2830
### New features

Makefile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ TEST_FILE:=tests/playwright/$(SUB_FILE)
157157
DEPLOYS_TEST_FILE:=tests/playwright/deploys$(SUB_FILE)
158158
SHINY_TEST_FILE:=tests/playwright/shiny/$(SUB_FILE)
159159
EXAMPLES_TEST_FILE:=tests/playwright/examples/$(SUB_FILE)
160+
AI_TEST_FILE:=tests/playwright/ai_generated_apps/$(SUB_FILE)
160161

161162
install-playwright: FORCE
162163
playwright install --with-deps
@@ -187,6 +188,10 @@ playwright-deploys: FORCE
187188
playwright-examples: FORCE
188189
$(MAKE) playwright TEST_FILE="$(EXAMPLES_TEST_FILE)"
189190

191+
# end-to-end tests for all AI generated apps
192+
playwright-ai: FORCE
193+
$(MAKE) playwright TEST_FILE="$(AI_TEST_FILE)"
194+
190195
coverage: FORCE ## check combined code coverage (must run e2e last)
191196
pytest --cov-report term-missing --cov=shiny tests/pytest/ $(SHINY_TEST_FILE) $(PYTEST_BROWSERS)
192197
coverage html

docs/_quartodoc-core.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,23 @@ quartodoc:
9898
- ui.input_file
9999
- ui.download_button
100100
- ui.download_link
101+
- title: Bookmarking
102+
desc: Saving and restoring app state
103+
contents:
104+
- ui.input_bookmark_button
105+
- bookmark.restore_input
106+
- bookmark.Bookmark
107+
- bookmark.BookmarkState
108+
- bookmark.RestoreState
109+
- kind: page
110+
path: bookmark_integration
111+
summary:
112+
name: "Integration"
113+
desc: "Decorators to set save and restore directories."
114+
flatten: true
115+
contents:
116+
- bookmark.set_global_save_dir_fn
117+
- bookmark.set_global_restore_dir_fn
101118
- title: Chat interface
102119
desc: Build a chatbot interface
103120
contents:

pyrightconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"docs",
1111
"tests/playwright/deploys/*/app.py",
1212
"shiny/templates",
13-
"tests/playwright/shiny/tests_for_ai_generated_apps"
13+
"tests/playwright/ai_generated_apps",
1414
],
1515
"typeCheckingMode": "strict",
1616
"reportImportCycles": "none",

shiny/_app.py

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from contextlib import AsyncExitStack, asynccontextmanager
77
from inspect import signature
88
from pathlib import Path
9-
from typing import Any, Callable, Mapping, Optional, TypeVar, cast
9+
from typing import Any, Callable, Literal, Mapping, Optional, TypeVar, cast
1010

1111
import starlette.applications
1212
import starlette.exceptions
@@ -30,6 +30,15 @@
3030
from ._error import ErrorMiddleware
3131
from ._shinyenv import is_pyodide
3232
from ._utils import guess_mime_type, is_async_callable, sort_keys_length
33+
from .bookmark import _global as bookmark_global_state
34+
from .bookmark._global import as_bookmark_dir_fn
35+
from .bookmark._restore_state import RestoreContext, restore_context
36+
from .bookmark._types import (
37+
BookmarkDirFn,
38+
BookmarkRestoreDirFn,
39+
BookmarkSaveDirFn,
40+
BookmarkStore,
41+
)
3342
from .html_dependencies import jquery_deps, require_deps, shiny_deps
3443
from .http_staticfiles import FileResponse, StaticFiles
3544
from .session._session import AppSession, Inputs, Outputs, Session, session_context
@@ -106,6 +115,10 @@ def server(input: Inputs, output: Outputs, session: Session):
106115
ui: RenderedHTML | Callable[[Request], Tag | TagList]
107116
server: Callable[[Inputs, Outputs, Session], None]
108117

118+
_bookmark_save_dir_fn: BookmarkSaveDirFn | None
119+
_bookmark_restore_dir_fn: BookmarkRestoreDirFn | None
120+
_bookmark_store: BookmarkStore
121+
109122
def __init__(
110123
self,
111124
ui: Tag | TagList | Callable[[Request], Tag | TagList] | Path,
@@ -114,6 +127,8 @@ def __init__(
114127
),
115128
*,
116129
static_assets: Optional[str | Path | Mapping[str, str | Path]] = None,
130+
# Document type as Literal to have clearer type hints to App author
131+
bookmark_store: Literal["url", "server", "disable"] = "disable",
117132
debug: bool = False,
118133
) -> None:
119134
# Used to store callbacks to be called when the app is shutting down (according
@@ -133,6 +148,8 @@ def __init__(
133148
"`server` must have 1 (Inputs) or 3 parameters (Inputs, Outputs, Session)"
134149
)
135150

151+
self._init_bookmarking(bookmark_store=bookmark_store, ui=ui)
152+
136153
self._debug: bool = debug
137154

138155
# Settings that the user can change after creating the App object.
@@ -167,7 +184,7 @@ def __init__(
167184

168185
self._sessions: dict[str, AppSession] = {}
169186

170-
self._sessions_needing_flush: dict[int, AppSession] = {}
187+
# self._sessions_needing_flush: dict[int, AppSession] = {}
171188

172189
self._registered_dependencies: dict[str, HTMLDependency] = {}
173190
self._dependency_handler = starlette.routing.Router()
@@ -353,8 +370,18 @@ async def _on_root_request_cb(self, request: Request) -> Response:
353370
request for / occurs.
354371
"""
355372
ui: RenderedHTML
373+
if self.bookmark_store == "disable":
374+
restore_ctx = RestoreContext()
375+
else:
376+
restore_ctx = await RestoreContext.from_query_string(
377+
request.url.query, app=self
378+
)
379+
356380
if callable(self.ui):
357-
ui = self._render_page(self.ui(request), self.lib_prefix)
381+
# At this point, if `app.bookmark_store != "disable"`, then we've already
382+
# checked that `ui` is a function (in `App._init_bookmarking()`). No need to throw warning if `ui` is _not_ a function.
383+
with restore_context(restore_ctx):
384+
ui = self._render_page(self.ui(request), self.lib_prefix)
358385
else:
359386
ui = self.ui
360387
return HTMLResponse(content=ui["html"])
@@ -466,6 +493,30 @@ def _render_page_from_file(self, file: Path, lib_prefix: str) -> RenderedHTML:
466493

467494
return rendered
468495

496+
# ==========================================================================
497+
# Bookmarking
498+
# ==========================================================================
499+
500+
def _init_bookmarking(self, *, bookmark_store: BookmarkStore, ui: Any) -> None:
501+
self._bookmark_save_dir_fn = bookmark_global_state.bookmark_save_dir
502+
self._bookmark_restore_dir_fn = bookmark_global_state.bookmark_restore_dir
503+
self._bookmark_store = bookmark_store
504+
505+
if bookmark_store != "disable" and not callable(ui):
506+
raise TypeError(
507+
"App(ui=) must be a function that accepts a request object to allow the UI to be properly reconstructed from a bookmarked state."
508+
)
509+
510+
@property
511+
def bookmark_store(self) -> BookmarkStore:
512+
return self._bookmark_store
513+
514+
def set_bookmark_save_dir_fn(self, bookmark_save_dir_fn: BookmarkDirFn):
515+
self._bookmark_save_dir_fn = as_bookmark_dir_fn(bookmark_save_dir_fn)
516+
517+
def set_bookmark_restore_dir_fn(self, bookmark_restore_dir_fn: BookmarkDirFn):
518+
self._bookmark_restore_dir_fn = as_bookmark_dir_fn(bookmark_restore_dir_fn)
519+
469520

470521
def is_uifunc(x: Path | Tag | TagList | Callable[[Request], Tag | TagList]) -> bool:
471522
if (

shiny/_main_create.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -775,8 +775,7 @@ def copy_template_files(
775775
)
776776
sys.exit(1)
777777

778-
if not dest_dir.exists():
779-
dest_dir.mkdir()
778+
dest_dir.mkdir(parents=True, exist_ok=True)
780779

781780
for item in template.path.iterdir():
782781
if item.is_file():

shiny/_namespaces.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88

99
class ResolvedId(str):
10+
_sep: str = "-" # Shared object for all instances
11+
1012
def __call__(self, id: Id) -> ResolvedId:
1113
if isinstance(id, ResolvedId):
1214
return id
@@ -16,7 +18,7 @@ def __call__(self, id: Id) -> ResolvedId:
1618
if self == "":
1719
return ResolvedId(id)
1820
else:
19-
return ResolvedId(self + "-" + id)
21+
return ResolvedId(str(self) + self._sep + id)
2022

2123

2224
Root: ResolvedId = ResolvedId("")

shiny/_utils.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ async def fn_async(*args: P.args, **kwargs: P.kwargs) -> R:
281281
return fn_async
282282

283283

284-
# # TODO-barret-future; Q: Keep code?
284+
# # TODO: Barret - Future: Q: Keep code?
285285
# class WrapAsync(Generic[P, R]):
286286
# """
287287
# Make a function asynchronous.
@@ -517,11 +517,11 @@ async def __anext__(self):
517517
# ==============================================================================
518518
class Callbacks:
519519
def __init__(self) -> None:
520-
self._callbacks: dict[int, tuple[Callable[[], None], bool]] = {}
520+
self._callbacks: dict[int, tuple[Callable[..., None], bool]] = {}
521521
self._id: int = 0
522522

523523
def register(
524-
self, fn: Callable[[], None], once: bool = False
524+
self, fn: Callable[..., None], once: bool = False
525525
) -> Callable[[], None]:
526526
self._id += 1
527527
id = self._id
@@ -533,14 +533,14 @@ def _():
533533

534534
return _
535535

536-
def invoke(self) -> None:
536+
def invoke(self, *args: Any, **kwargs: Any) -> None:
537537
# The list() wrapper is necessary to force collection of all the items before
538538
# iteration begins. This is necessary because self._callbacks may be mutated
539539
# by callbacks.
540540
for id, value in list(self._callbacks.items()):
541541
fn, once = value
542542
try:
543-
fn()
543+
fn(*args, **kwargs)
544544
finally:
545545
if once:
546546
if id in self._callbacks:
@@ -550,32 +550,35 @@ def count(self) -> int:
550550
return len(self._callbacks)
551551

552552

553+
CancelCallback = Callable[[], None]
554+
555+
553556
class AsyncCallbacks:
554557
def __init__(self) -> None:
555-
self._callbacks: dict[int, tuple[Callable[[], Awaitable[None]], bool]] = {}
558+
self._callbacks: dict[int, tuple[Callable[..., Awaitable[None]], bool]] = {}
556559
self._id: int = 0
557560

558561
def register(
559-
self, fn: Callable[[], Awaitable[None]], once: bool = False
560-
) -> Callable[[], None]:
562+
self, fn: Callable[..., Awaitable[None]], once: bool = False
563+
) -> CancelCallback:
561564
self._id += 1
562565
id = self._id
563566
self._callbacks[id] = (fn, once)
564567

565-
def _():
568+
def cancel_callback():
566569
if id in self._callbacks:
567570
del self._callbacks[id]
568571

569-
return _
572+
return cancel_callback
570573

571-
async def invoke(self) -> None:
574+
async def invoke(self, *args: Any, **kwargs: Any) -> None:
572575
# The list() wrapper is necessary to force collection of all the items before
573576
# iteration begins. This is necessary because self._callbacks may be mutated
574577
# by callbacks.
575578
for id, value in list(self._callbacks.items()):
576579
fn, once = value
577580
try:
578-
await fn()
581+
await fn(*args, **kwargs)
579582
finally:
580583
if once:
581584
if id in self._callbacks:

0 commit comments

Comments
 (0)