Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 87 additions & 4 deletions reflex/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,93 @@

import inspect
from pathlib import Path
from typing import TYPE_CHECKING, overload

from reflex_base import constants
from reflex_base.config import get_config
from reflex_base.environment import EnvironmentVariables

if TYPE_CHECKING:
from typing_extensions import Buffer


class AssetPathStr(str):
"""The relative URL to an asset, with a build-time importable variant.

Returned by :func:`asset`. The string value is the asset URL with the
configured ``frontend_path`` prepended; :attr:`importable_path` is the
same asset prefixed with ``$/public`` so the asset can be referenced by
a component ``library`` or module import at build time.

The constructor signature mirrors :class:`str`: the input is interpreted
as the unprefixed asset path and both forms are derived from it at
construction time.
"""

__slots__ = ("importable_path",)

importable_path: str

@overload
def __new__(cls, object: object = "") -> "AssetPathStr": ...
@overload
def __new__(
cls,
object: "Buffer",
encoding: str = "utf-8",
errors: str = "strict",
) -> "AssetPathStr": ...

def __new__(
cls,
object: object = "",
encoding: str | None = None,
errors: str | None = None,
) -> "AssetPathStr":
"""Construct from an unprefixed, leading-slash asset path.

Args/semantics mirror :class:`str`. The resulting string is interpreted
as the asset path (e.g. ``"/external/mod/file.js"``); the
frontend-prefixed URL is stored as the ``AssetPathStr`` value and
``$/public`` + ``relative_path`` as :attr:`importable_path`.

Args:
object: The object to stringify (str, bytes, or any object).
encoding: Encoding to decode ``object`` with when it is bytes-like.
errors: Error handler for decoding.

Returns:
A new ``AssetPathStr`` instance.
"""
if encoding is None and errors is None:
relative_path = str.__new__(str, object)
else:
relative_path = str.__new__(
str,
object, # pyright: ignore[reportArgumentType]
"utf-8" if encoding is None else encoding,
"strict" if errors is None else errors,
)
instance = super().__new__(
cls, get_config().prepend_frontend_path(relative_path)
)
instance.importable_path = f"$/public{relative_path}"
return instance
Comment on lines +15 to +76
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Pickle/copy will double-apply frontend_path

When Python pickles a str subclass it calls cls.__new__(cls, str(self)) on reconstruction — passing the already-prefixed string value back into __new__, which calls prepend_frontend_path a second time. The result is a corrupted double-prefixed path (e.g. /my-app/my-app/external/…) and the importable_path slot is silently lost.

This can be fixed by overriding __getnewargs__ to return the raw relative path (recoverable from importable_path):

def __getnewargs__(self) -> tuple[str]:
    # Return the unprefixed path so __new__ reconstructs correctly.
    return (self.importable_path[len("$/public"):],)

If AssetPathStr is never intended to be serialized/pickled, document that constraint explicitly in the class docstring.


def __getnewargs__(self) -> tuple[str]:
"""Return the unprefixed path for pickle/copy reconstruction.

Python's default ``str`` pickle path would feed the frontend-prefixed
value back into :meth:`__new__`, double-applying the prefix and
losing the :attr:`importable_path` slot. Returning the raw path
(recovered by stripping the ``$/public`` prefix) lets ``__new__``
rebuild both forms correctly.

Returns:
A one-tuple containing the unprefixed asset path.
"""
return (self.importable_path[len("$/public") :],)


def remove_stale_external_asset_symlinks():
"""Remove broken symlinks and empty directories in assets/external/.
Expand Down Expand Up @@ -42,7 +124,7 @@ def asset(
shared: bool = False,
subfolder: str | None = None,
_stack_level: int = 1,
) -> str:
) -> AssetPathStr:
"""Add an asset to the app, either shared as a symlink or local.

Shared/External/Library assets:
Expand Down Expand Up @@ -74,7 +156,8 @@ def asset(
increase this number for each helper function in the stack.

Returns:
The relative URL to the asset.
The relative URL to the asset, with an ``importable_path`` property
for use as a build-time module reference.

Raises:
FileNotFoundError: If the file does not exist.
Expand All @@ -93,7 +176,7 @@ def asset(
if not backend_only and not src_file_local.exists():
msg = f"File not found: {src_file_local}"
raise FileNotFoundError(msg)
return get_config().prepend_frontend_path(f"/{path}")
return AssetPathStr(f"/{path}")

# Shared asset handling
# Determine the file by which the asset is exposed.
Expand Down Expand Up @@ -129,4 +212,4 @@ def asset(
dst_file.unlink()
dst_file.symlink_to(src_file_shared)

return get_config().prepend_frontend_path(f"/{external}/{subfolder}/{path}")
return AssetPathStr(f"/{external}/{subfolder}/{path}")
84 changes: 83 additions & 1 deletion tests/units/assets/test_assets.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import copy
import pickle
import shutil
from collections.abc import Generator
from pathlib import Path
Expand All @@ -6,7 +8,7 @@

import reflex as rx
import reflex.constants as constants
from reflex.assets import remove_stale_external_asset_symlinks
from reflex.assets import AssetPathStr, remove_stale_external_asset_symlinks


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


def test_asset_importable_path_local(custom_script_in_asset_dir: Path) -> None:
"""A local asset path exposes an `importable_path` prefixed with $/public.

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 isinstance(asset, AssetPathStr)
assert asset.importable_path == "$/public/custom_script.js"


def test_asset_importable_path_shared(mock_asset_path: Path) -> None:
"""A shared asset path exposes an `importable_path` prefixed with $/public."""
asset = rx.asset(path="custom_script.js", shared=True)
assert isinstance(asset, AssetPathStr)
assert asset.importable_path == "$/public/external/test_assets/custom_script.js"


def test_asset_importable_path_with_frontend_path(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""With frontend_path configured, str value is prefixed but importable_path is not.

Args:
monkeypatch: A pytest fixture for patching.
"""
import reflex.assets as assets_module

class _StubConfig:
frontend_path = "/my-app"

@staticmethod
def prepend_frontend_path(path: str) -> str:
return f"/my-app{path}" if path.startswith("/") else path

monkeypatch.setattr(assets_module, "get_config", lambda: _StubConfig)

asset = AssetPathStr("/external/mod/custom_script.js")
assert asset == "/my-app/external/mod/custom_script.js"
assert asset.importable_path == "$/public/external/mod/custom_script.js"

# Bytes + encoding form (matches str() signature) also works.
asset_from_bytes = AssetPathStr(b"/external/mod/file.js", "utf-8")
assert asset_from_bytes == "/my-app/external/mod/file.js"
assert asset_from_bytes.importable_path == "$/public/external/mod/file.js"


def test_asset_path_pickle_roundtrip(monkeypatch: pytest.MonkeyPatch) -> None:
"""Pickle/copy round-trips must not double-apply the frontend prefix.

Regression test for https://github.com/reflex-dev/reflex/pull/6348#discussion_r3113958087.

Args:
monkeypatch: A pytest fixture for patching.
"""
import reflex.assets as assets_module

class _StubConfig:
frontend_path = "/my-app"

@staticmethod
def prepend_frontend_path(path: str) -> str:
return f"/my-app{path}" if path.startswith("/") else path

monkeypatch.setattr(assets_module, "get_config", lambda: _StubConfig)

original = AssetPathStr("/external/mod/file.js")
assert original == "/my-app/external/mod/file.js"
assert original.importable_path == "$/public/external/mod/file.js"

for clone in (
pickle.loads(pickle.dumps(original)),
copy.copy(original),
copy.deepcopy(original),
):
assert isinstance(clone, AssetPathStr)
assert clone == "/my-app/external/mod/file.js"
assert clone.importable_path == "$/public/external/mod/file.js"


def test_remove_stale_external_asset_symlinks(mock_asset_path: Path) -> None:
"""Test that stale symlinks and empty dirs in assets/external/ are cleaned up."""
external_dir = (
Expand Down
Loading