Skip to content

Commit 0191a62

Browse files
Merge remote-tracking branch 'upstream/main' into sqla-mutable-proxy
2 parents 18488dd + 3c11451 commit 0191a62

10 files changed

Lines changed: 278 additions & 29 deletions

File tree

.devcontainer/devcontainer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"image": "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm",
2+
"image": "mcr.microsoft.com/devcontainers/python:3.14-trixie",
33
"postCreateCommand": "/bin/bash -c 'python -m pip install uv && python -m uv sync & git clone https://github.com/reflex-dev/reflex-examples; wait'",
44
"forwardPorts": [3000, 8000],
55
"portsAttributes": {

reflex/assets.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@ def asset(
9292
if not dst_file.exists() and (
9393
not dst_file.is_symlink() or dst_file.resolve() != src_file_shared.resolve()
9494
):
95-
dst_file.symlink_to(src_file_shared)
95+
try:
96+
dst_file.symlink_to(src_file_shared)
97+
except FileExistsError:
98+
# This happens when Simon builds the app on a bind mount in a docker container.
99+
dst_file.unlink()
100+
dst_file.symlink_to(src_file_shared)
96101

97102
return f"/{external}/{subfolder}/{path}"

reflex/compiler/templates.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,7 @@ def vite_config_template(
502502
force_full_reload: bool,
503503
experimental_hmr: bool,
504504
sourcemap: bool | Literal["inline", "hidden"],
505+
allowed_hosts: bool | list[str] = False,
505506
):
506507
"""Template for vite.config.js.
507508
@@ -511,10 +512,17 @@ def vite_config_template(
511512
force_full_reload: Whether to force a full reload on changes.
512513
experimental_hmr: Whether to enable experimental HMR features.
513514
sourcemap: The sourcemap configuration.
515+
allowed_hosts: Allow all hosts (True), specific hosts (list of strings), or only localhost (False).
514516
515517
Returns:
516518
Rendered vite.config.js content as string.
517519
"""
520+
if allowed_hosts is True:
521+
allowed_hosts_line = "\n allowedHosts: true,"
522+
elif isinstance(allowed_hosts, list) and allowed_hosts:
523+
allowed_hosts_line = f"\n allowedHosts: {json.dumps(allowed_hosts)},"
524+
else:
525+
allowed_hosts_line = ""
518526
return rf"""import {{ fileURLToPath, URL }} from "url";
519527
import {{ reactRouter }} from "@react-router/dev/vite";
520528
import {{ defineConfig }} from "vite";
@@ -586,7 +594,7 @@ def vite_config_template(
586594
hmr: {"true" if experimental_hmr else "false"},
587595
}},
588596
server: {{
589-
port: process.env.PORT,
597+
port: process.env.PORT,{allowed_hosts_line}
590598
hmr: {"true" if hmr else "false"},
591599
watch: {{
592600
ignored: [

reflex/config.py

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,11 @@ class BaseConfig:
213213
dataclasses.field(default=("*",))
214214
)
215215

216+
# Allowed hosts for the Vite dev server. Set to True to allow all hosts,
217+
# or provide a list of hostnames (e.g. ["myservice.local"]) to allow specific ones.
218+
# Prevents 403 errors in Docker, Codespaces, reverse proxies, etc.
219+
vite_allowed_hosts: bool | list[str] = False
220+
216221
# Whether to use React strict mode.
217222
react_strict_mode: bool = True
218223

@@ -254,8 +259,8 @@ class BaseConfig:
254259
# List of plugins to use in the app.
255260
plugins: list[Plugin] = dataclasses.field(default_factory=list)
256261

257-
# List of fully qualified import paths of plugins to disable in the app (e.g. reflex.plugins.sitemap.SitemapPlugin).
258-
disable_plugins: list[str] = dataclasses.field(default_factory=list)
262+
# List of plugin types to disable in the app.
263+
disable_plugins: list[type[Plugin]] = dataclasses.field(default_factory=list)
259264

260265
# The transport method for client-server communication.
261266
transport: Literal["websocket", "polling"] = "websocket"
@@ -353,6 +358,9 @@ def _post_init(self, **kwargs):
353358
for key, env_value in env_kwargs.items():
354359
setattr(self, key, env_value)
355360

361+
# Normalize disable_plugins: convert strings and Plugin subclasses to instances.
362+
self._normalize_disable_plugins()
363+
356364
# Add builtin plugins if not disabled.
357365
if not self._skip_plugins_checks:
358366
self._add_builtin_plugins()
@@ -369,16 +377,52 @@ def _post_init(self, **kwargs):
369377
msg = f"{self._prefixes[0]}REDIS_URL is required when using the redis state manager."
370378
raise ConfigError(msg)
371379

380+
def _normalize_disable_plugins(self):
381+
"""Normalize disable_plugins list entries to Plugin subclasses.
382+
383+
Handles backward compatibility by converting strings (fully qualified
384+
import paths) and Plugin instances to their associated classes.
385+
"""
386+
normalized: list[type[Plugin]] = []
387+
for entry in self.disable_plugins:
388+
if isinstance(entry, type) and issubclass(entry, Plugin):
389+
normalized.append(entry)
390+
elif isinstance(entry, Plugin):
391+
normalized.append(type(entry))
392+
elif isinstance(entry, str):
393+
console.deprecate(
394+
feature_name="Passing strings to disable_plugins",
395+
reason="pass Plugin classes directly instead, e.g. disable_plugins=[SitemapPlugin]",
396+
deprecation_version="0.8.28",
397+
removal_version="0.9.0",
398+
)
399+
try:
400+
from reflex.environment import interpret_plugin_class_env
401+
402+
normalized.append(
403+
interpret_plugin_class_env(entry, "disable_plugins")
404+
)
405+
except Exception:
406+
console.warn(
407+
f"Failed to import plugin from string {entry!r} in disable_plugins. "
408+
"Please pass Plugin subclasses directly.",
409+
)
410+
else:
411+
console.warn(
412+
f"reflex.Config.disable_plugins should contain Plugin subclasses, but got {entry!r}.",
413+
)
414+
self.disable_plugins = normalized
415+
372416
def _add_builtin_plugins(self):
373417
"""Add the builtin plugins to the config."""
374418
for plugin in _PLUGINS_ENABLED_BY_DEFAULT:
375419
plugin_name = plugin.__module__ + "." + plugin.__qualname__
376-
if plugin_name not in self.disable_plugins:
420+
if plugin not in self.disable_plugins:
377421
if not any(isinstance(p, plugin) for p in self.plugins):
378422
console.warn(
379423
f"`{plugin_name}` plugin is enabled by default, but not explicitly added to the config. "
380424
"If you want to use it, please add it to the `plugins` list in your config inside of `rxconfig.py`. "
381-
f"To disable this plugin, set `disable_plugins` to `{[plugin_name, *self.disable_plugins]!r}`.",
425+
f"To disable this plugin, add `{plugin.__name__}` to the `disable_plugins` list.",
382426
)
383427
self.plugins.append(plugin())
384428
else:
@@ -389,16 +433,9 @@ def _add_builtin_plugins(self):
389433
)
390434

391435
for disabled_plugin in self.disable_plugins:
392-
if not isinstance(disabled_plugin, str):
393-
console.warn(
394-
f"reflex.Config.disable_plugins should only contain strings, but got {disabled_plugin!r}. "
395-
)
396-
if not any(
397-
plugin.__module__ + "." + plugin.__qualname__ == disabled_plugin
398-
for plugin in _PLUGINS_ENABLED_BY_DEFAULT
399-
):
436+
if disabled_plugin not in _PLUGINS_ENABLED_BY_DEFAULT:
400437
console.warn(
401-
f"`{disabled_plugin}` is disabled in the config, but it is not a built-in plugin. "
438+
f"`{disabled_plugin!r}` is disabled in the config, but it is not a built-in plugin. "
402439
"Please remove it from the `disable_plugins` list in your config inside of `rxconfig.py`.",
403440
)
404441

reflex/constants/installer.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,20 @@ class Commands(SimpleNamespace):
106106

107107
DEV = "react-router dev --host"
108108
EXPORT = "react-router build"
109-
PROD = "sirv ./build/client --single 404.html --host"
109+
110+
@staticmethod
111+
def get_prod_command(frontend_path: str = "") -> str:
112+
"""Get the prod command with the correct 404.html path for the given frontend_path.
113+
114+
Args:
115+
frontend_path: The frontend path prefix (e.g. "/app").
116+
117+
Returns:
118+
The sirv command with the correct --single fallback path.
119+
"""
120+
stripped = frontend_path.strip("/")
121+
fallback = f"{stripped}/404.html" if stripped else "404.html"
122+
return f"sirv ./build/client --single {fallback} --host"
110123

111124
PATH = "package.json"
112125

reflex/environment.py

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -149,15 +149,17 @@ def interpret_path_env(value: str, field_name: str) -> Path:
149149
return Path(value)
150150

151151

152-
def interpret_plugin_env(value: str, field_name: str) -> Plugin:
153-
"""Interpret a plugin environment variable value.
152+
def interpret_plugin_class_env(value: str, field_name: str) -> type[Plugin]:
153+
"""Interpret an environment variable value as a Plugin subclass.
154+
155+
Resolves a fully qualified import path to the Plugin subclass it refers to.
154156
155157
Args:
156-
value: The environment variable value.
158+
value: The environment variable value (e.g. "reflex.plugins.sitemap.SitemapPlugin").
157159
field_name: The field name.
158160
159161
Returns:
160-
The interpreted value.
162+
The Plugin subclass.
161163
162164
Raises:
163165
EnvironmentVarValueError: If the value is invalid.
@@ -184,10 +186,30 @@ def interpret_plugin_env(value: str, field_name: str) -> Plugin:
184186
msg = f"Invalid plugin class: {plugin_name!r} for {field_name}. Must be a subclass of Plugin."
185187
raise EnvironmentVarValueError(msg)
186188

189+
return plugin_class
190+
191+
192+
def interpret_plugin_env(value: str, field_name: str) -> Plugin:
193+
"""Interpret a plugin environment variable value.
194+
195+
Resolves a fully qualified import path and returns an instance of the Plugin.
196+
197+
Args:
198+
value: The environment variable value (e.g. "reflex.plugins.sitemap.SitemapPlugin").
199+
field_name: The field name.
200+
201+
Returns:
202+
An instance of the Plugin subclass.
203+
204+
Raises:
205+
EnvironmentVarValueError: If the value is invalid.
206+
"""
207+
plugin_class = interpret_plugin_class_env(value, field_name)
208+
187209
try:
188210
return plugin_class()
189211
except Exception as e:
190-
msg = f"Failed to instantiate plugin {plugin_name!r} for {field_name}: {e}"
212+
msg = f"Failed to instantiate plugin {plugin_class.__name__!r} for {field_name}: {e}"
191213
raise EnvironmentVarValueError(msg) from e
192214

193215

@@ -243,8 +265,14 @@ def interpret_env_var_value(
243265
field_type = value_inside_optional(field_type)
244266

245267
if is_union(field_type):
246-
msg = f"Union types are not supported for environment variables: {field_name}."
247-
raise ValueError(msg)
268+
errors = []
269+
for arg in (union_types := get_args(field_type)):
270+
try:
271+
return interpret_env_var_value(value, arg, field_name)
272+
except (ValueError, EnvironmentVarValueError) as e: # noqa: PERF203
273+
errors.append(e)
274+
msg = f"Could not interpret {value!r} for {field_name} as any of {union_types}: {errors}"
275+
raise EnvironmentVarValueError(msg)
248276

249277
value = value.strip()
250278

@@ -268,6 +296,14 @@ def interpret_env_var_value(
268296
return interpret_existing_path_env(value, field_name)
269297
if field_type is Plugin:
270298
return interpret_plugin_env(value, field_name)
299+
if get_origin(field_type) is type:
300+
type_args = get_args(field_type)
301+
if (
302+
type_args
303+
and isinstance(type_args[0], type)
304+
and issubclass(type_args[0], Plugin)
305+
):
306+
return interpret_plugin_class_env(value, field_name)
271307
if get_origin(field_type) is Literal:
272308
literal_values = get_args(field_type)
273309
for literal_value in literal_values:

reflex/utils/frontend_skeleton.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,11 +168,14 @@ def _update_react_router_config(config: Config, prerender_routes: bool = False):
168168

169169

170170
def _compile_package_json():
171+
config = get_config()
171172
return templates.package_json_template(
172173
scripts={
173174
"dev": constants.PackageJson.Commands.DEV,
174175
"export": constants.PackageJson.Commands.EXPORT,
175-
"prod": constants.PackageJson.Commands.PROD,
176+
"prod": constants.PackageJson.Commands.get_prod_command(
177+
config.frontend_path
178+
),
176179
},
177180
dependencies=constants.PackageJson.DEPENDENCIES,
178181
dev_dependencies=constants.PackageJson.DEV_DEPENDENCIES,
@@ -197,6 +200,7 @@ def _compile_vite_config(config: Config):
197200
force_full_reload=environment.VITE_FORCE_FULL_RELOAD.get(),
198201
experimental_hmr=environment.VITE_EXPERIMENTAL_HMR.get(),
199202
sourcemap=environment.VITE_SOURCEMAP.get(),
203+
allowed_hosts=config.vite_allowed_hosts,
200204
)
201205

202206

tests/units/test_config.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
interpret_enum_env,
1818
interpret_int_env,
1919
)
20+
from reflex.plugins import Plugin
21+
from reflex.plugins.sitemap import SitemapPlugin
2022

2123

2224
def test_requires_app_name():
@@ -402,3 +404,61 @@ def test_env_file(
402404
)
403405
for key, value in exp_env_vars.items():
404406
assert os.environ.get(key) == value
407+
408+
409+
class TestDisablePlugins:
410+
"""Tests for the disable_plugins config option."""
411+
412+
def test_disable_with_plugin_class(self):
413+
"""Test disabling a plugin by passing the class (type)."""
414+
config = rx.Config(app_name="test", disable_plugins=[SitemapPlugin])
415+
assert not any(isinstance(p, SitemapPlugin) for p in config.plugins)
416+
417+
def test_disable_with_plugin_instance_backward_compat(self):
418+
"""Test disabling a plugin by passing an instance (deprecated)."""
419+
config = rx.Config(app_name="test", disable_plugins=[SitemapPlugin()]) # pyright: ignore[reportArgumentType]
420+
assert not any(isinstance(p, SitemapPlugin) for p in config.plugins)
421+
422+
def test_disable_with_string_backward_compat(self):
423+
"""Test disabling a plugin by passing a string (deprecated)."""
424+
config = rx.Config(
425+
app_name="test",
426+
disable_plugins=["reflex.plugins.sitemap.SitemapPlugin"], # pyright: ignore[reportArgumentType]
427+
)
428+
assert not any(isinstance(p, SitemapPlugin) for p in config.plugins)
429+
430+
def test_disable_plugins_normalized_to_classes(self):
431+
"""Test that disable_plugins entries are normalized to Plugin subclasses."""
432+
config = rx.Config(app_name="test", disable_plugins=[SitemapPlugin])
433+
assert all(
434+
isinstance(dp, type) and issubclass(dp, Plugin)
435+
for dp in config.disable_plugins
436+
)
437+
438+
def test_disable_instance_normalized_to_class(self):
439+
"""Test that a Plugin instance in disable_plugins is normalized to its class."""
440+
config = rx.Config(app_name="test", disable_plugins=[SitemapPlugin()]) # pyright: ignore[reportArgumentType]
441+
assert config.disable_plugins == [SitemapPlugin]
442+
443+
def test_disable_string_normalized_to_class(self):
444+
"""Test that a string in disable_plugins is normalized to the class."""
445+
config = rx.Config(
446+
app_name="test",
447+
disable_plugins=["reflex.plugins.sitemap.SitemapPlugin"], # pyright: ignore[reportArgumentType]
448+
)
449+
assert config.disable_plugins == [SitemapPlugin]
450+
451+
def test_disable_and_plugins_conflict_warns(self):
452+
"""Test that a warning is issued when a plugin is both enabled and disabled."""
453+
config = rx.Config(
454+
app_name="test",
455+
plugins=[SitemapPlugin()],
456+
disable_plugins=[SitemapPlugin],
457+
)
458+
# Plugin should still be in plugins list (just warned)
459+
assert any(isinstance(p, SitemapPlugin) for p in config.plugins)
460+
461+
def test_no_disable_adds_builtin(self):
462+
"""Test that builtin plugins are added when not disabled."""
463+
config = rx.Config(app_name="test")
464+
assert any(isinstance(p, SitemapPlugin) for p in config.plugins)

0 commit comments

Comments
 (0)