浏览代码

Introduce "Dynamic Resources" for robust inclusion of `codehilite.css` against match-all `@ui.page("/{_:path}")` (#4778)

This PR fix #4774 by introducing the concept of "Dynamic Resources",
resources whose `Response` (and thus content) are provided as return
value by a callable.

This is a new and unique mechanism compared to
libraries/components/resources, which are all static and
served-from-the-file.

#### Reason for dynamic generation

This is because we have established that we'd like to keep dynamically
generating the Codehilite CSS such that pygments styling work
appropriately in
https://github.com/zauberzeug/nicegui/issues/4774#issuecomment-2897824724

#### Code implementation highlights

This implementation is much better than the
`core.app.get(CODEHILITE_CSS_URL)(lambda: PlainTextResponse(...)`, in a
sense that:

- Properly uses `app.get` as a decorator, instead of a sketchy way. 
- Definition and serving of dynamic resources is centralized. 
- Works against match-all `@ui.page("/{_:path}")`, as much as the rest
of the resources work.

The dynamic resources are available at
`f'/_nicegui/{__version__}/dynamic_resources/{{filename}}'`. Note that
there is no `key`, since we don't need to do cachebusting since we
should always serve these resources with caching turned off.

#### Notable point

In #4540, a middleware will be introduced. We need to modify it so that
`dynamic_resources` does not get the long `cache_control` header.

---------

Co-authored-by: Falko Schindler <falko@zauberzeug.com>
Evan Chan 2 天之前
父节点
当前提交
bfd187018c
共有 6 个文件被更改,包括 50 次插入17 次删除
  1. 14 1
      nicegui/dependencies.py
  2. 19 3
      nicegui/element.py
  3. 2 2
      nicegui/elements/markdown.js
  4. 5 8
      nicegui/elements/markdown.py
  5. 8 1
      nicegui/nicegui.py
  6. 2 2
      tests/test_endpoint_docs.py

+ 14 - 1
nicegui/dependencies.py

@@ -3,7 +3,7 @@ from __future__ import annotations
 import functools
 from dataclasses import dataclass
 from pathlib import Path
-from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Set, Tuple
+from typing import TYPE_CHECKING, Callable, Dict, Iterable, List, Optional, Set, Tuple
 
 import vbuild
 
@@ -45,6 +45,12 @@ class Resource:
     path: Path
 
 
+@dataclass(**KWONLY_SLOTS)
+class DynamicResource:
+    name: str
+    function: Callable
+
+
 @dataclass(**KWONLY_SLOTS)
 class Library:
     key: str
@@ -57,6 +63,7 @@ vue_components: Dict[str, VueComponent] = {}
 js_components: Dict[str, JsComponent] = {}
 libraries: Dict[str, Library] = {}
 resources: Dict[str, Resource] = {}
+dynamic_resources: Dict[str, DynamicResource] = {}
 
 
 def register_vue_component(path: Path, *, max_time: Optional[float]) -> Component:
@@ -107,6 +114,12 @@ def register_resource(path: Path, *, max_time: Optional[float]) -> Resource:
     return resources[key]
 
 
+def register_dynamic_resource(name: str, function: Callable) -> DynamicResource:
+    """Register a dynamic resource which returns the result of a function."""
+    dynamic_resources[name] = DynamicResource(name=name, function=function)
+    return dynamic_resources[name]
+
+
 @functools.lru_cache(maxsize=None)
 def compute_key(path: Path, *, max_time: Optional[float]) -> str:
     """Compute a key for a given path using a hash function.

+ 19 - 3
nicegui/element.py

@@ -4,7 +4,7 @@ import inspect
 import re
 from copy import copy
 from pathlib import Path
-from typing import TYPE_CHECKING, Any, ClassVar, Dict, Iterator, List, Optional, Sequence, Union, cast
+from typing import TYPE_CHECKING, Any, Callable, ClassVar, Dict, Iterator, List, Optional, Sequence, Union, cast
 
 from typing_extensions import Self
 
@@ -12,7 +12,14 @@ from . import core, events, helpers, json, storage
 from .awaitable_response import AwaitableResponse, NullResponse
 from .classes import Classes
 from .context import context
-from .dependencies import Component, Library, register_library, register_resource, register_vue_component
+from .dependencies import (
+    Component,
+    Library,
+    register_dynamic_resource,
+    register_library,
+    register_resource,
+    register_vue_component,
+)
 from .elements.mixins.visibility import Visibility
 from .event_listener import EventListener
 from .props import Props
@@ -148,6 +155,15 @@ class Element(Visibility):
         resource = register_resource(path_, max_time=path_.stat().st_mtime)
         self._props['resource_path'] = f'/_nicegui/{__version__}/resources/{resource.key}'
 
+    def add_dynamic_resource(self, name: str, function: Callable) -> None:
+        """Add a dynamic resource to the element which returns the result of a function.
+
+        :param name: name of the resource
+        :param function: function that returns the resource response
+        """
+        register_dynamic_resource(name, function)
+        self._props['dynamic_resource_path'] = f'/_nicegui/{__version__}/dynamic_resources'
+
     def add_slot(self, name: str, template: Optional[str] = None) -> Slot:
         """Add a slot to the element.
 
@@ -520,7 +536,7 @@ class Element(Visibility):
             additions.append(f'text={shorten(self._text)}')
         if hasattr(self, 'content') and self.content:  # pylint: disable=no-member
             additions.append(f'content={shorten(self.content)}')  # pylint: disable=no-member
-        IGNORED_PROPS = {'loopback', 'color', 'view', 'innerHTML', 'codehilite_css_url'}
+        IGNORED_PROPS = {'loopback', 'color', 'view', 'innerHTML', 'dynamic_resource_path'}
         additions += [
             f'{key}={shorten(value)}'
             for key, value in self._props.items()

+ 2 - 2
nicegui/elements/markdown.js

@@ -4,7 +4,7 @@ export default {
   template: `<div></div>`,
   async mounted() {
     await this.$nextTick(); // NOTE: wait for window.path_prefix to be set
-    await loadResource(window.path_prefix + this.codehilite_css_url);
+    await loadResource(window.path_prefix + `${this.dynamic_resource_path}/codehilite.css`);
     if (this.use_mermaid) {
       this.mermaid = (await import("mermaid")).default;
       this.mermaid.initialize({ startOnLoad: false });
@@ -52,7 +52,7 @@ export default {
     },
   },
   props: {
-    codehilite_css_url: String,
+    dynamic_resource_path: String,
     use_mermaid: {
       required: false,
       default: false,

+ 5 - 8
nicegui/elements/markdown.py

@@ -6,12 +6,8 @@ import markdown2
 from fastapi.responses import PlainTextResponse
 from pygments.formatters import HtmlFormatter  # pylint: disable=no-name-in-module
 
-from .. import core
-from ..version import __version__
 from .mixins.content_element import ContentElement
 
-CODEHILITE_CSS_URL = f'/_nicegui/{__version__}/codehilite.css'
-
 
 class Markdown(ContentElement, component='markdown.js', default_classes='nicegui-markdown'):
 
@@ -31,13 +27,14 @@ class Markdown(ContentElement, component='markdown.js', default_classes='nicegui
         if 'mermaid' in extras:
             self._props['use_mermaid'] = True
 
-        self._props['codehilite_css_url'] = CODEHILITE_CSS_URL
-        if not any(r for r in core.app.routes if getattr(r, 'path', None) == CODEHILITE_CSS_URL):
-            core.app.get(CODEHILITE_CSS_URL)(lambda: PlainTextResponse(
+        self.add_dynamic_resource(
+            'codehilite.css',
+            lambda: PlainTextResponse(
                 HtmlFormatter(nobackground=True).get_style_defs('.codehilite') +
                 HtmlFormatter(nobackground=True, style='github-dark').get_style_defs('.body--dark .codehilite'),
                 media_type='text/css',
-            ))
+            ),
+        )
 
     def _handle_content_change(self, content: str) -> None:
         html = prepare_content(content, extras=' '.join(self.extras))

+ 8 - 1
nicegui/nicegui.py

@@ -12,7 +12,7 @@ from fastapi.responses import FileResponse, Response
 from . import air, background_tasks, binding, core, favicon, helpers, json, run, welcome
 from .app import App
 from .client import Client
-from .dependencies import js_components, libraries, resources
+from .dependencies import dynamic_resources, js_components, libraries, resources
 from .error import error_content
 from .json import NiceGUIJSONResponse
 from .logging import log
@@ -103,6 +103,13 @@ def _get_resource(key: str, path: str) -> FileResponse:
     raise HTTPException(status_code=404, detail=f'resource "{key}" not found')
 
 
+@app.get(f'/_nicegui/{__version__}' + '/dynamic_resources/{name}')
+def _get_dynamic_resource(name: str) -> Response:
+    if name in dynamic_resources:
+        return dynamic_resources[name].function()
+    raise HTTPException(status_code=404, detail=f'dynamic resource "{name}" not found')
+
+
 async def _startup() -> None:
     """Handle the startup event."""
     if not app.config.has_run_config:

+ 2 - 2
tests/test_endpoint_docs.py

@@ -33,10 +33,10 @@ def test_endpoint_documentation_internal_only(screen: Screen):
 
     screen.open('/')
     assert get_openapi_paths() == {
-        f'/_nicegui/{__version__}/codehilite.css',
         f'/_nicegui/{__version__}/libraries/{{key}}',
         f'/_nicegui/{__version__}/components/{{key}}',
         f'/_nicegui/{__version__}/resources/{{key}}/{{path}}',
+        f'/_nicegui/{__version__}/dynamic_resources/{{name}}',
     }
 
 
@@ -47,8 +47,8 @@ def test_endpoint_documentation_all(screen: Screen):
     screen.open('/')
     assert get_openapi_paths() == {
         '/',
-        f'/_nicegui/{__version__}/codehilite.css',
         f'/_nicegui/{__version__}/libraries/{{key}}',
         f'/_nicegui/{__version__}/components/{{key}}',
         f'/_nicegui/{__version__}/resources/{{key}}/{{path}}',
+        f'/_nicegui/{__version__}/dynamic_resources/{{name}}',
     }