From a5486335a35c70d5656e24c500d9cd1e04eac37e Mon Sep 17 00:00:00 2001 From: benedikt-bartscher <31854409+benedikt-bartscher@users.noreply.github.com> Date: Fri, 22 Nov 2024 02:16:43 +0100 Subject: [PATCH] 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 --- reflex/__init__.py | 1 + reflex/__init__.pyi | 1 + reflex/assets.py | 95 +++++++++++++++++++ reflex/experimental/assets.py | 50 +++------- .../{experimental => assets}/custom_script.js | 0 tests/units/assets/test_assets.py | 94 ++++++++++++++++++ tests/units/experimental/test_assets.py | 36 ------- 7 files changed, 205 insertions(+), 72 deletions(-) create mode 100644 reflex/assets.py rename tests/units/{experimental => assets}/custom_script.js (100%) create mode 100644 tests/units/assets/test_assets.py delete mode 100644 tests/units/experimental/test_assets.py diff --git a/reflex/__init__.py b/reflex/__init__.py index 3941542f2..562524416 100644 --- a/reflex/__init__.py +++ b/reflex/__init__.py @@ -264,6 +264,7 @@ _MAPPING: dict = { "experimental": ["_x"], "admin": ["AdminDash"], "app": ["App", "UploadFile"], + "assets": ["asset"], "base": ["Base"], "components.component": [ "Component", diff --git a/reflex/__init__.pyi b/reflex/__init__.pyi index 30a498db4..6f61435e6 100644 --- a/reflex/__init__.pyi +++ b/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 diff --git a/reflex/assets.py b/reflex/assets.py new file mode 100644 index 000000000..8a50664b6 --- /dev/null +++ b/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}" diff --git a/reflex/experimental/assets.py b/reflex/experimental/assets.py index dcf386d8d..e9be19aaf 100644 --- a/reflex/experimental/assets.py +++ b/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 + ) diff --git a/tests/units/experimental/custom_script.js b/tests/units/assets/custom_script.js similarity index 100% rename from tests/units/experimental/custom_script.js rename to tests/units/assets/custom_script.js diff --git a/tests/units/assets/test_assets.py b/tests/units/assets/test_assets.py new file mode 100644 index 000000000..b957f1902 --- /dev/null +++ b/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" diff --git a/tests/units/experimental/test_assets.py b/tests/units/experimental/test_assets.py deleted file mode 100644 index 8037bcc75..000000000 --- a/tests/units/experimental/test_assets.py +++ /dev/null @@ -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()