Repository: reflex-dev/reflex
Pull request: rx._x.asset improvements
Closes #3623 

Problem statement:
Multiple rx._x.asset issues
- Assets should not be checked and copied in backend only mode. Even if you do not put them in globals, they will for example be executed if you use them in a `@rx.page` which will be loaded. Even in backend only mode.
- Local/Normal asset support - Assets which are in the local `assets/` directory should not be copied to `assets/external`. But we can still validate that the asset exists and return the serving path.

@abulvenz

Hints from issue discussion:

Commit message: fix darglint

Patch to review:
diff --git a/reflex/constants/__init__.py b/reflex/constants/__init__.py
index e974ab91583..185c2ee3e00 100644
--- a/reflex/constants/__init__.py
+++ b/reflex/constants/__init__.py
@@ -2,6 +2,7 @@
 
 from .base import (
     COOKIES,
+    ENV_BACKEND_ONLY,
     ENV_MODE_ENV_VAR,
     IS_WINDOWS,
     LOCAL_STORAGE,
@@ -113,6 +114,7 @@
     ROUTER_DATA_INCLUDE,
     ROUTE_NOT_FOUND,
     SETTER_PREFIX,
+    ENV_BACKEND_ONLY,
     SKIP_COMPILE_ENV_VAR,
     SocketEvent,
     Tailwind,
diff --git a/reflex/constants/base.py b/reflex/constants/base.py
index c818fbf06e6..93437a33b00 100644
--- a/reflex/constants/base.py
+++ b/reflex/constants/base.py
@@ -188,6 +188,9 @@ class Ping(SimpleNamespace):
 # This env var stores the execution mode of the app
 ENV_MODE_ENV_VAR = "REFLEX_ENV_MODE"
 
+# This env var can be set to run backend only
+ENV_BACKEND_ONLY = "REFLEX_BACKEND_ONLY"
+
 # Testing variables.
 # Testing os env set by pytest when running a test case.
 PYTEST_CURRENT_TEST = "PYTEST_CURRENT_TEST"
diff --git a/reflex/experimental/assets.py b/reflex/experimental/assets.py
index c18ac1e84bf..7df2ab0d164 100644
--- a/reflex/experimental/assets.py
+++ b/reflex/experimental/assets.py
@@ -1,57 +1,90 @@
 """Helper functions for adding assets to the app."""
 
 import inspect
+import os
 from pathlib import Path
 from typing import Optional
 
 from reflex import constants
 
 
-def asset(relative_filename: str, subfolder: Optional[str] = None) -> str:
-    """Add an asset to the app.
+def asset(
+    path: str,
+    shared: bool = False,
+    subfolder: Optional[str] = None,
+) -> 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.
-    Copies the file to the app's external assets directory.
+    Links the file to the app's external assets directory.
 
     Example:
     ```python
-    rx.script(src=rx._x.asset("my_custom_javascript.js"))
-    rx.image(src=rx._x.asset("test_image.png","subfolder"))
+    # my_custom_javascript.js is a shared asset located next to the including python file.
+    rx.script(src=rx._x.asset(path="my_custom_javascript.js", shared=True))
+    rx.image(src=rx._x.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._x.asset(path="local_image.png"))
     ```
 
     Args:
-        relative_filename: The relative filename of the asset.
-        subfolder: The directory to place the asset in.
+        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.
 
     Raises:
         FileNotFoundError: If the file does not exist.
+        ValueError: If subfolder is provided for local assets.
 
     Returns:
-        The relative URL to the copied asset.
+        The relative URL to the asset.
     """
+    assets = constants.Dirs.APP_ASSETS
+    backend_only = os.environ.get(constants.ENV_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.
     calling_file = inspect.stack()[1].filename
     module = inspect.getmodule(inspect.stack()[1][0])
     assert module is not 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
+    src_file_shared = Path(calling_file).parent / path
+    if not src_file_shared.exists():
+        raise FileNotFoundError(f"File not found: {src_file_shared}")
 
-    if not src_file.exists():
-        raise FileNotFoundError(f"File not found: {src_file}")
+    caller_module_path = module.__name__.replace(".", "/")
+    subfolder = f"{caller_module_path}/{subfolder}" if subfolder else caller_module_path
 
-    # Create the asset folder in the currently compiling app.
-    asset_folder = Path.cwd() / assets / external / subfolder
-    asset_folder.mkdir(parents=True, exist_ok=True)
+    # 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 / relative_filename
+        dst_file = asset_folder / path
 
-    if not dst_file.exists():
-        dst_file.symlink_to(src_file)
+        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)
 
-    asset_url = f"/{external}/{subfolder}/{relative_filename}"
-    return asset_url
+    return f"/{external}/{subfolder}/{path}"
diff --git a/reflex/reflex.py b/reflex/reflex.py
index a9e164477cc..f97fef2da50 100644
--- a/reflex/reflex.py
+++ b/reflex/reflex.py
@@ -242,7 +242,12 @@ def run(
     frontend: bool = typer.Option(
         False, "--frontend-only", help="Execute only frontend."
     ),
-    backend: bool = typer.Option(False, "--backend-only", help="Execute only backend."),
+    backend: bool = typer.Option(
+        False,
+        "--backend-only",
+        help="Execute only backend.",
+        envvar=constants.ENV_BACKEND_ONLY,
+    ),
     frontend_port: str = typer.Option(
         config.frontend_port, help="Specify a different frontend port."
     ),
@@ -257,6 +262,7 @@ def run(
     ),
 ):
     """Run the app in the current directory."""
+    os.environ[constants.ENV_BACKEND_ONLY] = str(backend)
     _run(env, frontend, backend, frontend_port, backend_port, backend_host, loglevel)
 
 
diff --git a/tests/experimental/test_assets.py b/tests/experimental/test_assets.py
index 8037bcc75eb..4df2e5284ab 100644
--- a/tests/experimental/test_assets.py
+++ b/tests/experimental/test_assets.py
@@ -1,16 +1,17 @@
 import shutil
 from pathlib import Path
+from typing import Generator
 
 import pytest
 
 import reflex as rx
+import reflex.constants as constants
 
 
-def test_asset():
-    # Test the asset function.
-
+def test_shared_asset() -> None:
+    """Test shared assets."""
     # The asset function copies a file to the app's external assets directory.
-    asset = rx._x.asset("custom_script.js", "subfolder")
+    asset = rx._x.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"
@@ -18,10 +19,10 @@ def test_asset():
     assert result_file.exists()
 
     # Running a second time should not raise an error.
-    asset = rx._x.asset("custom_script.js", "subfolder")
+    asset = rx._x.asset(path="custom_script.js", shared=True, subfolder="subfolder")
 
     # Test the asset function without a subfolder.
-    asset = rx._x.asset("custom_script.js")
+    asset = rx._x.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()
@@ -34,3 +35,47 @@ def test_asset():
 
     # Nothing is done to assets when file does not exist.
     assert not Path(Path.cwd() / "assets/external").exists()
+
+
+@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._x.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._x.asset("custom_script.js", shared=False)
+    assert asset == "/custom_script.js"


Code review comments:
diff_hunk: @@ -1,57 +1,90 @@
 """Helper functions for adding assets to the app."""
 
 import inspect
+import os
 from pathlib import Path
 from typing import Optional
 
 from reflex import constants
 
 
-def asset(relative_filename: str, subfolder: Optional[str] = None) -> str:
-    """Add an asset to the app.
+def asset(
+    path: str,
+    shared: bool = False,
path: reflex/experimental/assets.py
text: @user1:
the `shared` argument is a little bit confusing to me... is `shared=False` for referencing files that are already in the app's `assets/` directory?

i suppose this is to align the API to work with source-relative assets as well as assets that are already in the correct place? But setting the default to `False` seems to break how existing callers are already using this function to reference source-relative assets.

@author:
`shared=True` ist for librarys which want to expose the assets to other python modules/reflex apps. Shared mode expects the files to be relative to the python source file where `rx._x.asset` is called.

`shared=False` is for "normal"/"local" assets in reflex. It just returns the serving path and checks if the file really exits.

diff_hunk: @@ -1,57 +1,90 @@
 """Helper functions for adding assets to the app."""
 
 import inspect
+import os
 from pathlib import Path
 from typing import Optional
 
 from reflex import constants
 
 
-def asset(relative_filename: str, subfolder: Optional[str] = None) -> str:
-    """Add an asset to the app.
+def asset(
+    path: str,
+    shared: bool = False,
+    subfolder: Optional[str] = None,
+) -> 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.
-    Copies the file to the app's external assets directory.
+    Links the file to the app's external assets directory.
 
     Example:
     ```python
-    rx.script(src=rx._x.asset("my_custom_javascript.js"))
-    rx.image(src=rx._x.asset("test_image.png","subfolder"))
+    # my_custom_javascript.js is a shared asset located next to the including python file.
+    rx.script(src=rx._x.asset(path="my_custom_javascript.js", shared=True))
+    rx.image(src=rx._x.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._x.asset(path="local_image.png"))
     ```
 
     Args:
-        relative_filename: The relative filename of the asset.
-        subfolder: The directory to place the asset in.
+        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.
 
     Raises:
         FileNotFoundError: If the file does not exist.
+        ValueError: If subfolder is provided for local assets.
 
     Returns:
-        The relative URL to the copied asset.
+        The relative URL to the asset.
     """
+    assets = constants.Dirs.APP_ASSETS
+    backend_only = os.environ.get(constants.ENV_BACKEND_ONLY)
path: reflex/experimental/assets.py
text: @user2:
@user1-bartscher hey Bene, I found a minor bug - here you will get either a `string` or a `None` which means when you are checking for 

```python

if not backend_only:
   ...
 ```
if the env var is a string `"False"`, it will be evaluated as `True`

so I suggest doing sth like:

```python

backend_only = os.environ.get(constants.ENV_BACKEND_ONLY, "false").lower() == "true"

```

@user3:
@user2 Thank you for analyzing it down to the root.

diff_hunk: @@ -1,57 +1,90 @@
 """Helper functions for adding assets to the app."""
 
 import inspect
+import os
 from pathlib import Path
 from typing import Optional
 
 from reflex import constants
 
 
-def asset(relative_filename: str, subfolder: Optional[str] = None) -> str:
-    """Add an asset to the app.
+def asset(
+    path: str,
+    shared: bool = False,
+    subfolder: Optional[str] = None,
+) -> 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.
-    Copies the file to the app's external assets directory.
+    Links the file to the app's external assets directory.
 
     Example:
     ```python
-    rx.script(src=rx._x.asset("my_custom_javascript.js"))
-    rx.image(src=rx._x.asset("test_image.png","subfolder"))
+    # my_custom_javascript.js is a shared asset located next to the including python file.
+    rx.script(src=rx._x.asset(path="my_custom_javascript.js", shared=True))
+    rx.image(src=rx._x.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._x.asset(path="local_image.png"))
     ```
 
     Args:
-        relative_filename: The relative filename of the asset.
-        subfolder: The directory to place the asset in.
+        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.
 
     Raises:
         FileNotFoundError: If the file does not exist.
+        ValueError: If subfolder is provided for local assets.
 
     Returns:
-        The relative URL to the copied asset.
+        The relative URL to the asset.
     """
+    assets = constants.Dirs.APP_ASSETS
+    backend_only = os.environ.get(constants.ENV_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.
     calling_file = inspect.stack()[1].filename
     module = inspect.getmodule(inspect.stack()[1][0])
     assert module is not 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
+    src_file_shared = Path(calling_file).parent / path
+    if not src_file_shared.exists():
+        raise FileNotFoundError(f"File not found: {src_file_shared}")
 
-    if not src_file.exists():
-        raise FileNotFoundError(f"File not found: {src_file}")
+    caller_module_path = module.__name__.replace(".", "/")
+    subfolder = f"{caller_module_path}/{subfolder}" if subfolder else caller_module_path
 
-    # Create the asset folder in the currently compiling app.
-    asset_folder = Path.cwd() / assets / external / subfolder
-    asset_folder.mkdir(parents=True, exist_ok=True)
+    # 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 / relative_filename
+        dst_file = asset_folder / path
 
-    if not dst_file.exists():
-        dst_file.symlink_to(src_file)
+        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)
path: reflex/experimental/assets.py
text: @user1:
this makes me a bit nervous because symlinks are not generally enabled or available to unprivileged users on windows.

can we have a fallback path that just copies the files on windows or if this operation fails

@author:
this PR did not introduce the symlinks, they were there before. i can address it in another PR