Skip to content

Commit 204b47c

Browse files
authored
Add error checking to component template tag (#154)
- Make it so that `component` template tag render failures don't cause catastrophic failure. - `component` template tag render failures now output a visual indicator if `REACTPY_DEBUG_MODE` is enabled. - Render `<hr>` via raw HTML within the tests so that component render failures don't break formatting - Fix typo on the docs - Ensure `generate_object_name` always returns a string
1 parent 112f280 commit 204b47c

File tree

12 files changed

+233
-124
lines changed

12 files changed

+233
-124
lines changed

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,13 @@ Using the following categories, list your changes in this order:
3434

3535
## [Unreleased]
3636

37-
- Nothing (yet)
37+
### Added
38+
39+
- Template tag exception details are now rendered on the webpage when `DEBUG` is enabled.
40+
41+
### Fixed
42+
43+
- Prevent exceptions within the `component` template tag from causing the whole template to fail to render.
3844

3945
## [3.2.0] - 2023-06-08
4046

docs/python/settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,6 @@
1717
# Dotted path to the Django authentication backend to use for ReactPy components
1818
# This is only needed if:
1919
# 1. You are using `AuthMiddlewareStack` and...
20-
# 2. You are using Django's `AUTHENTICATION_BACKENDS` settings and...
20+
# 2. You are using Django's `AUTHENTICATION_BACKENDS` setting and...
2121
# 3. Your Django user model does not define a `backend` attribute
2222
REACTPY_AUTH_BACKEND = None

src/reactpy_django/exceptions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
class ComponentParamError(TypeError):
2+
...
3+
4+
5+
class ComponentDoesNotExistError(AttributeError):
6+
...

src/reactpy_django/hooks.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,7 @@ async def execute_query() -> None:
157157
set_data(None)
158158
set_loading(False)
159159
set_error(e)
160-
_logger.exception(
161-
f"Failed to execute query: {generate_obj_name(query) or query}"
162-
)
160+
_logger.exception(f"Failed to execute query: {generate_obj_name(query)}")
163161
return
164162

165163
# Query was successful
@@ -252,7 +250,7 @@ async def execute_mutation(exec_args, exec_kwargs) -> None:
252250
set_loading(False)
253251
set_error(e)
254252
_logger.exception(
255-
f"Failed to execute mutation: {generate_obj_name(mutation) or mutation}"
253+
f"Failed to execute mutation: {generate_obj_name(mutation)}"
256254
)
257255

258256
# Mutation was successful

src/reactpy_django/templates/reactpy/component.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
{% load static %}
2+
{% if reactpy_failure %}
3+
{% if reactpy_debug_mode %}
4+
<b>{% firstof reactpy_error "UnknownError" %}:</b> "{% firstof reactpy_dotted_path "UnknownPath" %}"
5+
{% endif %}
6+
{% else %}
27
<div id="{{ reactpy_mount_uuid }}" class="{{ class }}"></div>
38
<script type="module" crossorigin="anonymous">
49
import { mountViewToElement } from "{% static 'reactpy_django/client.js' %}";
@@ -11,3 +16,4 @@
1116
"{{ reactpy_component_path }}",
1217
);
1318
</script>
19+
{% endif %}

src/reactpy_django/templatetags/reactpy.py

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from logging import getLogger
12
from uuid import uuid4
23

34
import dill as pickle
@@ -7,15 +8,22 @@
78
from reactpy_django import models
89
from reactpy_django.config import (
910
REACTPY_DATABASE,
11+
REACTPY_DEBUG_MODE,
1012
REACTPY_RECONNECT_MAX,
1113
REACTPY_WEBSOCKET_URL,
1214
)
15+
from reactpy_django.exceptions import ComponentDoesNotExistError, ComponentParamError
1316
from reactpy_django.types import ComponentParamData
14-
from reactpy_django.utils import _register_component, func_has_params
17+
from reactpy_django.utils import (
18+
_register_component,
19+
check_component_args,
20+
func_has_args,
21+
)
1522

1623

1724
REACTPY_WEB_MODULES_URL = reverse("reactpy:web_modules", args=["x"])[:-1][1:]
1825
register = template.Library()
26+
_logger = getLogger(__name__)
1927

2028

2129
@register.inclusion_tag("reactpy/component.html")
@@ -39,24 +47,45 @@ def component(dotted_path: str, *args, **kwargs):
3947
</body>
4048
</html>
4149
"""
42-
component = _register_component(dotted_path)
43-
uuid = uuid4().hex
44-
class_ = kwargs.pop("class", "")
45-
kwargs.pop("key", "") # `key` is effectively useless for the root node
50+
51+
# Register the component if needed
52+
try:
53+
component = _register_component(dotted_path)
54+
uuid = uuid4().hex
55+
class_ = kwargs.pop("class", "")
56+
kwargs.pop("key", "") # `key` is effectively useless for the root node
57+
58+
except Exception as e:
59+
if isinstance(e, ComponentDoesNotExistError):
60+
_logger.error(str(e))
61+
else:
62+
_logger.exception(
63+
"An unknown error has occurred while registering component '%s'.",
64+
dotted_path,
65+
)
66+
return failure_context(dotted_path, e)
4667

4768
# Store the component's args/kwargs in the database if needed
4869
# This will be fetched by the websocket consumer later
4970
try:
50-
if func_has_params(component, *args, **kwargs):
71+
check_component_args(component, *args, **kwargs)
72+
if func_has_args(component):
5173
params = ComponentParamData(args, kwargs)
5274
model = models.ComponentSession(uuid=uuid, params=pickle.dumps(params))
5375
model.full_clean()
5476
model.save(using=REACTPY_DATABASE)
55-
except TypeError as e:
56-
raise TypeError(
57-
f"The provided parameters are incompatible with component '{dotted_path}'."
58-
) from e
5977

78+
except Exception as e:
79+
if isinstance(e, ComponentParamError):
80+
_logger.error(str(e))
81+
else:
82+
_logger.exception(
83+
"An unknown error has occurred while saving component params for '%s'.",
84+
dotted_path,
85+
)
86+
return failure_context(dotted_path, e)
87+
88+
# Return the template rendering context
6089
return {
6190
"class": class_,
6291
"reactpy_websocket_url": REACTPY_WEBSOCKET_URL,
@@ -65,3 +94,12 @@ def component(dotted_path: str, *args, **kwargs):
6594
"reactpy_mount_uuid": uuid,
6695
"reactpy_component_path": f"{dotted_path}/{uuid}/",
6796
}
97+
98+
99+
def failure_context(dotted_path: str, error: Exception):
100+
return {
101+
"reactpy_failure": True,
102+
"reactpy_debug_mode": REACTPY_DEBUG_MODE,
103+
"reactpy_dotted_path": dotted_path,
104+
"reactpy_error": type(error).__name__,
105+
}

src/reactpy_django/utils.py

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
from django.utils.encoding import smart_str
2323
from django.views import View
2424

25+
from reactpy_django.exceptions import ComponentDoesNotExistError, ComponentParamError
26+
2527

2628
_logger = logging.getLogger(__name__)
2729
_component_tag = r"(?P<tag>component)"
@@ -91,7 +93,12 @@ def _register_component(dotted_path: str) -> Callable:
9193
if dotted_path in REACTPY_REGISTERED_COMPONENTS:
9294
return REACTPY_REGISTERED_COMPONENTS[dotted_path]
9395

94-
REACTPY_REGISTERED_COMPONENTS[dotted_path] = import_dotted_path(dotted_path)
96+
try:
97+
REACTPY_REGISTERED_COMPONENTS[dotted_path] = import_dotted_path(dotted_path)
98+
except AttributeError as e:
99+
raise ComponentDoesNotExistError(
100+
f"Error while fetching '{dotted_path}'. {(str(e).capitalize())}."
101+
) from e
95102
_logger.debug("ReactPy has registered component %s", dotted_path)
96103
return REACTPY_REGISTERED_COMPONENTS[dotted_path]
97104

@@ -210,15 +217,18 @@ def register_components(self, components: set[str]) -> None:
210217
)
211218

212219

213-
def generate_obj_name(object: Any) -> str | None:
220+
def generate_obj_name(object: Any) -> str:
214221
"""Makes a best effort to create a name for an object.
215222
Useful for JSON serialization of Python objects."""
216223
if hasattr(object, "__module__"):
217224
if hasattr(object, "__name__"):
218225
return f"{object.__module__}.{object.__name__}"
219226
if hasattr(object, "__class__"):
220227
return f"{object.__module__}.{object.__class__.__name__}"
221-
return None
228+
229+
with contextlib.suppress(Exception):
230+
return str(object)
231+
return ""
222232

223233

224234
def django_query_postprocessor(
@@ -284,20 +294,29 @@ def django_query_postprocessor(
284294
return data
285295

286296

287-
def func_has_params(func: Callable, *args, **kwargs) -> bool:
288-
"""Checks if a function has any args or kwarg parameters.
289-
290-
Can optionally validate whether a set of args/kwargs would work on the given function.
291-
"""
297+
def func_has_args(func: Callable) -> bool:
298+
"""Checks if a function has any args or kwarg."""
292299
signature = inspect.signature(func)
293300

294301
# Check if the function has any args/kwargs
295-
if not args and not kwargs:
296-
return str(signature) != "()"
302+
return str(signature) != "()"
303+
297304

298-
# Check if the function has the given args/kwargs
299-
signature.bind(*args, **kwargs)
300-
return True
305+
def check_component_args(func: Callable, *args, **kwargs):
306+
"""
307+
Validate whether a set of args/kwargs would work on the given function.
308+
309+
Raises `ComponentParamError` if the args/kwargs are invalid.
310+
"""
311+
signature = inspect.signature(func)
312+
313+
try:
314+
signature.bind(*args, **kwargs)
315+
except TypeError as e:
316+
name = generate_obj_name(func)
317+
raise ComponentParamError(
318+
f"Invalid args for '{name}'. {str(e).capitalize()}."
319+
) from e
301320

302321

303322
def create_cache_key(*args):

src/reactpy_django/websocket/consumer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from reactpy.core.serve import serve_layout
1818

1919
from reactpy_django.types import ComponentParamData, ComponentWebsocket
20-
from reactpy_django.utils import db_cleanup, func_has_params
20+
from reactpy_django.utils import db_cleanup, func_has_args
2121

2222

2323
_logger = logging.getLogger(__name__)
@@ -111,7 +111,7 @@ async def _run_dispatch_loop(self):
111111

112112
# Fetch the component's args/kwargs from the database, if needed
113113
try:
114-
if func_has_params(component_constructor):
114+
if func_has_args(component_constructor):
115115
try:
116116
# Always clean up expired entries first
117117
await database_sync_to_async(db_cleanup, thread_sensitive=False)()

0 commit comments

Comments
 (0)