From b861fb4abe41d63f248e52022bc125a94be73208 Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Wed, 8 Apr 2026 13:14:21 +0200 Subject: [PATCH 1/3] feat(gui): registry-based page registration with frame injection Co-Authored-By: Claude Sonnet 4.6 --- ATTRIBUTIONS.md | 2 +- docs/decisions/0005-gui-page-registration.md | 70 ++++ src/aignostics_foundry_core/gui/__init__.py | 4 + src/aignostics_foundry_core/gui/auth.py | 347 ++++++++++++++---- src/aignostics_foundry_core/gui/core.py | 24 +- tests/aignostics_foundry_core/gui/gui_test.py | 240 +++++++++--- 6 files changed, 543 insertions(+), 144 deletions(-) create mode 100644 docs/decisions/0005-gui-page-registration.md diff --git a/ATTRIBUTIONS.md b/ATTRIBUTIONS.md index 873b4c2..cfd0a97 100644 --- a/ATTRIBUTIONS.md +++ b/ATTRIBUTIONS.md @@ -360,7 +360,7 @@ SOFTWARE. ``` -## aignostics-foundry-core (0.8.1) - MIT License +## aignostics-foundry-core (0.8.2) - MIT License 🏭 Foundational infrastructure for Foundry components. diff --git a/docs/decisions/0005-gui-page-registration.md b/docs/decisions/0005-gui-page-registration.md new file mode 100644 index 0000000..cc8a701 --- /dev/null +++ b/docs/decisions/0005-gui-page-registration.md @@ -0,0 +1,70 @@ +# 5. GUI page registration + +Date: 2026-04-08 + +## Status + +Accepted + +## Context + +Library consumers need to register NiceGUI pages with a frame that displays a consistent header, +navigation sidebar, and health status bar. The frame function is inherently tied to the service, +meaning it cannot be defined in a generic library like foundry-core. + +The previous implementation relied on library consumers creating `gui = GUINamespace(frame_func=frame)` in +their service. Since this library replaces the foundational components for services, there is no natural +place to do this. In Bridge, this `GUINamespace` would be created in the system module and imported +into other modules, violating domain boundaries. + +### Alternatives considered + +**Option A — Deferred singleton in foundry-core**: Add `gui = GUINamespace()` to +`aignostics_foundry_core.gui` and a `configure(frame_func=...)` method. Feature modules import +`gui` from foundry-core; services call `gui.configure(frame_func=frame)` at startup. +Rejected: pollutes a generic library with a stateful singleton that must be mutated by the +application layer. Complicates testing (global state to reset). Philosophically wrong: the +singleton is specific to the service and should not live in a general-purpose library. + +**Option B — New `gui` module in services**: Create a new module in each service as a neutral singleton +home. Feature modules import from that module. +Rejected: adds a thin wrapper module whose only content is a singleton. The module boundary +problem is merely moved, not eliminated. No architectural gain justifies the additional module. + +**Option C (chosen) — Registry-based page decorators**: The standalone `page_*` decorators in +foundry-core (`page_authenticated`, `page_public`, etc.) write to a module-level `_registry` +instead of calling `@ui.page()` immediately. `gui_register_pages(frame_func=frame)` processes +the registry after all `BasePageBuilder.register_pages()` calls, actualizing each entry with +the correct `frame_func`. Feature modules import only from `aignostics_foundry_core.gui`. +Library consumers provide the `frame_func` to `gui_run()`, which flows it down to +`gui_register_pages`. The `gui` singleton in library consumers is deleted entirely. + +## Decision + +Implement Option C. The standalone `page_*` decorators become pure registration decorators +that record intent (path, title, access level, page function) to a module-level list. The +`gui_register_pages(frame_func)` function actualizes all entries using the private +`_actualize_*` functions. `GUINamespace` methods continue to call `_actualize_*` directly, +bypassing the registry (preserving the existing opt-in, frame-at-construction-time API for +any future consumers that prefer it). + +`gui_run()` gains a `frame_func` parameter that is forwarded to `gui_register_pages`. +Library consumers passe `frame_func=frame` when calling `gui_run()`. + +## Consequences + +**Easier**: +- Feature modules have zero dependency on a `gui` module for page registration. +- Dependency graph is clean: feature modules → `aignostics_foundry_core.gui`; library consumers + orchestrate and provide the frame. +- Adding a new page requires only `from aignostics_foundry_core.gui import page_authenticated` + — no reference to any singleton or other service module. + +**Harder / risks**: +- Page registration is now a two-phase process (write to registry, then actualize). Code that + calls `page_authenticated(path)(func)` and expects the route to be live immediately (without + subsequently calling `gui_register_pages`) will silently not register the route. +- `_registry` is module-level mutable state. Tests must call `clear_page_registry()` in + teardown to avoid cross-test contamination. +- `GUINamespace` now calls a different set of internal functions (`_actualize_*`) than the + public `page_*` API, which is a maintenance surface to keep in sync. diff --git a/src/aignostics_foundry_core/gui/__init__.py b/src/aignostics_foundry_core/gui/__init__.py index b75f260..9222a3c 100644 --- a/src/aignostics_foundry_core/gui/__init__.py +++ b/src/aignostics_foundry_core/gui/__init__.py @@ -8,7 +8,9 @@ """ from .auth import ( + AccessLevel, GUINamespace, + clear_page_registry, get_gui_user, gui, page_admin, @@ -37,11 +39,13 @@ "BROWSER_RECONNECT_TIMEOUT", "RESPONSE_TIMEOUT", "WINDOW_SIZE", + "AccessLevel", "BaseNavBuilder", "BasePageBuilder", "GUINamespace", "NavGroup", "NavItem", + "clear_page_registry", "get_gui_user", "gui", "gui_get_nav_groups", diff --git a/src/aignostics_foundry_core/gui/auth.py b/src/aignostics_foundry_core/gui/auth.py index 2ef40a1..9edf263 100644 --- a/src/aignostics_foundry_core/gui/auth.py +++ b/src/aignostics_foundry_core/gui/auth.py @@ -3,16 +3,48 @@ This module provides: - get_gui_user: Get authenticated user from Auth0 session - require_gui_user: Require authentication, redirect to login if not authenticated -- Page decorators: page_public, page_authenticated, page_admin, page_internal, +- Page actualize functions (private): _actualize_public, _actualize_authenticated, + _actualize_admin, _actualize_internal, _actualize_internal_admin +- Page registry decorators: page_public, page_authenticated, page_admin, page_internal, page_internal_admin +- clear_page_registry: Clear the global page registry (for test isolation) - GUINamespace: Configurable namespace for page decorators - gui: Default GUINamespace singleton (no frame) + +References: + docs/decisions/0005-gui-page-registration.md + +Example (registry-based, new style):: + + from aignostics_foundry_core.gui import page_authenticated, gui_run + + + @page_authenticated("/dashboard") + def dashboard(user: dict) -> None: + ui.label(f"Hello, {user['name']}") + + + # Later, in main: + gui_run(frame_func=my_frame) # Actualizes all registered pages + +Example (namespace-based, legacy style):: + + from aignostics_foundry_core.gui import GUINamespace + + gui = GUINamespace(frame_func=my_frame) + + + @gui.authenticated("/dashboard") + def dashboard(user: dict) -> None: + ui.label(f"Hello, {user['name']}") """ import contextlib import inspect import time from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from enum import StrEnum from typing import Any from fastapi import Request @@ -25,6 +57,7 @@ from .core import RESPONSE_TIMEOUT CLASS_FORBIDDEN_ERROR = "text-red-500 text-2xl" +MSG_403_FORBIDDEN = "403 Forbidden" # FrameFunc is an optional callable that returns a context manager. # Call signature: frame_func(title, user=user) @@ -32,6 +65,68 @@ FrameFunc = Callable[..., Any] | None +class AccessLevel(StrEnum): + """Access level for a registered NiceGUI page.""" + + PUBLIC = "public" + AUTHENTICATED = "authenticated" + ADMIN = "admin" + INTERNAL = "internal" + INTERNAL_ADMIN = "internal_admin" + + +@dataclass +class _PageEntry: + """Registry entry for a deferred NiceGUI page registration.""" + + access: AccessLevel + path: str + title: str + func: Callable[..., Any] + + +# Module-level registry — NOT thread-safe. Tests MUST call clear_page_registry() +# in setup/teardown. Production code should only write during module import and +# read during gui_register_pages(). +_registry: list[_PageEntry] = [] + + +def clear_page_registry() -> None: + """Clear the global page registry. + + Call in test teardown to ensure isolation between tests. Each test that + exercises page registration should clear the registry before and after. + """ + _registry.clear() + + +def process_page_registry(frame_func: FrameFunc = None) -> None: + """Actualize all entries in the page registry and clear it. + + Iterates over every ``_PageEntry`` recorded by the ``page_*`` decorators, + calls the matching ``_actualize_*`` function with the given ``frame_func``, + then clears the registry. + + Called by ``gui_register_pages`` after all ``BasePageBuilder.register_pages()`` + methods have run. + + Args: + frame_func: Optional frame callable injected into every registered page. + Called as ``frame_func(title, user=user)`` inside the page wrapper. + When ``None``, pages render without a frame. + """ + actualize_map = { + AccessLevel.PUBLIC: _actualize_public, + AccessLevel.AUTHENTICATED: _actualize_authenticated, + AccessLevel.ADMIN: _actualize_admin, + AccessLevel.INTERNAL: _actualize_internal, + AccessLevel.INTERNAL_ADMIN: _actualize_internal_admin, + } + for entry in _registry: + actualize_map[entry.access](entry.path, entry.title, frame_func=frame_func)(entry.func) + _registry.clear() + + async def _invoke_page_func(func: Callable[..., Any], user: dict[str, Any] | None) -> None: """Invoke a page function, awaiting it if it is a coroutine function.""" if inspect.iscoroutinefunction(func): @@ -116,33 +211,22 @@ async def require_gui_user(request: Request, return_to: str | None = None) -> di return user -def page_public( +# --------------------------------------------------------------------------- +# Private actualize functions — called immediately with a known frame_func. +# Used by GUINamespace methods and by gui_register_pages when processing the +# registry. +# --------------------------------------------------------------------------- + + +def _actualize_public( path: str, title: str = "", frame_func: FrameFunc = None, ) -> Callable[..., Callable[[Request], Awaitable[None]]]: - """Decorator for public NiceGUI pages with optional frame. - - Creates a page that does not require authentication. The user (or ``None`` - if not authenticated) is passed to the decorated function. Supports both - sync and async functions. - - Args: - path: The URL path for the page. - title: The title passed to ``frame_func`` (if provided). - frame_func: Optional callable returning a context manager used to wrap - the page content. Called as ``frame_func(title, user=user)``. + """Register a public NiceGUI page immediately with the given frame_func. Returns: A decorator that wraps the page function. - - Example: - @page_public("/public-page") - def public_page(user: dict[str, Any] | None) -> None: - if user: - ui.label(f"Hello, {user.get('name')}") - else: - ui.label("Hello, guest!") """ from nicegui import ui # noqa: PLC0415 @@ -164,22 +248,12 @@ async def wrapper(request: Request) -> None: return decorator -def page_authenticated( +def _actualize_authenticated( path: str, title: str = "", frame_func: FrameFunc = None, ) -> Callable[..., Callable[[Request], Awaitable[None]]]: - """Decorator for authenticated NiceGUI pages with optional frame. - - Creates a page that requires authentication. If the user is not - authenticated, they are redirected to login with ``returnTo`` set to the - current page. Supports both sync and async functions. - - Args: - path: The URL path for the page. - title: The title passed to ``frame_func`` (if provided). - frame_func: Optional callable returning a context manager used to wrap - the page content. Called as ``frame_func(title, user=user)``. + """Register an authenticated NiceGUI page immediately with the given frame_func. Returns: A decorator that wraps the page function. @@ -206,22 +280,12 @@ async def wrapper(request: Request) -> None: return decorator -def page_admin( +def _actualize_admin( path: str, title: str = "", frame_func: FrameFunc = None, ) -> Callable[..., Callable[[Request], Awaitable[None]]]: - """Decorator for admin-only NiceGUI pages with optional frame. - - Creates a page that requires authentication and admin role. If the user is - not authenticated, they are redirected to login. If authenticated but not - admin, a 403 error is shown. Supports both sync and async functions. - - Args: - path: The URL path for the page. - title: The title passed to ``frame_func`` (if provided). - frame_func: Optional callable returning a context manager used to wrap - the page content. Called as ``frame_func(title, user=user)``. + """Register an admin-only NiceGUI page immediately with the given frame_func. Returns: A decorator that wraps the page function. @@ -241,7 +305,7 @@ async def wrapper(request: Request) -> None: role = user.get(auth_settings.auth0_role_claim) if role != AUTH0_ROLE_ADMIN: with frame_func(title, user=user) if frame_func is not None else contextlib.nullcontext(): - ui.label("403 Forbidden - Admin access required").classes(CLASS_FORBIDDEN_ERROR) + ui.label(f"{MSG_403_FORBIDDEN} - Admin access required").classes(CLASS_FORBIDDEN_ERROR) return with frame_func(title, user=user) if frame_func is not None else contextlib.nullcontext(): @@ -256,23 +320,12 @@ async def wrapper(request: Request) -> None: return decorator -def page_internal( +def _actualize_internal( path: str, title: str = "", frame_func: FrameFunc = None, ) -> Callable[..., Callable[[Request], Awaitable[None]]]: - """Decorator for internal-org-only NiceGUI pages with optional frame. - - Creates a page that requires authentication and internal org membership. - If the user is not authenticated, they are redirected to login. If - authenticated but not in the internal org, a 403 error is shown. Supports - both sync and async functions. - - Args: - path: The URL path for the page. - title: The title passed to ``frame_func`` (if provided). - frame_func: Optional callable returning a context manager used to wrap - the page content. Called as ``frame_func(title, user=user)``. + """Register an internal-org-only NiceGUI page immediately with the given frame_func. Returns: A decorator that wraps the page function. @@ -292,7 +345,7 @@ async def wrapper(request: Request) -> None: org_id = user.get("org_id") if org_id != auth_settings.internal_org_id: with frame_func(title, user=user) if frame_func is not None else contextlib.nullcontext(): - ui.label("403 Forbidden - Internal access required").classes(CLASS_FORBIDDEN_ERROR) + ui.label(f"{MSG_403_FORBIDDEN} - Internal access required").classes(CLASS_FORBIDDEN_ERROR) return with frame_func(title, user=user) if frame_func is not None else contextlib.nullcontext(): @@ -307,21 +360,12 @@ async def wrapper(request: Request) -> None: return decorator -def page_internal_admin( +def _actualize_internal_admin( path: str, title: str = "", frame_func: FrameFunc = None, ) -> Callable[..., Callable[[Request], Awaitable[None]]]: - """Decorator for internal-org admin-only NiceGUI pages with optional frame. - - Creates a page that requires authentication, internal org membership, AND - admin role. Supports both sync and async functions. - - Args: - path: The URL path for the page. - title: The title passed to ``frame_func`` (if provided). - frame_func: Optional callable returning a context manager used to wrap - the page content. Called as ``frame_func(title, user=user)``. + """Register an internal-org admin-only NiceGUI page immediately with the given frame_func. Returns: A decorator that wraps the page function. @@ -343,7 +387,7 @@ async def wrapper(request: Request) -> None: if org_id != auth_settings.internal_org_id or role != AUTH0_ROLE_ADMIN: with frame_func(title, user=user) if frame_func is not None else contextlib.nullcontext(): - ui.label("403 Forbidden - Internal admin access required").classes(CLASS_FORBIDDEN_ERROR) + ui.label(f"{MSG_403_FORBIDDEN} - Internal admin access required").classes(CLASS_FORBIDDEN_ERROR) return with frame_func(title, user=user) if frame_func is not None else contextlib.nullcontext(): @@ -358,16 +402,148 @@ async def wrapper(request: Request) -> None: return decorator +# --------------------------------------------------------------------------- +# Public registry decorators — write intent to _registry; frame_func is +# injected later by gui_register_pages(frame_func=...). +# --------------------------------------------------------------------------- + + +def page_public(path: str, title: str = "") -> Callable[..., Any]: + """Decorator that registers a public page in the global registry. + + The route is NOT registered with NiceGUI immediately. Call + ``gui_register_pages(frame_func=...)`` to actualize all registered pages + with the appropriate frame. + + Args: + path: The URL path for the page. + title: The title passed to the frame function when the page is actualized. + + Returns: + A decorator that records the page function in the registry and returns + it unchanged. + + Example: + @page_public("/") + def home(user: dict[str, Any] | None) -> None: + ui.label("Welcome!") + """ + + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + _registry.append(_PageEntry(AccessLevel.PUBLIC, path, title, func)) + return func + + return decorator + + +def page_authenticated(path: str, title: str = "") -> Callable[..., Any]: + """Decorator that registers an authenticated page in the global registry. + + The route is NOT registered with NiceGUI immediately. Call + ``gui_register_pages(frame_func=...)`` to actualize all registered pages + with the appropriate frame. + + Args: + path: The URL path for the page. + title: The title passed to the frame function when the page is actualized. + + Returns: + A decorator that records the page function in the registry and returns + it unchanged. + """ + + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + _registry.append(_PageEntry(AccessLevel.AUTHENTICATED, path, title, func)) + return func + + return decorator + + +def page_admin(path: str, title: str = "") -> Callable[..., Any]: + """Decorator that registers an admin-only page in the global registry. + + The route is NOT registered with NiceGUI immediately. Call + ``gui_register_pages(frame_func=...)`` to actualize all registered pages + with the appropriate frame. + + Args: + path: The URL path for the page. + title: The title passed to the frame function when the page is actualized. + + Returns: + A decorator that records the page function in the registry and returns + it unchanged. + """ + + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + _registry.append(_PageEntry(AccessLevel.ADMIN, path, title, func)) + return func + + return decorator + + +def page_internal(path: str, title: str = "") -> Callable[..., Any]: + """Decorator that registers an internal-org-only page in the global registry. + + The route is NOT registered with NiceGUI immediately. Call + ``gui_register_pages(frame_func=...)`` to actualize all registered pages + with the appropriate frame. + + Args: + path: The URL path for the page. + title: The title passed to the frame function when the page is actualized. + + Returns: + A decorator that records the page function in the registry and returns + it unchanged. + """ + + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + _registry.append(_PageEntry(AccessLevel.INTERNAL, path, title, func)) + return func + + return decorator + + +def page_internal_admin(path: str, title: str = "") -> Callable[..., Any]: + """Decorator that registers an internal-org admin-only page in the global registry. + + The route is NOT registered with NiceGUI immediately. Call + ``gui_register_pages(frame_func=...)`` to actualize all registered pages + with the appropriate frame. + + Args: + path: The URL path for the page. + title: The title passed to the frame function when the page is actualized. + + Returns: + A decorator that records the page function in the registry and returns + it unchanged. + """ + + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + _registry.append(_PageEntry(AccessLevel.INTERNAL_ADMIN, path, title, func)) + return func + + return decorator + + class GUINamespace: """Namespace for NiceGUI page decorators with configurable frame function. Provides decorators for creating pages with automatic frame rendering - and user injection at various access levels. + and user injection at various access levels. Unlike the standalone + ``page_*`` registry decorators, ``GUINamespace`` methods actualize the + route immediately (bypassing the registry) using the frame function + supplied at construction time. Args: frame_func: Optional callable returning a context manager for page framing. Called as ``frame_func(title, user=user)``. + References: + docs/decisions/0005-gui-page-registration.md + Example: from aignostics_foundry_core.gui import gui @@ -396,6 +572,9 @@ def public( ) -> Callable[..., Callable[[Request], Awaitable[None]]]: """Decorator for public NiceGUI pages. + Registers the route immediately via ``_actualize_public``, bypassing + the global registry. + Args: path: The URL path for the page. title: The title passed to the frame function (if configured). @@ -403,7 +582,7 @@ def public( Returns: A decorator that wraps the page function. """ - return page_public(path, title, frame_func=self._frame_func) + return _actualize_public(path, title, frame_func=self._frame_func) def authenticated( self, @@ -412,6 +591,9 @@ def authenticated( ) -> Callable[..., Callable[[Request], Awaitable[None]]]: """Decorator for authenticated NiceGUI pages. + Registers the route immediately via ``_actualize_authenticated``, + bypassing the global registry. + Args: path: The URL path for the page. title: The title passed to the frame function (if configured). @@ -419,7 +601,7 @@ def authenticated( Returns: A decorator that wraps the page function. """ - return page_authenticated(path, title, frame_func=self._frame_func) + return _actualize_authenticated(path, title, frame_func=self._frame_func) def admin( self, @@ -428,6 +610,9 @@ def admin( ) -> Callable[..., Callable[[Request], Awaitable[None]]]: """Decorator for admin-only NiceGUI pages. + Registers the route immediately via ``_actualize_admin``, bypassing + the global registry. + Args: path: The URL path for the page. title: The title passed to the frame function (if configured). @@ -435,7 +620,7 @@ def admin( Returns: A decorator that wraps the page function. """ - return page_admin(path, title, frame_func=self._frame_func) + return _actualize_admin(path, title, frame_func=self._frame_func) def internal( self, @@ -444,6 +629,9 @@ def internal( ) -> Callable[..., Callable[[Request], Awaitable[None]]]: """Decorator for internal-org-only NiceGUI pages. + Registers the route immediately via ``_actualize_internal``, bypassing + the global registry. + Args: path: The URL path for the page. title: The title passed to the frame function (if configured). @@ -451,7 +639,7 @@ def internal( Returns: A decorator that wraps the page function. """ - return page_internal(path, title, frame_func=self._frame_func) + return _actualize_internal(path, title, frame_func=self._frame_func) def internal_admin( self, @@ -460,6 +648,9 @@ def internal_admin( ) -> Callable[..., Callable[[Request], Awaitable[None]]]: """Decorator for internal-org admin-only NiceGUI pages. + Registers the route immediately via ``_actualize_internal_admin``, + bypassing the global registry. + Args: path: The URL path for the page. title: The title passed to the frame function (if configured). @@ -467,7 +658,7 @@ def internal_admin( Returns: A decorator that wraps the page function. """ - return page_internal_admin(path, title, frame_func=self._frame_func) + return _actualize_internal_admin(path, title, frame_func=self._frame_func) gui = GUINamespace() diff --git a/src/aignostics_foundry_core/gui/core.py b/src/aignostics_foundry_core/gui/core.py index e47c4d2..d756b71 100644 --- a/src/aignostics_foundry_core/gui/core.py +++ b/src/aignostics_foundry_core/gui/core.py @@ -22,6 +22,7 @@ from fastapi.routing import APIRouter from aignostics_foundry_core.foundry import FoundryContext + from aignostics_foundry_core.gui.auth import FrameFunc WINDOW_SIZE = (1280, 768) BROWSER_RECONNECT_TIMEOUT = 60 * 60 * 24 * 7 # 7 days @@ -49,22 +50,31 @@ def register_pages() -> None: """Register NiceGUI pages.""" -def gui_register_pages(*, context: FoundryContext | None = None) -> None: - """Register pages from all discovered PageBuilders. +def gui_register_pages(*, context: FoundryContext | None = None, frame_func: FrameFunc = None) -> None: + """Register pages from all discovered PageBuilders and actualize the registry. - Discovers all ``BasePageBuilder`` subclasses for the configured project and - calls ``register_pages()`` on each one. + Discovers all ``BasePageBuilder`` subclasses for the configured project, + calls ``register_pages()`` on each one (which populates ``_registry`` via + the ``page_*`` decorators), then actualizes every registry entry with the + given ``frame_func``. The registry is cleared after processing. Args: context: Project context used for PageBuilder discovery. When ``None``, the global context installed via :func:`aignostics_foundry_core.foundry.set_context` is used. + frame_func: Optional frame callable injected into every registered page. + Called as ``frame_func(title, user=user)`` inside the page wrapper. + When ``None``, pages render without a frame. """ + from .auth import process_page_registry # noqa: PLC0415 + page_builders = locate_subclasses(BasePageBuilder, context=context or get_context()) for page_builder in page_builders: page_builder: BasePageBuilder # type: ignore[no-redef] page_builder.register_pages() + process_page_registry(frame_func=frame_func) + def _register_callbacks( app: FastAPI, @@ -123,6 +133,7 @@ def gui_run( # noqa: PLR0913, PLR0917 shutdown_callbacks: list[Callable[[], Any]] | None = None, *, context: FoundryContext | None = None, + frame_func: FrameFunc = None, ) -> None: """Start the NiceGUI application. @@ -146,6 +157,9 @@ def gui_run( # noqa: PLR0913, PLR0917 context: Project context used for page builder discovery and window title. When ``None``, the global context installed via :func:`aignostics_foundry_core.foundry.set_context` is used. + frame_func: Optional frame callable forwarded to ``gui_register_pages``. + Injected into every page registered via the ``page_*`` registry + decorators. Called as ``frame_func(title, user=user)``. """ from nicegui import app, ui # noqa: PLC0415 from nicegui import native as native_app # noqa: PLC0415 @@ -153,7 +167,7 @@ def gui_run( # noqa: PLR0913, PLR0917 ctx = context or get_context() _register_callbacks(app, startup_callbacks, shutdown_callbacks) - gui_register_pages(context=ctx) + gui_register_pages(context=ctx, frame_func=frame_func) if fastapi_app is not None: _mount_fastapi_app(app, fastapi_app, auth_router) diff --git a/tests/aignostics_foundry_core/gui/gui_test.py b/tests/aignostics_foundry_core/gui/gui_test.py index 432fc4a..d182928 100644 --- a/tests/aignostics_foundry_core/gui/gui_test.py +++ b/tests/aignostics_foundry_core/gui/gui_test.py @@ -1,5 +1,6 @@ """Tests for aignostics_foundry_core.gui.*.""" +import asyncio import os import sys import time @@ -28,6 +29,7 @@ from tests.conftest import TEST_PROJECT_NAME, make_context _PATCH_GET_GUI_USER = "aignostics_foundry_core.gui.auth.get_gui_user" +_PATCH_REQUIRE_GUI_USER = "aignostics_foundry_core.gui.auth.require_gui_user" _PATH_NAV_LOCATE = "aignostics_foundry_core.gui.nav.locate_subclasses" _PATH_CORE_LOCATE = "aignostics_foundry_core.gui.core.locate_subclasses" @@ -38,6 +40,7 @@ _FIXED_PORT = 9000 _DOCS_PATH = "/docs" _USER_SUB = "auth0|x" +_MODULE_STARLETTE_RESPONSES = "starlette.responses" # --------------------------------------------------------------------------- @@ -245,6 +248,15 @@ def test_browser_reconnect_timeout_is_long(self) -> None: class TestGuiRegisterPages: """Tests for gui_register_pages behaviour.""" + @pytest.fixture(autouse=True) + def _clear_registry(self) -> Generator[None, None, None]: # pyright: ignore[reportUnusedFunction] + """Ensure the page registry is clean before and after each test.""" + from aignostics_foundry_core.gui import clear_page_registry + + clear_page_registry() + yield + clear_page_registry() + def test_calls_register_pages_on_each_builder(self) -> None: """gui_register_pages calls register_pages() on every discovered builder.""" builder_a = MagicMock(spec=BasePageBuilder) @@ -298,7 +310,7 @@ class TestGuiRun: def _call_gui_run(self, nicegui_mock: MagicMock, **kwargs: object) -> None: """Call gui_run with context and nicegui and locate_subclasses mocked.""" with ( - patch.dict(sys.modules, {"nicegui": nicegui_mock, "starlette.responses": MagicMock()}), + patch.dict(sys.modules, {"nicegui": nicegui_mock, _MODULE_STARLETTE_RESPONSES: MagicMock()}), patch(_PATH_CORE_LOCATE, return_value=[]), ): gui_run(context=make_context(), **kwargs) # type: ignore[arg-type] @@ -435,7 +447,7 @@ def test_gui_register_pages_called(self) -> None: nicegui_mock, _, _ = _make_nicegui_app_mock() ctx = make_context() with ( - patch.dict(sys.modules, {"nicegui": nicegui_mock, "starlette.responses": MagicMock()}), + patch.dict(sys.modules, {"nicegui": nicegui_mock, _MODULE_STARLETTE_RESPONSES: MagicMock()}), patch(_PATH_CORE_LOCATE, return_value=[]) as locate_mock, ): gui_run(context=ctx) @@ -602,7 +614,7 @@ async def test_uses_return_to_override(self) -> None: # --------------------------------------------------------------------------- -# Page decorator registration +# Page registry decorators — deferred registration # --------------------------------------------------------------------------- @@ -620,11 +632,20 @@ def _make_nicegui_mock() -> tuple[MagicMock, MagicMock]: @pytest.mark.unit -class TestPagePublicRegistration: - """Tests for page_public route registration.""" +class TestPageRegistryDecorators: + """Tests for page_* registry decorators (deferred registration).""" - def test_registers_route_with_ui_page(self) -> None: - """page_public calls ui.page with the supplied path and RESPONSE_TIMEOUT.""" + @pytest.fixture(autouse=True) + def _clear_registry(self) -> Generator[None, None, None]: # pyright: ignore[reportUnusedFunction] + """Ensure registry is clean before and after each test.""" + from aignostics_foundry_core.gui import clear_page_registry + + clear_page_registry() + yield + clear_page_registry() + + def test_page_public_does_not_register_route_immediately(self) -> None: + """page_public(path)(func) does NOT call ui.page; route deferred until gui_register_pages.""" from aignostics_foundry_core.gui.auth import page_public nicegui_mock, page_recorder = _make_nicegui_mock() @@ -632,86 +653,176 @@ def test_registers_route_with_ui_page(self) -> None: with patch.dict(sys.modules, {"nicegui": nicegui_mock}): page_public(_TEST_PATH)(lambda user: None) # pyright: ignore[reportUnknownLambdaType] - page_recorder.assert_called_once_with(_TEST_PATH, response_timeout=RESPONSE_TIMEOUT) + page_recorder.assert_not_called() - def test_preserves_function_name(self) -> None: - """page_public preserves the decorated function's __name__.""" - from aignostics_foundry_core.gui.auth import page_public + def test_page_authenticated_does_not_register_route_immediately(self) -> None: + """page_authenticated(path)(func) does NOT call ui.page immediately.""" + from aignostics_foundry_core.gui.auth import page_authenticated + + nicegui_mock, page_recorder = _make_nicegui_mock() - nicegui_mock, _ = _make_nicegui_mock() + with patch.dict(sys.modules, {"nicegui": nicegui_mock}): + page_authenticated(_TEST_PATH)(lambda user: None) # pyright: ignore[reportUnknownLambdaType] - def my_named_page(user: object) -> None: - # Test double: content is irrelevant; only __name__ is tested - ... + page_recorder.assert_not_called() + + def test_page_admin_does_not_register_route_immediately(self) -> None: + """page_admin(path)(func) does NOT call ui.page immediately.""" + from aignostics_foundry_core.gui.auth import page_admin + + nicegui_mock, page_recorder = _make_nicegui_mock() with patch.dict(sys.modules, {"nicegui": nicegui_mock}): - result = page_public(_TEST_PATH)(my_named_page) + page_admin(_TEST_PATH)(lambda user: None) # pyright: ignore[reportUnknownLambdaType] - assert result.__name__ == "my_named_page" + page_recorder.assert_not_called() + def test_page_internal_does_not_register_route_immediately(self) -> None: + """page_internal(path)(func) does NOT call ui.page immediately.""" + from aignostics_foundry_core.gui.auth import page_internal -@pytest.mark.unit -class TestPageAuthenticatedRegistration: - """Tests for page_authenticated route registration.""" + nicegui_mock, page_recorder = _make_nicegui_mock() - def test_registers_route_with_ui_page(self) -> None: - """page_authenticated calls ui.page with the supplied path.""" - from aignostics_foundry_core.gui.auth import page_authenticated + with patch.dict(sys.modules, {"nicegui": nicegui_mock}): + page_internal(_TEST_PATH)(lambda user: None) # pyright: ignore[reportUnknownLambdaType] + + page_recorder.assert_not_called() + + def test_page_internal_admin_does_not_register_route_immediately(self) -> None: + """page_internal_admin(path)(func) does NOT call ui.page immediately.""" + from aignostics_foundry_core.gui.auth import page_internal_admin nicegui_mock, page_recorder = _make_nicegui_mock() with patch.dict(sys.modules, {"nicegui": nicegui_mock}): - page_authenticated(_TEST_PATH)(lambda user: None) # pyright: ignore[reportUnknownLambdaType] + page_internal_admin(_TEST_PATH)(lambda user: None) # pyright: ignore[reportUnknownLambdaType] - page_recorder.assert_called_once_with(_TEST_PATH, response_timeout=RESPONSE_TIMEOUT) + page_recorder.assert_not_called() + def test_page_public_preserves_original_function(self) -> None: + """page_public(path)(func) returns the original func unchanged, not a wrapper.""" + from aignostics_foundry_core.gui.auth import page_public -@pytest.mark.unit -class TestPageAdminRegistration: - """Tests for page_admin route registration.""" + def my_page(user: object) -> None: ... - def test_registers_route_with_ui_page(self) -> None: - """page_admin calls ui.page with the supplied path.""" - from aignostics_foundry_core.gui.auth import page_admin + result = page_public(_TEST_PATH)(my_page) - nicegui_mock, page_recorder = _make_nicegui_mock() + assert result is my_page - with patch.dict(sys.modules, {"nicegui": nicegui_mock}): - page_admin(_TEST_PATH)(lambda user: None) # pyright: ignore[reportUnknownLambdaType] + def test_gui_register_pages_actualizes_registered_page_with_frame_func(self) -> None: + """gui_register_pages processes registry and injects frame_func into the route.""" + from aignostics_foundry_core.gui.auth import page_public - page_recorder.assert_called_once_with(_TEST_PATH, response_timeout=RESPONSE_TIMEOUT) + frame_entered: list[bool] = [] + @contextmanager + def fake_frame(title: str, **_kw: object): # type: ignore[misc] + frame_entered.append(True) + yield -@pytest.mark.unit -class TestPageInternalRegistration: - """Tests for page_internal route registration.""" + def my_page(user: object) -> None: ... - def test_registers_route_with_ui_page(self) -> None: - """page_internal calls ui.page with the supplied path.""" - from aignostics_foundry_core.gui.auth import page_internal + page_public(_TEST_PATH)(my_page) - nicegui_mock, page_recorder = _make_nicegui_mock() + wrappers: list[object] = [] + nicegui_mock = MagicMock() + nicegui_mock.ui.page.side_effect = ( # pyright: ignore[reportUnknownMemberType] + lambda *a, **kw: lambda f: wrappers.append(f) or f # pyright: ignore[reportUnknownLambdaType, reportUnknownArgumentType] + ) - with patch.dict(sys.modules, {"nicegui": nicegui_mock}): - page_internal(_TEST_PATH)(lambda user: None) # pyright: ignore[reportUnknownLambdaType] + with ( + patch.dict(sys.modules, {"nicegui": nicegui_mock}), + patch(_PATH_CORE_LOCATE, return_value=[]), + ): + gui_register_pages(context=make_context(), frame_func=fake_frame) - page_recorder.assert_called_once_with(_TEST_PATH, response_timeout=RESPONSE_TIMEOUT) + nicegui_mock.ui.page.assert_called_once_with(_TEST_PATH, response_timeout=RESPONSE_TIMEOUT) + assert len(wrappers) == 1 + request = MagicMock() + with patch(_PATCH_GET_GUI_USER, new=AsyncMock(return_value=None)): + asyncio.run(wrappers[0](request)) # type: ignore[arg-type] -@pytest.mark.unit -class TestPageInternalAdminRegistration: - """Tests for page_internal_admin route registration.""" + assert frame_entered == [True] - def test_registers_route_with_ui_page(self) -> None: - """page_internal_admin calls ui.page with the supplied path.""" - from aignostics_foundry_core.gui.auth import page_internal_admin + def test_gui_register_pages_without_frame_func_renders_without_error(self) -> None: + """gui_register_pages with frame_func=None actualizes the page without a frame.""" + from aignostics_foundry_core.gui.auth import page_public + + page_func_called: list[bool] = [] + + def my_page(user: object) -> None: + page_func_called.append(True) + + page_public(_TEST_PATH)(my_page) + + wrappers: list[object] = [] + nicegui_mock = MagicMock() + nicegui_mock.ui.page.side_effect = ( # pyright: ignore[reportUnknownMemberType] + lambda *a, **kw: lambda f: wrappers.append(f) or f # pyright: ignore[reportUnknownLambdaType, reportUnknownArgumentType] + ) + + with ( + patch.dict(sys.modules, {"nicegui": nicegui_mock}), + patch(_PATH_CORE_LOCATE, return_value=[]), + ): + gui_register_pages(context=make_context(), frame_func=None) + + assert len(wrappers) == 1 + request = MagicMock() + with patch(_PATCH_GET_GUI_USER, new=AsyncMock(return_value=None)): + asyncio.run(wrappers[0](request)) # type: ignore[arg-type] + + assert page_func_called == [True] + + def test_gui_register_pages_clears_registry_after_processing(self) -> None: + """Calling gui_register_pages twice only actualizes the page once (registry cleared).""" + from aignostics_foundry_core.gui.auth import page_public + + page_public(_TEST_PATH)(lambda user: None) # pyright: ignore[reportUnknownLambdaType] nicegui_mock, page_recorder = _make_nicegui_mock() - with patch.dict(sys.modules, {"nicegui": nicegui_mock}): - page_internal_admin(_TEST_PATH)(lambda user: None) # pyright: ignore[reportUnknownLambdaType] + with ( + patch.dict(sys.modules, {"nicegui": nicegui_mock}), + patch(_PATH_CORE_LOCATE, return_value=[]), + ): + gui_register_pages(context=make_context()) + gui_register_pages(context=make_context()) - page_recorder.assert_called_once_with(_TEST_PATH, response_timeout=RESPONSE_TIMEOUT) + page_recorder.assert_called_once() + + def test_gui_register_pages_frame_func_forwarded_from_gui_run(self) -> None: + """gui_run(frame_func=...) passes frame_func through to gui_register_pages.""" + from aignostics_foundry_core.gui.auth import page_public + + frame_entered: list[bool] = [] + + @contextmanager + def fake_frame(title: str, **_kw: object): # type: ignore[misc] + frame_entered.append(True) + yield + + page_public(_TEST_PATH)(lambda user: None) # pyright: ignore[reportUnknownLambdaType] + + nicegui_mock, _app_mock, _ = _make_nicegui_app_mock() + wrappers: list[object] = [] + nicegui_mock.ui.page.side_effect = ( # pyright: ignore[reportUnknownMemberType] + lambda *a, **kw: lambda f: wrappers.append(f) or f # pyright: ignore[reportUnknownLambdaType, reportUnknownArgumentType] + ) + + with ( + patch.dict(sys.modules, {"nicegui": nicegui_mock, _MODULE_STARLETTE_RESPONSES: MagicMock()}), + patch(_PATH_CORE_LOCATE, return_value=[]), + ): + gui_run(context=make_context(), frame_func=fake_frame) + + assert len(wrappers) == 1 + request = MagicMock() + with patch(_PATCH_GET_GUI_USER, new=AsyncMock(return_value=None)): + asyncio.run(wrappers[0](request)) # type: ignore[arg-type] + + assert frame_entered == [True] # --------------------------------------------------------------------------- @@ -733,8 +844,8 @@ def test_gui_exposes_all_decorator_methods(self) -> None: assert callable(gui.internal) assert callable(gui.internal_admin) - def test_public_method_delegates_to_page_public(self) -> None: - """GUINamespace.public registers a route via ui.page.""" + def test_public_method_delegates_to_actualize_immediately(self) -> None: + """GUINamespace.public registers a route via ui.page immediately, bypassing registry.""" from aignostics_foundry_core.gui.auth import GUINamespace nicegui_mock, page_recorder = _make_nicegui_mock() @@ -745,8 +856,8 @@ def test_public_method_delegates_to_page_public(self) -> None: page_recorder.assert_called_once_with(_TEST_PATH, response_timeout=RESPONSE_TIMEOUT) - def test_authenticated_method_delegates_to_page_authenticated(self) -> None: - """GUINamespace.authenticated registers a route via ui.page.""" + def test_authenticated_method_delegates_to_actualize_immediately(self) -> None: + """GUINamespace.authenticated registers a route via ui.page immediately.""" from aignostics_foundry_core.gui.auth import GUINamespace nicegui_mock, page_recorder = _make_nicegui_mock() @@ -759,8 +870,6 @@ def test_authenticated_method_delegates_to_page_authenticated(self) -> None: def test_frame_func_is_called_in_wrapper(self) -> None: """When frame_func is provided it is called inside the page wrapper.""" - import asyncio - from aignostics_foundry_core.gui.auth import GUINamespace frame_entered: list[bool] = [] @@ -790,3 +899,14 @@ def fake_frame(title: str, **_kw: object): # type: ignore[misc] asyncio.run(wrappers[0](request)) # type: ignore[arg-type] assert frame_entered == [True] + + def test_gui_run_frame_func_parameter_accepted(self) -> None: + """gui_run accepts frame_func kwarg without raising (parameter is wired up).""" + nicegui_mock, _, _ = _make_nicegui_app_mock() + fake_frame = MagicMock() + + with ( + patch.dict(sys.modules, {"nicegui": nicegui_mock, _MODULE_STARLETTE_RESPONSES: MagicMock()}), + patch(_PATH_CORE_LOCATE, return_value=[]), + ): + gui_run(context=make_context(), frame_func=fake_frame) # must not raise From ff9e918839882c57cd6b61c30a10069dfa913b10 Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Thu, 9 Apr 2026 11:44:50 +0200 Subject: [PATCH 2/3] test(gui): async page function invoked via registry Co-Authored-By: Claude Sonnet 4.6 --- src/aignostics_foundry_core/gui/auth.py | 40 ++++++++++++++++--- tests/aignostics_foundry_core/gui/gui_test.py | 31 ++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/aignostics_foundry_core/gui/auth.py b/src/aignostics_foundry_core/gui/auth.py index 9edf263..bf498e1 100644 --- a/src/aignostics_foundry_core/gui/auth.py +++ b/src/aignostics_foundry_core/gui/auth.py @@ -421,11 +421,12 @@ def page_public(path: str, title: str = "") -> Callable[..., Any]: Returns: A decorator that records the page function in the registry and returns - it unchanged. + it unchanged (sync or async). The function will be awaited automatically + when the page renders if it is a coroutine function. Example: @page_public("/") - def home(user: dict[str, Any] | None) -> None: + async def home(user: dict[str, Any] | None) -> None: ui.label("Welcome!") """ @@ -449,7 +450,13 @@ def page_authenticated(path: str, title: str = "") -> Callable[..., Any]: Returns: A decorator that records the page function in the registry and returns - it unchanged. + it unchanged (sync or async). The function will be awaited automatically + when the page renders if it is a coroutine function. + + Example: + @page_authenticated("/dashboard") + async def dashboard(user: dict[str, Any]) -> None: + ui.label(f"Hello, {user['name']}") """ def decorator(func: Callable[..., Any]) -> Callable[..., Any]: @@ -472,7 +479,13 @@ def page_admin(path: str, title: str = "") -> Callable[..., Any]: Returns: A decorator that records the page function in the registry and returns - it unchanged. + it unchanged (sync or async). The function will be awaited automatically + when the page renders if it is a coroutine function. + + Example: + @page_admin("/admin") + async def admin_page(user: dict[str, Any]) -> None: + ui.label(f"Admin panel for {user['name']}") """ def decorator(func: Callable[..., Any]) -> Callable[..., Any]: @@ -495,7 +508,13 @@ def page_internal(path: str, title: str = "") -> Callable[..., Any]: Returns: A decorator that records the page function in the registry and returns - it unchanged. + it unchanged (sync or async). The function will be awaited automatically + when the page renders if it is a coroutine function. + + Example: + @page_internal("/internal") + async def internal_page(user: dict[str, Any]) -> None: + ui.label(f"Internal tools for {user['name']}") """ def decorator(func: Callable[..., Any]) -> Callable[..., Any]: @@ -518,7 +537,13 @@ def page_internal_admin(path: str, title: str = "") -> Callable[..., Any]: Returns: A decorator that records the page function in the registry and returns - it unchanged. + it unchanged (sync or async). The function will be awaited automatically + when the page renders if it is a coroutine function. + + Example: + @page_internal_admin("/internal-admin") + async def internal_admin_page(user: dict[str, Any]) -> None: + ui.label(f"Internal admin panel for {user['name']}") """ def decorator(func: Callable[..., Any]) -> Callable[..., Any]: @@ -544,6 +569,9 @@ class GUINamespace: References: docs/decisions/0005-gui-page-registration.md + Both sync and async page functions are supported; async functions are + awaited automatically when the page renders. + Example: from aignostics_foundry_core.gui import gui diff --git a/tests/aignostics_foundry_core/gui/gui_test.py b/tests/aignostics_foundry_core/gui/gui_test.py index d182928..06d17d4 100644 --- a/tests/aignostics_foundry_core/gui/gui_test.py +++ b/tests/aignostics_foundry_core/gui/gui_test.py @@ -709,6 +709,37 @@ def my_page(user: object) -> None: ... assert result is my_page + def test_page_decorator_accepts_and_invokes_async_page_function(self) -> None: + """page_public works with async page functions: the coroutine is awaited on render.""" + from aignostics_foundry_core.gui.auth import page_public + + called: list[bool] = [] + + async def my_async_page(user: object) -> None: # noqa: RUF029 + called.append(True) + + result = page_public(_TEST_PATH)(my_async_page) + assert result is my_async_page # decorator returns original function unchanged + + wrappers: list[object] = [] + nicegui_mock = MagicMock() + nicegui_mock.ui.page.side_effect = ( + lambda *a, **kw: lambda f: wrappers.append(f) or f # pyright: ignore[reportUnknownLambdaType, reportUnknownArgumentType] + ) + + with ( + patch.dict(sys.modules, {"nicegui": nicegui_mock}), + patch(_PATH_CORE_LOCATE, return_value=[]), + ): + gui_register_pages(context=make_context(), frame_func=None) + + assert len(wrappers) == 1 + request = MagicMock() + with patch(_PATCH_GET_GUI_USER, new=AsyncMock(return_value=None)): + asyncio.run(wrappers[0](request)) # type: ignore[arg-type] + + assert called == [True] + def test_gui_register_pages_actualizes_registered_page_with_frame_func(self) -> None: """gui_register_pages processes registry and injects frame_func into the route.""" from aignostics_foundry_core.gui.auth import page_public From 6e808b91e1303890810398a798d8e5d2ceb2ffd4 Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Thu, 9 Apr 2026 11:50:13 +0200 Subject: [PATCH 3/3] refactor(gui): extract _actualize_via_register_pages helper Co-Authored-By: Claude Sonnet 4.6 --- tests/aignostics_foundry_core/gui/gui_test.py | 55 ++++++++----------- 1 file changed, 22 insertions(+), 33 deletions(-) diff --git a/tests/aignostics_foundry_core/gui/gui_test.py b/tests/aignostics_foundry_core/gui/gui_test.py index 06d17d4..93e31b7 100644 --- a/tests/aignostics_foundry_core/gui/gui_test.py +++ b/tests/aignostics_foundry_core/gui/gui_test.py @@ -644,6 +644,25 @@ def _clear_registry(self) -> Generator[None, None, None]: # pyright: ignore[rep yield clear_page_registry() + def _actualize_via_register_pages(self, frame_func: object = None) -> tuple[list[object], MagicMock]: + """Run gui_register_pages and return (wrappers, nicegui_mock). + + Builds a capturing NiceGUI mock whose ``ui.page`` side-effect appends + each registered async wrapper to *wrappers*, then calls + ``gui_register_pages`` with that mock injected and an empty builder list. + """ + wrappers: list[object] = [] + nicegui_mock = MagicMock() + nicegui_mock.ui.page.side_effect = ( # pyright: ignore[reportUnknownMemberType] + lambda *a, **kw: lambda f: wrappers.append(f) or f # pyright: ignore[reportUnknownLambdaType, reportUnknownArgumentType] + ) + with ( + patch.dict(sys.modules, {"nicegui": nicegui_mock}), + patch(_PATH_CORE_LOCATE, return_value=[]), + ): + gui_register_pages(context=make_context(), frame_func=frame_func) # type: ignore[arg-type] + return wrappers, nicegui_mock + def test_page_public_does_not_register_route_immediately(self) -> None: """page_public(path)(func) does NOT call ui.page; route deferred until gui_register_pages.""" from aignostics_foundry_core.gui.auth import page_public @@ -721,17 +740,7 @@ async def my_async_page(user: object) -> None: # noqa: RUF029 result = page_public(_TEST_PATH)(my_async_page) assert result is my_async_page # decorator returns original function unchanged - wrappers: list[object] = [] - nicegui_mock = MagicMock() - nicegui_mock.ui.page.side_effect = ( - lambda *a, **kw: lambda f: wrappers.append(f) or f # pyright: ignore[reportUnknownLambdaType, reportUnknownArgumentType] - ) - - with ( - patch.dict(sys.modules, {"nicegui": nicegui_mock}), - patch(_PATH_CORE_LOCATE, return_value=[]), - ): - gui_register_pages(context=make_context(), frame_func=None) + wrappers, _ = self._actualize_via_register_pages() assert len(wrappers) == 1 request = MagicMock() @@ -755,17 +764,7 @@ def my_page(user: object) -> None: ... page_public(_TEST_PATH)(my_page) - wrappers: list[object] = [] - nicegui_mock = MagicMock() - nicegui_mock.ui.page.side_effect = ( # pyright: ignore[reportUnknownMemberType] - lambda *a, **kw: lambda f: wrappers.append(f) or f # pyright: ignore[reportUnknownLambdaType, reportUnknownArgumentType] - ) - - with ( - patch.dict(sys.modules, {"nicegui": nicegui_mock}), - patch(_PATH_CORE_LOCATE, return_value=[]), - ): - gui_register_pages(context=make_context(), frame_func=fake_frame) + wrappers, nicegui_mock = self._actualize_via_register_pages(frame_func=fake_frame) nicegui_mock.ui.page.assert_called_once_with(_TEST_PATH, response_timeout=RESPONSE_TIMEOUT) assert len(wrappers) == 1 @@ -787,17 +786,7 @@ def my_page(user: object) -> None: page_public(_TEST_PATH)(my_page) - wrappers: list[object] = [] - nicegui_mock = MagicMock() - nicegui_mock.ui.page.side_effect = ( # pyright: ignore[reportUnknownMemberType] - lambda *a, **kw: lambda f: wrappers.append(f) or f # pyright: ignore[reportUnknownLambdaType, reportUnknownArgumentType] - ) - - with ( - patch.dict(sys.modules, {"nicegui": nicegui_mock}), - patch(_PATH_CORE_LOCATE, return_value=[]), - ): - gui_register_pages(context=make_context(), frame_func=None) + wrappers, _ = self._actualize_via_register_pages() assert len(wrappers) == 1 request = MagicMock()