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>
This commit is contained in:
parent
a6b324bd3e
commit
a5486335a3
@ -264,6 +264,7 @@ _MAPPING: dict = {
|
||||
"experimental": ["_x"],
|
||||
"admin": ["AdminDash"],
|
||||
"app": ["App", "UploadFile"],
|
||||
"assets": ["asset"],
|
||||
"base": ["Base"],
|
||||
"components.component": [
|
||||
"Component",
|
||||
|
@ -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
reflex/assets.py
Normal file
95
reflex/assets.py
Normal file
@ -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}"
|
@ -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
|
||||
)
|
||||
|
94
tests/units/assets/test_assets.py
Normal file
94
tests/units/assets/test_assets.py
Normal file
@ -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"
|
@ -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()
|
Loading…
Reference in New Issue
Block a user