Skip to content

Add error checking to component template tag #154

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Jun 27, 2023
Merged
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,13 @@ Using the following categories, list your changes in this order:

## [Unreleased]

- Nothing (yet)
### Added

- Template tag exception details are now rendered on the webpage when `DEBUG` is enabled.

### Fixed

- Prevent exceptions within the `component` template tag from causing the whole template to fail to render.

## [3.2.0] - 2023-06-08

Expand Down
2 changes: 1 addition & 1 deletion docs/python/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@
# Dotted path to the Django authentication backend to use for ReactPy components
# This is only needed if:
# 1. You are using `AuthMiddlewareStack` and...
# 2. You are using Django's `AUTHENTICATION_BACKENDS` settings and...
# 2. You are using Django's `AUTHENTICATION_BACKENDS` setting and...
# 3. Your Django user model does not define a `backend` attribute
REACTPY_AUTH_BACKEND = None
6 changes: 6 additions & 0 deletions src/reactpy_django/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class ComponentParamError(TypeError):
...


class ComponentDoesNotExistError(AttributeError):
...
6 changes: 2 additions & 4 deletions src/reactpy_django/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,7 @@ async def execute_query() -> None:
set_data(None)
set_loading(False)
set_error(e)
_logger.exception(
f"Failed to execute query: {generate_obj_name(query) or query}"
)
_logger.exception(f"Failed to execute query: {generate_obj_name(query)}")
return

# Query was successful
Expand Down Expand Up @@ -252,7 +250,7 @@ async def execute_mutation(exec_args, exec_kwargs) -> None:
set_loading(False)
set_error(e)
_logger.exception(
f"Failed to execute mutation: {generate_obj_name(mutation) or mutation}"
f"Failed to execute mutation: {generate_obj_name(mutation)}"
)

# Mutation was successful
Expand Down
6 changes: 6 additions & 0 deletions src/reactpy_django/templates/reactpy/component.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
{% load static %}
{% if reactpy_failure %}
{% if reactpy_debug_mode %}
<b>{% firstof reactpy_error "UnknownError" %}:</b> "{% firstof reactpy_dotted_path "UnknownPath" %}"
{% endif %}
{% else %}
<div id="{{ reactpy_mount_uuid }}" class="{{ class }}"></div>
<script type="module" crossorigin="anonymous">
import { mountViewToElement } from "{% static 'reactpy_django/client.js' %}";
Expand All @@ -11,3 +16,4 @@
"{{ reactpy_component_path }}",
);
</script>
{% endif %}
58 changes: 48 additions & 10 deletions src/reactpy_django/templatetags/reactpy.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from logging import getLogger
from uuid import uuid4

import dill as pickle
Expand All @@ -7,15 +8,22 @@
from reactpy_django import models
from reactpy_django.config import (
REACTPY_DATABASE,
REACTPY_DEBUG_MODE,
REACTPY_RECONNECT_MAX,
REACTPY_WEBSOCKET_URL,
)
from reactpy_django.exceptions import ComponentDoesNotExistError, ComponentParamError
from reactpy_django.types import ComponentParamData
from reactpy_django.utils import _register_component, func_has_params
from reactpy_django.utils import (
_register_component,
check_component_args,
func_has_args,
)


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


@register.inclusion_tag("reactpy/component.html")
Expand All @@ -39,24 +47,45 @@ def component(dotted_path: str, *args, **kwargs):
</body>
</html>
"""
component = _register_component(dotted_path)
uuid = uuid4().hex
class_ = kwargs.pop("class", "")
kwargs.pop("key", "") # `key` is effectively useless for the root node

# Register the component if needed
try:
component = _register_component(dotted_path)
uuid = uuid4().hex
class_ = kwargs.pop("class", "")
kwargs.pop("key", "") # `key` is effectively useless for the root node

except Exception as e:
if isinstance(e, ComponentDoesNotExistError):
_logger.error(str(e))
else:
_logger.exception(
"An unknown error has occurred while registering component '%s'.",
dotted_path,
)
return failure_context(dotted_path, e)

# Store the component's args/kwargs in the database if needed
# This will be fetched by the websocket consumer later
try:
if func_has_params(component, *args, **kwargs):
check_component_args(component, *args, **kwargs)
if func_has_args(component):
params = ComponentParamData(args, kwargs)
model = models.ComponentSession(uuid=uuid, params=pickle.dumps(params))
model.full_clean()
model.save(using=REACTPY_DATABASE)
except TypeError as e:
raise TypeError(
f"The provided parameters are incompatible with component '{dotted_path}'."
) from e

except Exception as e:
if isinstance(e, ComponentParamError):
_logger.error(str(e))
else:
_logger.exception(
"An unknown error has occurred while saving component params for '%s'.",
dotted_path,
)
return failure_context(dotted_path, e)

# Return the template rendering context
return {
"class": class_,
"reactpy_websocket_url": REACTPY_WEBSOCKET_URL,
Expand All @@ -65,3 +94,12 @@ def component(dotted_path: str, *args, **kwargs):
"reactpy_mount_uuid": uuid,
"reactpy_component_path": f"{dotted_path}/{uuid}/",
}


def failure_context(dotted_path: str, error: Exception):
return {
"reactpy_failure": True,
"reactpy_debug_mode": REACTPY_DEBUG_MODE,
"reactpy_dotted_path": dotted_path,
"reactpy_error": type(error).__name__,
}
45 changes: 32 additions & 13 deletions src/reactpy_django/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
from django.utils.encoding import smart_str
from django.views import View

from reactpy_django.exceptions import ComponentDoesNotExistError, ComponentParamError


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

REACTPY_REGISTERED_COMPONENTS[dotted_path] = import_dotted_path(dotted_path)
try:
REACTPY_REGISTERED_COMPONENTS[dotted_path] = import_dotted_path(dotted_path)
except AttributeError as e:
raise ComponentDoesNotExistError(
f"Error while fetching '{dotted_path}'. {(str(e).capitalize())}."
) from e
_logger.debug("ReactPy has registered component %s", dotted_path)
return REACTPY_REGISTERED_COMPONENTS[dotted_path]

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


def generate_obj_name(object: Any) -> str | None:
def generate_obj_name(object: Any) -> str:
"""Makes a best effort to create a name for an object.
Useful for JSON serialization of Python objects."""
if hasattr(object, "__module__"):
if hasattr(object, "__name__"):
return f"{object.__module__}.{object.__name__}"
if hasattr(object, "__class__"):
return f"{object.__module__}.{object.__class__.__name__}"
return None

with contextlib.suppress(Exception):
return str(object)
return ""


def django_query_postprocessor(
Expand Down Expand Up @@ -284,20 +294,29 @@ def django_query_postprocessor(
return data


def func_has_params(func: Callable, *args, **kwargs) -> bool:
"""Checks if a function has any args or kwarg parameters.

Can optionally validate whether a set of args/kwargs would work on the given function.
"""
def func_has_args(func: Callable) -> bool:
"""Checks if a function has any args or kwarg."""
signature = inspect.signature(func)

# Check if the function has any args/kwargs
if not args and not kwargs:
return str(signature) != "()"
return str(signature) != "()"


# Check if the function has the given args/kwargs
signature.bind(*args, **kwargs)
return True
def check_component_args(func: Callable, *args, **kwargs):
"""
Validate whether a set of args/kwargs would work on the given function.

Raises `ComponentParamError` if the args/kwargs are invalid.
"""
signature = inspect.signature(func)

try:
signature.bind(*args, **kwargs)
except TypeError as e:
name = generate_obj_name(func)
raise ComponentParamError(
f"Invalid args for '{name}'. {str(e).capitalize()}."
) from e


def create_cache_key(*args):
Expand Down
4 changes: 2 additions & 2 deletions src/reactpy_django/websocket/consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from reactpy.core.serve import serve_layout

from reactpy_django.types import ComponentParamData, ComponentWebsocket
from reactpy_django.utils import db_cleanup, func_has_params
from reactpy_django.utils import db_cleanup, func_has_args


_logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -111,7 +111,7 @@ async def _run_dispatch_loop(self):

# Fetch the component's args/kwargs from the database, if needed
try:
if func_has_params(component_constructor):
if func_has_args(component_constructor):
try:
# Always clean up expired entries first
await database_sync_to_async(db_cleanup, thread_sensitive=False)()
Expand Down
Loading