Skip to content

Commit 0d9f0c5

Browse files
authored
Expose .importable_path on the value returned from rx.asset (#6348)
* Expose `.importable_path` on the value returned from rx.asset This allows components to reference the internal path in the `.web` directory for use with JS imports at compile time. * handle picklability of AssetPathStr
1 parent b9f3570 commit 0d9f0c5

File tree

2 files changed

+170
-5
lines changed

2 files changed

+170
-5
lines changed

reflex/assets.py

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,93 @@
22

33
import inspect
44
from pathlib import Path
5+
from typing import TYPE_CHECKING, overload
56

67
from reflex_base import constants
78
from reflex_base.config import get_config
89
from reflex_base.environment import EnvironmentVariables
910

11+
if TYPE_CHECKING:
12+
from typing_extensions import Buffer
13+
14+
15+
class AssetPathStr(str):
16+
"""The relative URL to an asset, with a build-time importable variant.
17+
18+
Returned by :func:`asset`. The string value is the asset URL with the
19+
configured ``frontend_path`` prepended; :attr:`importable_path` is the
20+
same asset prefixed with ``$/public`` so the asset can be referenced by
21+
a component ``library`` or module import at build time.
22+
23+
The constructor signature mirrors :class:`str`: the input is interpreted
24+
as the unprefixed asset path and both forms are derived from it at
25+
construction time.
26+
"""
27+
28+
__slots__ = ("importable_path",)
29+
30+
importable_path: str
31+
32+
@overload
33+
def __new__(cls, object: object = "") -> "AssetPathStr": ...
34+
@overload
35+
def __new__(
36+
cls,
37+
object: "Buffer",
38+
encoding: str = "utf-8",
39+
errors: str = "strict",
40+
) -> "AssetPathStr": ...
41+
42+
def __new__(
43+
cls,
44+
object: object = "",
45+
encoding: str | None = None,
46+
errors: str | None = None,
47+
) -> "AssetPathStr":
48+
"""Construct from an unprefixed, leading-slash asset path.
49+
50+
Args/semantics mirror :class:`str`. The resulting string is interpreted
51+
as the asset path (e.g. ``"/external/mod/file.js"``); the
52+
frontend-prefixed URL is stored as the ``AssetPathStr`` value and
53+
``$/public`` + ``relative_path`` as :attr:`importable_path`.
54+
55+
Args:
56+
object: The object to stringify (str, bytes, or any object).
57+
encoding: Encoding to decode ``object`` with when it is bytes-like.
58+
errors: Error handler for decoding.
59+
60+
Returns:
61+
A new ``AssetPathStr`` instance.
62+
"""
63+
if encoding is None and errors is None:
64+
relative_path = str.__new__(str, object)
65+
else:
66+
relative_path = str.__new__(
67+
str,
68+
object, # pyright: ignore[reportArgumentType]
69+
"utf-8" if encoding is None else encoding,
70+
"strict" if errors is None else errors,
71+
)
72+
instance = super().__new__(
73+
cls, get_config().prepend_frontend_path(relative_path)
74+
)
75+
instance.importable_path = f"$/public{relative_path}"
76+
return instance
77+
78+
def __getnewargs__(self) -> tuple[str]:
79+
"""Return the unprefixed path for pickle/copy reconstruction.
80+
81+
Python's default ``str`` pickle path would feed the frontend-prefixed
82+
value back into :meth:`__new__`, double-applying the prefix and
83+
losing the :attr:`importable_path` slot. Returning the raw path
84+
(recovered by stripping the ``$/public`` prefix) lets ``__new__``
85+
rebuild both forms correctly.
86+
87+
Returns:
88+
A one-tuple containing the unprefixed asset path.
89+
"""
90+
return (self.importable_path[len("$/public") :],)
91+
1092

1193
def remove_stale_external_asset_symlinks():
1294
"""Remove broken symlinks and empty directories in assets/external/.
@@ -42,7 +124,7 @@ def asset(
42124
shared: bool = False,
43125
subfolder: str | None = None,
44126
_stack_level: int = 1,
45-
) -> str:
127+
) -> AssetPathStr:
46128
"""Add an asset to the app, either shared as a symlink or local.
47129
48130
Shared/External/Library assets:
@@ -74,7 +156,8 @@ def asset(
74156
increase this number for each helper function in the stack.
75157
76158
Returns:
77-
The relative URL to the asset.
159+
The relative URL to the asset, with an ``importable_path`` property
160+
for use as a build-time module reference.
78161
79162
Raises:
80163
FileNotFoundError: If the file does not exist.
@@ -93,7 +176,7 @@ def asset(
93176
if not backend_only and not src_file_local.exists():
94177
msg = f"File not found: {src_file_local}"
95178
raise FileNotFoundError(msg)
96-
return get_config().prepend_frontend_path(f"/{path}")
179+
return AssetPathStr(f"/{path}")
97180

98181
# Shared asset handling
99182
# Determine the file by which the asset is exposed.
@@ -129,4 +212,4 @@ def asset(
129212
dst_file.unlink()
130213
dst_file.symlink_to(src_file_shared)
131214

132-
return get_config().prepend_frontend_path(f"/{external}/{subfolder}/{path}")
215+
return AssetPathStr(f"/{external}/{subfolder}/{path}")

tests/units/assets/test_assets.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import copy
2+
import pickle
13
import shutil
24
from collections.abc import Generator
35
from pathlib import Path
@@ -6,7 +8,7 @@
68

79
import reflex as rx
810
import reflex.constants as constants
9-
from reflex.assets import remove_stale_external_asset_symlinks
11+
from reflex.assets import AssetPathStr, remove_stale_external_asset_symlinks
1012

1113

1214
@pytest.fixture
@@ -108,6 +110,86 @@ def test_local_asset(custom_script_in_asset_dir: Path) -> None:
108110
assert asset == "/custom_script.js"
109111

110112

113+
def test_asset_importable_path_local(custom_script_in_asset_dir: Path) -> None:
114+
"""A local asset path exposes an `importable_path` prefixed with $/public.
115+
116+
Args:
117+
custom_script_in_asset_dir: Fixture that creates a custom_script.js file in the app's assets directory.
118+
"""
119+
asset = rx.asset("custom_script.js", shared=False)
120+
assert isinstance(asset, AssetPathStr)
121+
assert asset.importable_path == "$/public/custom_script.js"
122+
123+
124+
def test_asset_importable_path_shared(mock_asset_path: Path) -> None:
125+
"""A shared asset path exposes an `importable_path` prefixed with $/public."""
126+
asset = rx.asset(path="custom_script.js", shared=True)
127+
assert isinstance(asset, AssetPathStr)
128+
assert asset.importable_path == "$/public/external/test_assets/custom_script.js"
129+
130+
131+
def test_asset_importable_path_with_frontend_path(
132+
monkeypatch: pytest.MonkeyPatch,
133+
) -> None:
134+
"""With frontend_path configured, str value is prefixed but importable_path is not.
135+
136+
Args:
137+
monkeypatch: A pytest fixture for patching.
138+
"""
139+
import reflex.assets as assets_module
140+
141+
class _StubConfig:
142+
frontend_path = "/my-app"
143+
144+
@staticmethod
145+
def prepend_frontend_path(path: str) -> str:
146+
return f"/my-app{path}" if path.startswith("/") else path
147+
148+
monkeypatch.setattr(assets_module, "get_config", lambda: _StubConfig)
149+
150+
asset = AssetPathStr("/external/mod/custom_script.js")
151+
assert asset == "/my-app/external/mod/custom_script.js"
152+
assert asset.importable_path == "$/public/external/mod/custom_script.js"
153+
154+
# Bytes + encoding form (matches str() signature) also works.
155+
asset_from_bytes = AssetPathStr(b"/external/mod/file.js", "utf-8")
156+
assert asset_from_bytes == "/my-app/external/mod/file.js"
157+
assert asset_from_bytes.importable_path == "$/public/external/mod/file.js"
158+
159+
160+
def test_asset_path_pickle_roundtrip(monkeypatch: pytest.MonkeyPatch) -> None:
161+
"""Pickle/copy round-trips must not double-apply the frontend prefix.
162+
163+
Regression test for https://github.com/reflex-dev/reflex/pull/6348#discussion_r3113958087.
164+
165+
Args:
166+
monkeypatch: A pytest fixture for patching.
167+
"""
168+
import reflex.assets as assets_module
169+
170+
class _StubConfig:
171+
frontend_path = "/my-app"
172+
173+
@staticmethod
174+
def prepend_frontend_path(path: str) -> str:
175+
return f"/my-app{path}" if path.startswith("/") else path
176+
177+
monkeypatch.setattr(assets_module, "get_config", lambda: _StubConfig)
178+
179+
original = AssetPathStr("/external/mod/file.js")
180+
assert original == "/my-app/external/mod/file.js"
181+
assert original.importable_path == "$/public/external/mod/file.js"
182+
183+
for clone in (
184+
pickle.loads(pickle.dumps(original)),
185+
copy.copy(original),
186+
copy.deepcopy(original),
187+
):
188+
assert isinstance(clone, AssetPathStr)
189+
assert clone == "/my-app/external/mod/file.js"
190+
assert clone.importable_path == "$/public/external/mod/file.js"
191+
192+
111193
def test_remove_stale_external_asset_symlinks(mock_asset_path: Path) -> None:
112194
"""Test that stale symlinks and empty dirs in assets/external/ are cleaned up."""
113195
external_dir = (

0 commit comments

Comments
 (0)