Bladeren bron

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 5 maanden geleden
bovenliggende
commit
a5486335a3

+ 1 - 0
reflex/__init__.py

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

+ 1 - 0
reflex/__init__.pyi

@@ -19,6 +19,7 @@ from . import vars as vars
 from .admin import AdminDash as AdminDash
 from .app import App as App
 from .app import UploadFile as UploadFile
+from .assets import asset as asset
 from .base import Base as Base
 from .components import el as el
 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."""
 
-import inspect
-from pathlib import Path
 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:
-    """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.
     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.
         subfolder: The directory to place the asset in.
 
-    Raises:
-        FileNotFoundError: If the file does not exist.
-        ValueError: If the module is None.
-
     Returns:
         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()