Explorar o código

rx._x.asset improvements (#3624)

* wip rx._x.asset improvements

* only add symlink if it doesn't already exist

* minor improvements, add more tests

* use deprecated Generator for python3.8 support

* improve docstring

* only allow explicit shared, only validate local assets if not backend_only

* fix darglint

* allow setting backend only env to false.

* use new is_backend_only in assets

* ruffing

* Move to `rx.asset`, retain old API in `rx._x.asset`

---------

Co-authored-by: Masen Furer <m_github@0x26.net>
benedikt-bartscher hai 5 meses
pai
achega
a5486335a3

+ 1 - 0
reflex/__init__.py

@@ -264,6 +264,7 @@ _MAPPING: dict = {
     "experimental": ["_x"],
     "experimental": ["_x"],
     "admin": ["AdminDash"],
     "admin": ["AdminDash"],
     "app": ["App", "UploadFile"],
     "app": ["App", "UploadFile"],
+    "assets": ["asset"],
     "base": ["Base"],
     "base": ["Base"],
     "components.component": [
     "components.component": [
         "Component",
         "Component",

+ 1 - 0
reflex/__init__.pyi

@@ -19,6 +19,7 @@ from . import vars as vars
 from .admin import AdminDash as AdminDash
 from .admin import AdminDash as AdminDash
 from .app import App as App
 from .app import App as App
 from .app import UploadFile as UploadFile
 from .app import UploadFile as UploadFile
+from .assets import asset as asset
 from .base import Base as Base
 from .base import Base as Base
 from .components import el as el
 from .components import el as el
 from .components import lucide as lucide
 from .components import lucide as lucide

+ 95 - 0
reflex/assets.py

@@ -0,0 +1,95 @@
+"""Helper functions for adding assets to the app."""
+
+import inspect
+from pathlib import Path
+from typing import Optional
+
+from reflex import constants
+from reflex.utils.exec import is_backend_only
+
+
+def asset(
+    path: str,
+    shared: bool = False,
+    subfolder: Optional[str] = None,
+    _stack_level: int = 1,
+) -> str:
+    """Add an asset to the app, either shared as a symlink or local.
+
+    Shared/External/Library assets:
+        Place the file next to your including python file.
+        Links the file to the app's external assets directory.
+
+    Example:
+    ```python
+    # my_custom_javascript.js is a shared asset located next to the including python file.
+    rx.script(src=rx.asset(path="my_custom_javascript.js", shared=True))
+    rx.image(src=rx.asset(path="test_image.png", shared=True, subfolder="subfolder"))
+    ```
+
+    Local/Internal assets:
+        Place the file in the app's assets/ directory.
+
+    Example:
+    ```python
+    # local_image.png is an asset located in the app's assets/ directory. It cannot be shared when developing a library.
+    rx.image(src=rx.asset(path="local_image.png"))
+    ```
+
+    Args:
+        path: The relative path of the asset.
+        subfolder: The directory to place the shared asset in.
+        shared: Whether to expose the asset to other apps.
+        _stack_level: The stack level to determine the calling file, defaults to
+            the immediate caller 1. When using rx.asset via a helper function,
+            increase this number for each helper function in the stack.
+
+    Raises:
+        FileNotFoundError: If the file does not exist.
+        ValueError: If subfolder is provided for local assets.
+
+    Returns:
+        The relative URL to the asset.
+    """
+    assets = constants.Dirs.APP_ASSETS
+    backend_only = is_backend_only()
+
+    # Local asset handling
+    if not shared:
+        cwd = Path.cwd()
+        src_file_local = cwd / assets / path
+        if subfolder is not None:
+            raise ValueError("Subfolder is not supported for local assets.")
+        if not backend_only and not src_file_local.exists():
+            raise FileNotFoundError(f"File not found: {src_file_local}")
+        return f"/{path}"
+
+    # Shared asset handling
+    # Determine the file by which the asset is exposed.
+    frame = inspect.stack()[_stack_level]
+    calling_file = frame.filename
+    module = inspect.getmodule(frame[0])
+    assert module is not None
+
+    external = constants.Dirs.EXTERNAL_APP_ASSETS
+    src_file_shared = Path(calling_file).parent / path
+    if not src_file_shared.exists():
+        raise FileNotFoundError(f"File not found: {src_file_shared}")
+
+    caller_module_path = module.__name__.replace(".", "/")
+    subfolder = f"{caller_module_path}/{subfolder}" if subfolder else caller_module_path
+
+    # Symlink the asset to the app's external assets directory if running frontend.
+    if not backend_only:
+        # Create the asset folder in the currently compiling app.
+        asset_folder = Path.cwd() / assets / external / subfolder
+        asset_folder.mkdir(parents=True, exist_ok=True)
+
+        dst_file = asset_folder / path
+
+        if not dst_file.exists() and (
+            not dst_file.is_symlink() or dst_file.resolve() != src_file_shared.resolve()
+        ):
+            dst_file.symlink_to(src_file_shared)
+
+    return f"/{external}/{subfolder}/{path}"

+ 14 - 36
reflex/experimental/assets.py

@@ -1,14 +1,15 @@
 """Helper functions for adding assets to the app."""
 """Helper functions for adding assets to the app."""
 
 
-import inspect
-from pathlib import Path
 from typing import Optional
 from typing import Optional
 
 
-from reflex import constants
+from reflex import assets
+from reflex.utils import console
 
 
 
 
 def asset(relative_filename: str, subfolder: Optional[str] = None) -> str:
 def asset(relative_filename: str, subfolder: Optional[str] = None) -> str:
-    """Add an asset to the app.
+    """DEPRECATED: use `rx.asset` with `shared=True` instead.
+
+    Add an asset to the app.
     Place the file next to your including python file.
     Place the file next to your including python file.
     Copies the file to the app's external assets directory.
     Copies the file to the app's external assets directory.
 
 
@@ -22,38 +23,15 @@ def asset(relative_filename: str, subfolder: Optional[str] = None) -> str:
         relative_filename: The relative filename of the asset.
         relative_filename: The relative filename of the asset.
         subfolder: The directory to place the asset in.
         subfolder: The directory to place the asset in.
 
 
-    Raises:
-        FileNotFoundError: If the file does not exist.
-        ValueError: If the module is None.
-
     Returns:
     Returns:
         The relative URL to the copied asset.
         The relative URL to the copied asset.
     """
     """
-    # Determine the file by which the asset is exposed.
-    calling_file = inspect.stack()[1].filename
-    module = inspect.getmodule(inspect.stack()[1][0])
-    if module is None:
-        raise ValueError("Module is None")
-    caller_module_path = module.__name__.replace(".", "/")
-
-    subfolder = f"{caller_module_path}/{subfolder}" if subfolder else caller_module_path
-
-    src_file = Path(calling_file).parent / relative_filename
-
-    assets = constants.Dirs.APP_ASSETS
-    external = constants.Dirs.EXTERNAL_APP_ASSETS
-
-    if not src_file.exists():
-        raise FileNotFoundError(f"File not found: {src_file}")
-
-    # Create the asset folder in the currently compiling app.
-    asset_folder = Path.cwd() / assets / external / subfolder
-    asset_folder.mkdir(parents=True, exist_ok=True)
-
-    dst_file = asset_folder / relative_filename
-
-    if not dst_file.exists():
-        dst_file.symlink_to(src_file)
-
-    asset_url = f"/{external}/{subfolder}/{relative_filename}"
-    return asset_url
+    console.deprecate(
+        feature_name="rx._x.asset",
+        reason="Use `rx.asset` with `shared=True` instead of `rx._x.asset`.",
+        deprecation_version="0.6.6",
+        removal_version="0.7.0",
+    )
+    return assets.asset(
+        relative_filename, shared=True, subfolder=subfolder, _stack_level=2
+    )

+ 0 - 0
tests/units/experimental/custom_script.js → tests/units/assets/custom_script.js


+ 94 - 0
tests/units/assets/test_assets.py

@@ -0,0 +1,94 @@
+import shutil
+from pathlib import Path
+from typing import Generator
+
+import pytest
+
+import reflex as rx
+import reflex.constants as constants
+
+
+def test_shared_asset() -> None:
+    """Test shared assets."""
+    # The asset function copies a file to the app's external assets directory.
+    asset = rx.asset(path="custom_script.js", shared=True, subfolder="subfolder")
+    assert asset == "/external/test_assets/subfolder/custom_script.js"
+    result_file = Path(
+        Path.cwd(), "assets/external/test_assets/subfolder/custom_script.js"
+    )
+    assert result_file.exists()
+
+    # Running a second time should not raise an error.
+    asset = rx.asset(path="custom_script.js", shared=True, subfolder="subfolder")
+
+    # Test the asset function without a subfolder.
+    asset = rx.asset(path="custom_script.js", shared=True)
+    assert asset == "/external/test_assets/custom_script.js"
+    result_file = Path(Path.cwd(), "assets/external/test_assets/custom_script.js")
+    assert result_file.exists()
+
+    # clean up
+    shutil.rmtree(Path.cwd() / "assets/external")
+
+    with pytest.raises(FileNotFoundError):
+        asset = rx.asset("non_existent_file.js")
+
+    # Nothing is done to assets when file does not exist.
+    assert not Path(Path.cwd() / "assets/external").exists()
+
+
+def test_deprecated_x_asset(capsys) -> None:
+    """Test that the deprecated asset function raises a warning.
+
+    Args:
+        capsys: Pytest fixture that captures stdout and stderr.
+    """
+    assert rx.asset("custom_script.js", shared=True) == rx._x.asset("custom_script.js")
+    assert (
+        "DeprecationWarning: rx._x.asset has been deprecated in version 0.6.6"
+        in capsys.readouterr().out
+    )
+
+
+@pytest.mark.parametrize(
+    "path,shared",
+    [
+        pytest.param("non_existing_file", True),
+        pytest.param("non_existing_file", False),
+    ],
+)
+def test_invalid_assets(path: str, shared: bool) -> None:
+    """Test that asset raises an error when the file does not exist.
+
+    Args:
+        path: The path to the asset.
+        shared: Whether the asset should be shared.
+    """
+    with pytest.raises(FileNotFoundError):
+        _ = rx.asset(path, shared=shared)
+
+
+@pytest.fixture
+def custom_script_in_asset_dir() -> Generator[Path, None, None]:
+    """Create a custom_script.js file in the app's assets directory.
+
+    Yields:
+        The path to the custom_script.js file.
+    """
+    asset_dir = Path.cwd() / constants.Dirs.APP_ASSETS
+    asset_dir.mkdir(exist_ok=True)
+    path = asset_dir / "custom_script.js"
+    path.touch()
+    yield path
+    path.unlink()
+
+
+def test_local_asset(custom_script_in_asset_dir: Path) -> None:
+    """Test that no error is raised if shared is set and both files exist.
+
+    Args:
+        custom_script_in_asset_dir: Fixture that creates a custom_script.js file in the app's assets directory.
+
+    """
+    asset = rx.asset("custom_script.js", shared=False)
+    assert asset == "/custom_script.js"

+ 0 - 36
tests/units/experimental/test_assets.py

@@ -1,36 +0,0 @@
-import shutil
-from pathlib import Path
-
-import pytest
-
-import reflex as rx
-
-
-def test_asset():
-    # Test the asset function.
-
-    # The asset function copies a file to the app's external assets directory.
-    asset = rx._x.asset("custom_script.js", "subfolder")
-    assert asset == "/external/test_assets/subfolder/custom_script.js"
-    result_file = Path(
-        Path.cwd(), "assets/external/test_assets/subfolder/custom_script.js"
-    )
-    assert result_file.exists()
-
-    # Running a second time should not raise an error.
-    asset = rx._x.asset("custom_script.js", "subfolder")
-
-    # Test the asset function without a subfolder.
-    asset = rx._x.asset("custom_script.js")
-    assert asset == "/external/test_assets/custom_script.js"
-    result_file = Path(Path.cwd(), "assets/external/test_assets/custom_script.js")
-    assert result_file.exists()
-
-    # clean up
-    shutil.rmtree(Path.cwd() / "assets/external")
-
-    with pytest.raises(FileNotFoundError):
-        asset = rx._x.asset("non_existent_file.js")
-
-    # Nothing is done to assets when file does not exist.
-    assert not Path(Path.cwd() / "assets/external").exists()