diff --git a/CHANGELOG.md b/CHANGELOG.md index dcda688d..ef52d7de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/python/settings.py b/docs/python/settings.py index 9633da43..a08dbc55 100644 --- a/docs/python/settings.py +++ b/docs/python/settings.py @@ -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 diff --git a/src/reactpy_django/exceptions.py b/src/reactpy_django/exceptions.py new file mode 100644 index 00000000..072f1d4f --- /dev/null +++ b/src/reactpy_django/exceptions.py @@ -0,0 +1,6 @@ +class ComponentParamError(TypeError): + ... + + +class ComponentDoesNotExistError(AttributeError): + ... diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index 00e22bd9..e9f80908 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -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 @@ -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 diff --git a/src/reactpy_django/templates/reactpy/component.html b/src/reactpy_django/templates/reactpy/component.html index 08ab566d..7dae08eb 100644 --- a/src/reactpy_django/templates/reactpy/component.html +++ b/src/reactpy_django/templates/reactpy/component.html @@ -1,4 +1,9 @@ {% load static %} +{% if reactpy_failure %} +{% if reactpy_debug_mode %} +{% firstof reactpy_error "UnknownError" %}: "{% firstof reactpy_dotted_path "UnknownPath" %}" +{% endif %} +{% else %}
+{% endif %} diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index b508d0e9..d1ce87e5 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -1,3 +1,4 @@ +from logging import getLogger from uuid import uuid4 import dill as pickle @@ -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") @@ -39,24 +47,45 @@ def component(dotted_path: str, *args, **kwargs): """ - 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, @@ -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__, + } diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index da973a60..a9edbd3f 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -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"(?Pcomponent)" @@ -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] @@ -210,7 +217,7 @@ 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__"): @@ -218,7 +225,10 @@ def generate_obj_name(object: Any) -> str | None: 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( @@ -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): diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index de8ec423..90ee0632 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -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__) @@ -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)() diff --git a/tests/test_app/components.py b/tests/test_app/components.py index 6dddefaf..433bd9e4 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -26,7 +26,7 @@ @component def hello_world(): - return html._(html.div({"id": "hello-world"}, "Hello World!"), html.hr()) + return html._(html.div({"id": "hello-world"}, "Hello World!")) @component @@ -42,8 +42,7 @@ def button(): html.p( {"id": "counter-num", "data-count": count}, f"Current count is: {count}" ), - ), - html.hr(), + ) ) @@ -54,8 +53,7 @@ def parameterized_component(x, y): html.div( {"id": "parametrized-component", "data-value": total}, f"parameterized_component: {total}", - ), - html.hr(), + ) ) @@ -66,8 +64,7 @@ def object_in_templatetag(my_object: TestObject): return html._( html.div( {"id": co_name, "data-success": success}, f"{co_name}: ", str(my_object) - ), - html.hr(), + ) ) @@ -82,11 +79,7 @@ def object_in_templatetag(my_object: TestObject): @component def simple_button(): - return html._( - "simple_button:", - SimpleButton({"id": "simple-button"}), - html.hr(), - ) + return html._("simple_button:", SimpleButton({"id": "simple-button"})) @component @@ -100,9 +93,7 @@ def use_connection(): and getattr(ws.carrier, "dotted_path", None) ) return html.div( - {"id": "use-connection", "data-success": success}, - f"use_connection: {ws}", - html.hr(), + {"id": "use-connection", "data-success": success}, f"use_connection: {ws}" ) @@ -110,9 +101,7 @@ def use_connection(): def use_scope(): scope = reactpy_django.hooks.use_scope() success = len(scope) >= 10 and scope["type"] == "websocket" - return html.div( - {"id": "use-scope", "data-success": success}, f"use_scope: {scope}", html.hr() - ) + return html.div({"id": "use-scope", "data-success": success}, f"use_scope: {scope}") @component @@ -120,9 +109,7 @@ def use_location(): location = reactpy_django.hooks.use_location() success = bool(location) return html.div( - {"id": "use-location", "data-success": success}, - f"use_location: {location}", - html.hr(), + {"id": "use-location", "data-success": success}, f"use_location: {location}" ) @@ -131,9 +118,7 @@ def use_origin(): origin = reactpy_django.hooks.use_origin() success = bool(origin) return html.div( - {"id": "use-origin", "data-success": success}, - f"use_origin: {origin}", - html.hr(), + {"id": "use-origin", "data-success": success}, f"use_origin: {origin}" ) @@ -144,7 +129,6 @@ def django_css(): reactpy_django.components.django_css("django-css-test.css", key="test"), html.div({"style": {"display": "inline"}}, "django_css: "), html.button("This text should be blue."), - html.hr(), ) @@ -156,42 +140,27 @@ def django_js(): {"id": "django-js", "data-success": success}, f"django_js: {success}", reactpy_django.components.django_js("django-js-test.js", key="test"), - ), - html.hr(), + ) ) @component @reactpy_django.decorators.auth_required( fallback=html.div( - {"id": "unauthorized-user-fallback"}, - "unauthorized_user: Success", - html.hr(), + {"id": "unauthorized-user-fallback"}, "unauthorized_user: Success" ) ) def unauthorized_user(): - return html.div( - {"id": "unauthorized-user"}, - "unauthorized_user: Fail", - html.hr(), - ) + return html.div({"id": "unauthorized-user"}, "unauthorized_user: Fail") @component @reactpy_django.decorators.auth_required( auth_attribute="is_anonymous", - fallback=html.div( - {"id": "authorized-user-fallback"}, - "authorized_user: Fail", - html.hr(), - ), + fallback=html.div({"id": "authorized-user-fallback"}, "authorized_user: Fail"), ) def authorized_user(): - return html.div( - {"id": "authorized-user"}, - "authorized_user: Success", - html.hr(), - ) + return html.div({"id": "authorized-user"}, "authorized_user: Success") def create_relational_parent() -> RelationalParent: @@ -242,7 +211,6 @@ def relational_query(): html.div(f"Relational Parent One To One: {oto}"), html.div(f"Relational Parent Many to One: {mto}"), html.div(f"Relational Child Foreign Key: {fk}"), - html.hr(), ) @@ -304,7 +272,6 @@ def async_relational_query(): html.div(f"Relational Parent One To One: {oto}"), html.div(f"Relational Parent Many to One: {mto}"), html.div(f"Relational Child Foreign Key: {fk}"), - html.hr(), ) @@ -400,7 +367,6 @@ def on_change(event): ), mutation_status, rendered_items, - html.hr(), ) @@ -476,7 +442,6 @@ async def on_change(event): ), mutation_status, rendered_items, - html.hr(), ) @@ -513,7 +478,6 @@ def view_to_component_sync_func_compatibility(): return html.div( {"id": inspect.currentframe().f_code.co_name}, # type: ignore _view_to_component_sync_func_compatibility(key="test"), - html.hr(), ) @@ -522,7 +486,6 @@ def view_to_component_async_func_compatibility(): return html.div( {"id": inspect.currentframe().f_code.co_name}, # type: ignore _view_to_component_async_func_compatibility(), - html.hr(), ) @@ -531,7 +494,6 @@ def view_to_component_sync_class_compatibility(): return html.div( {"id": inspect.currentframe().f_code.co_name}, # type: ignore _view_to_component_sync_class_compatibility(), - html.hr(), ) @@ -540,7 +502,6 @@ def view_to_component_async_class_compatibility(): return html.div( {"id": inspect.currentframe().f_code.co_name}, # type: ignore _view_to_component_async_class_compatibility(), - html.hr(), ) @@ -549,7 +510,6 @@ def view_to_component_template_view_class_compatibility(): return html.div( {"id": inspect.currentframe().f_code.co_name}, # type: ignore _view_to_component_template_view_class_compatibility(), - html.hr(), ) diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index ba69185c..10eaac27 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -8,51 +8,87 @@ ReactPy - - - -

ReactPy Test Page

+ + + +

ReactPy Test Page

+
+ {% component "test_app.components.hello_world" class="hello-world" %} +
+ {% component "test_app.components.button" class="button" %} +
+ {% component "test_app.components.parameterized_component" class="parametarized-component" x=123 y=456 %} +
+ {% component "test_app.components.object_in_templatetag" my_object %} +
+ {% component "test_app.components.simple_button" %} +
+ {% component "test_app.components.use_connection" %} +
+ {% component "test_app.components.use_scope" %} +
+ {% component "test_app.components.use_location" %} +
+ {% component "test_app.components.use_origin" %} +
+ {% component "test_app.components.django_css" %} +
+ {% component "test_app.components.django_js" %} +
+ {% component "test_app.components.unauthorized_user" %} +
+ {% component "test_app.components.authorized_user" %} +
+ {% component "test_app.components.relational_query" %} +
+ {% component "test_app.components.async_relational_query" %} +
+ {% component "test_app.components.todo_list" %} +
+ {% component "test_app.components.async_todo_list" %} +
+ {% component "test_app.components.view_to_component_sync_func" %} +
+ {% component "test_app.components.view_to_component_async_func" %} +
+ {% component "test_app.components.view_to_component_sync_class" %} +
+ {% component "test_app.components.view_to_component_async_class" %} +
+ {% component "test_app.components.view_to_component_template_view_class" %} +
+ {% component "test_app.components.view_to_component_script" %} +
+ {% component "test_app.components.view_to_component_request" %} +
+ {% component "test_app.components.view_to_component_args" %} +
+ {% component "test_app.components.view_to_component_kwargs" %} +
+ {% component "test_app.components.view_to_component_sync_func_compatibility" %} +
+ {% component "test_app.components.view_to_component_async_func_compatibility" %} +
+ {% component "test_app.components.view_to_component_sync_class_compatibility" %} +
+ {% component "test_app.components.view_to_component_async_class_compatibility" %} +
+ {% component "test_app.components.view_to_component_template_view_class_compatibility" %} +
+ {% component "test_app.components.view_to_component_decorator" %} +
+ {% component "test_app.components.view_to_component_decorator_args" %} +
+
{% component "test_app.components.does_not_exist" %}
+
+
{% component "test_app.components.hello_world" invalid_param="random_value" %}

-
{% component "test_app.components.hello_world" class="hello-world" %}
-
{% component "test_app.components.button" class="button" %}
-
{% component "test_app.components.parameterized_component" class="parametarized-component" x=123 y=456 %}
-
{% component "test_app.components.object_in_templatetag" my_object %}
-
{% component "test_app.components.simple_button" %}
-
{% component "test_app.components.use_connection" %}
-
{% component "test_app.components.use_scope" %}
-
{% component "test_app.components.use_location" %}
-
{% component "test_app.components.use_origin" %}
-
{% component "test_app.components.django_css" %}
-
{% component "test_app.components.django_js" %}
-
{% component "test_app.components.unauthorized_user" %}
-
{% component "test_app.components.authorized_user" %}
-
{% component "test_app.components.relational_query" %}
-
{% component "test_app.components.async_relational_query" %}
-
{% component "test_app.components.todo_list" %}
-
{% component "test_app.components.async_todo_list" %}
-
{% component "test_app.components.view_to_component_sync_func" %}
-
{% component "test_app.components.view_to_component_async_func" %}
-
{% component "test_app.components.view_to_component_sync_class" %}
-
{% component "test_app.components.view_to_component_async_class" %}
-
{% component "test_app.components.view_to_component_template_view_class" %}
-
{% component "test_app.components.view_to_component_script" %}
-
{% component "test_app.components.view_to_component_request" %}
-
{% component "test_app.components.view_to_component_args" %}
-
{% component "test_app.components.view_to_component_kwargs" %}
-
{% component "test_app.components.view_to_component_sync_func_compatibility" %}
-
{% component "test_app.components.view_to_component_async_func_compatibility" %}
-
{% component "test_app.components.view_to_component_sync_class_compatibility" %}
-
{% component "test_app.components.view_to_component_async_class_compatibility" %}
-
{% component "test_app.components.view_to_component_template_view_class_compatibility" %}
-
{% component "test_app.components.view_to_component_decorator" %}
-
{% component "test_app.components.view_to_component_decorator_args" %}
diff --git a/tests/test_app/templates/view_to_component.html b/tests/test_app/templates/view_to_component.html index 7dbe51de..a1232a40 100644 --- a/tests/test_app/templates/view_to_component.html +++ b/tests/test_app/templates/view_to_component.html @@ -1,4 +1,4 @@ {% block top %}{% endblock %}
{{ test_name }}: {% firstof status "Success" %}
-
{% block bottom %}{% endblock %} +{% block bottom %}{% endblock %} diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 2b378e37..fc4ef799 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -11,6 +11,8 @@ from django.test.utils import modify_settings from playwright.sync_api import TimeoutError, sync_playwright +from reactpy_django.models import ComponentSession + CLICK_DELAY = 250 if os.getenv("GITHUB_ACTIONS") else 25 # Delay in miliseconds. @@ -257,3 +259,41 @@ def test_view_to_component_decorator_args(self): self.page.locator( "#view_to_component_decorator_args[data-success=true]" ).wait_for() + + def test_component_does_not_exist_error(self): + broken_component = self.page.locator("#component_does_not_exist_error") + broken_component.wait_for() + self.assertIn("ComponentDoesNotExistError:", broken_component.text_content()) + + def test_component_param_error(self): + broken_component = self.page.locator("#component_param_error") + broken_component.wait_for() + self.assertIn("ComponentParamError:", broken_component.text_content()) + + def test_component_session_exists(self): + """Session should exist for components with args/kwargs.""" + from reactpy_django.config import REACTPY_DATABASE + + component = self.page.locator("#parametrized-component") + component.wait_for() + parent = component.locator("..") + session_id = parent.get_attribute("id") + os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" + query = ComponentSession.objects.filter(uuid=session_id).using(REACTPY_DATABASE) + query_exists = query.exists() + os.environ.pop("DJANGO_ALLOW_ASYNC_UNSAFE") + self.assertTrue(query_exists) + + def test_component_session_missing(self): + """No session should exist for components that don't have args/kwargs.""" + from reactpy_django.config import REACTPY_DATABASE + + component = self.page.locator("#simple-button") + component.wait_for() + parent = component.locator("..") + session_id = parent.get_attribute("id") + os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" + query = ComponentSession.objects.filter(uuid=session_id).using(REACTPY_DATABASE) + query_exists = query.exists() + os.environ.pop("DJANGO_ALLOW_ASYNC_UNSAFE") + self.assertFalse(query_exists)