Skip to content

Commit a548633

Browse files
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 <[email protected]>
1 parent a6b324b commit a548633

File tree

7 files changed

+205
-72
lines changed

7 files changed

+205
-72
lines changed

reflex/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@
264264
"experimental": ["_x"],
265265
"admin": ["AdminDash"],
266266
"app": ["App", "UploadFile"],
267+
"assets": ["asset"],
267268
"base": ["Base"],
268269
"components.component": [
269270
"Component",

reflex/__init__.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ from . import vars as vars
1919
from .admin import AdminDash as AdminDash
2020
from .app import App as App
2121
from .app import UploadFile as UploadFile
22+
from .assets import asset as asset
2223
from .base import Base as Base
2324
from .components import el as el
2425
from .components import lucide as lucide

reflex/assets.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Helper functions for adding assets to the app."""
2+
3+
import inspect
4+
from pathlib import Path
5+
from typing import Optional
6+
7+
from reflex import constants
8+
from reflex.utils.exec import is_backend_only
9+
10+
11+
def asset(
12+
path: str,
13+
shared: bool = False,
14+
subfolder: Optional[str] = None,
15+
_stack_level: int = 1,
16+
) -> str:
17+
"""Add an asset to the app, either shared as a symlink or local.
18+
19+
Shared/External/Library assets:
20+
Place the file next to your including python file.
21+
Links the file to the app's external assets directory.
22+
23+
Example:
24+
```python
25+
# my_custom_javascript.js is a shared asset located next to the including python file.
26+
rx.script(src=rx.asset(path="my_custom_javascript.js", shared=True))
27+
rx.image(src=rx.asset(path="test_image.png", shared=True, subfolder="subfolder"))
28+
```
29+
30+
Local/Internal assets:
31+
Place the file in the app's assets/ directory.
32+
33+
Example:
34+
```python
35+
# local_image.png is an asset located in the app's assets/ directory. It cannot be shared when developing a library.
36+
rx.image(src=rx.asset(path="local_image.png"))
37+
```
38+
39+
Args:
40+
path: The relative path of the asset.
41+
subfolder: The directory to place the shared asset in.
42+
shared: Whether to expose the asset to other apps.
43+
_stack_level: The stack level to determine the calling file, defaults to
44+
the immediate caller 1. When using rx.asset via a helper function,
45+
increase this number for each helper function in the stack.
46+
47+
Raises:
48+
FileNotFoundError: If the file does not exist.
49+
ValueError: If subfolder is provided for local assets.
50+
51+
Returns:
52+
The relative URL to the asset.
53+
"""
54+
assets = constants.Dirs.APP_ASSETS
55+
backend_only = is_backend_only()
56+
57+
# Local asset handling
58+
if not shared:
59+
cwd = Path.cwd()
60+
src_file_local = cwd / assets / path
61+
if subfolder is not None:
62+
raise ValueError("Subfolder is not supported for local assets.")
63+
if not backend_only and not src_file_local.exists():
64+
raise FileNotFoundError(f"File not found: {src_file_local}")
65+
return f"/{path}"
66+
67+
# Shared asset handling
68+
# Determine the file by which the asset is exposed.
69+
frame = inspect.stack()[_stack_level]
70+
calling_file = frame.filename
71+
module = inspect.getmodule(frame[0])
72+
assert module is not None
73+
74+
external = constants.Dirs.EXTERNAL_APP_ASSETS
75+
src_file_shared = Path(calling_file).parent / path
76+
if not src_file_shared.exists():
77+
raise FileNotFoundError(f"File not found: {src_file_shared}")
78+
79+
caller_module_path = module.__name__.replace(".", "/")
80+
subfolder = f"{caller_module_path}/{subfolder}" if subfolder else caller_module_path
81+
82+
# Symlink the asset to the app's external assets directory if running frontend.
83+
if not backend_only:
84+
# Create the asset folder in the currently compiling app.
85+
asset_folder = Path.cwd() / assets / external / subfolder
86+
asset_folder.mkdir(parents=True, exist_ok=True)
87+
88+
dst_file = asset_folder / path
89+
90+
if not dst_file.exists() and (
91+
not dst_file.is_symlink() or dst_file.resolve() != src_file_shared.resolve()
92+
):
93+
dst_file.symlink_to(src_file_shared)
94+
95+
return f"/{external}/{subfolder}/{path}"

reflex/experimental/assets.py

Lines changed: 14 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
"""Helper functions for adding assets to the app."""
22

3-
import inspect
4-
from pathlib import Path
53
from typing import Optional
64

7-
from reflex import constants
5+
from reflex import assets
6+
from reflex.utils import console
87

98

109
def asset(relative_filename: str, subfolder: Optional[str] = None) -> str:
11-
"""Add an asset to the app.
10+
"""DEPRECATED: use `rx.asset` with `shared=True` instead.
11+
12+
Add an asset to the app.
1213
Place the file next to your including python file.
1314
Copies the file to the app's external assets directory.
1415
@@ -22,38 +23,15 @@ def asset(relative_filename: str, subfolder: Optional[str] = None) -> str:
2223
relative_filename: The relative filename of the asset.
2324
subfolder: The directory to place the asset in.
2425
25-
Raises:
26-
FileNotFoundError: If the file does not exist.
27-
ValueError: If the module is None.
28-
2926
Returns:
3027
The relative URL to the copied asset.
3128
"""
32-
# Determine the file by which the asset is exposed.
33-
calling_file = inspect.stack()[1].filename
34-
module = inspect.getmodule(inspect.stack()[1][0])
35-
if module is None:
36-
raise ValueError("Module is None")
37-
caller_module_path = module.__name__.replace(".", "/")
38-
39-
subfolder = f"{caller_module_path}/{subfolder}" if subfolder else caller_module_path
40-
41-
src_file = Path(calling_file).parent / relative_filename
42-
43-
assets = constants.Dirs.APP_ASSETS
44-
external = constants.Dirs.EXTERNAL_APP_ASSETS
45-
46-
if not src_file.exists():
47-
raise FileNotFoundError(f"File not found: {src_file}")
48-
49-
# Create the asset folder in the currently compiling app.
50-
asset_folder = Path.cwd() / assets / external / subfolder
51-
asset_folder.mkdir(parents=True, exist_ok=True)
52-
53-
dst_file = asset_folder / relative_filename
54-
55-
if not dst_file.exists():
56-
dst_file.symlink_to(src_file)
57-
58-
asset_url = f"/{external}/{subfolder}/{relative_filename}"
59-
return asset_url
29+
console.deprecate(
30+
feature_name="rx._x.asset",
31+
reason="Use `rx.asset` with `shared=True` instead of `rx._x.asset`.",
32+
deprecation_version="0.6.6",
33+
removal_version="0.7.0",
34+
)
35+
return assets.asset(
36+
relative_filename, shared=True, subfolder=subfolder, _stack_level=2
37+
)

tests/units/assets/test_assets.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import shutil
2+
from pathlib import Path
3+
from typing import Generator
4+
5+
import pytest
6+
7+
import reflex as rx
8+
import reflex.constants as constants
9+
10+
11+
def test_shared_asset() -> None:
12+
"""Test shared assets."""
13+
# The asset function copies a file to the app's external assets directory.
14+
asset = rx.asset(path="custom_script.js", shared=True, subfolder="subfolder")
15+
assert asset == "/external/test_assets/subfolder/custom_script.js"
16+
result_file = Path(
17+
Path.cwd(), "assets/external/test_assets/subfolder/custom_script.js"
18+
)
19+
assert result_file.exists()
20+
21+
# Running a second time should not raise an error.
22+
asset = rx.asset(path="custom_script.js", shared=True, subfolder="subfolder")
23+
24+
# Test the asset function without a subfolder.
25+
asset = rx.asset(path="custom_script.js", shared=True)
26+
assert asset == "/external/test_assets/custom_script.js"
27+
result_file = Path(Path.cwd(), "assets/external/test_assets/custom_script.js")
28+
assert result_file.exists()
29+
30+
# clean up
31+
shutil.rmtree(Path.cwd() / "assets/external")
32+
33+
with pytest.raises(FileNotFoundError):
34+
asset = rx.asset("non_existent_file.js")
35+
36+
# Nothing is done to assets when file does not exist.
37+
assert not Path(Path.cwd() / "assets/external").exists()
38+
39+
40+
def test_deprecated_x_asset(capsys) -> None:
41+
"""Test that the deprecated asset function raises a warning.
42+
43+
Args:
44+
capsys: Pytest fixture that captures stdout and stderr.
45+
"""
46+
assert rx.asset("custom_script.js", shared=True) == rx._x.asset("custom_script.js")
47+
assert (
48+
"DeprecationWarning: rx._x.asset has been deprecated in version 0.6.6"
49+
in capsys.readouterr().out
50+
)
51+
52+
53+
@pytest.mark.parametrize(
54+
"path,shared",
55+
[
56+
pytest.param("non_existing_file", True),
57+
pytest.param("non_existing_file", False),
58+
],
59+
)
60+
def test_invalid_assets(path: str, shared: bool) -> None:
61+
"""Test that asset raises an error when the file does not exist.
62+
63+
Args:
64+
path: The path to the asset.
65+
shared: Whether the asset should be shared.
66+
"""
67+
with pytest.raises(FileNotFoundError):
68+
_ = rx.asset(path, shared=shared)
69+
70+
71+
@pytest.fixture
72+
def custom_script_in_asset_dir() -> Generator[Path, None, None]:
73+
"""Create a custom_script.js file in the app's assets directory.
74+
75+
Yields:
76+
The path to the custom_script.js file.
77+
"""
78+
asset_dir = Path.cwd() / constants.Dirs.APP_ASSETS
79+
asset_dir.mkdir(exist_ok=True)
80+
path = asset_dir / "custom_script.js"
81+
path.touch()
82+
yield path
83+
path.unlink()
84+
85+
86+
def test_local_asset(custom_script_in_asset_dir: Path) -> None:
87+
"""Test that no error is raised if shared is set and both files exist.
88+
89+
Args:
90+
custom_script_in_asset_dir: Fixture that creates a custom_script.js file in the app's assets directory.
91+
92+
"""
93+
asset = rx.asset("custom_script.js", shared=False)
94+
assert asset == "/custom_script.js"

tests/units/experimental/test_assets.py

Lines changed: 0 additions & 36 deletions
This file was deleted.

0 commit comments

Comments
 (0)