From 824dcc7950f39f8bbd84109afc798c32b11544d0 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 24 Jun 2023 21:05:18 -0700 Subject: [PATCH 01/14] Write `
` into raw HTML --- tests/test_app/components.py | 68 ++++--------------- tests/test_app/templates/base.html | 34 +++++++++- .../test_app/templates/view_to_component.html | 2 +- 3 files changed, 48 insertions(+), 56 deletions(-) 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..80d1ab32 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -17,42 +17,74 @@

ReactPy Test Page

width: 100%; height: 45px; } -
{% 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 %} From efbc14b305f3f7b1bcb4870b29276ffd129a9296 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 24 Jun 2023 21:07:35 -0700 Subject: [PATCH 02/14] Add error checking to `component` template tag --- src/reactpy_django/exceptions.py | 2 + .../templates/reactpy/component.html | 6 ++ src/reactpy_django/templatetags/reactpy.py | 64 +++++++++++++++---- src/reactpy_django/utils.py | 30 ++++++--- 4 files changed, 78 insertions(+), 24 deletions(-) create mode 100644 src/reactpy_django/exceptions.py diff --git a/src/reactpy_django/exceptions.py b/src/reactpy_django/exceptions.py new file mode 100644 index 00000000..babc6cc3 --- /dev/null +++ b/src/reactpy_django/exceptions.py @@ -0,0 +1,2 @@ +class ComponentParamError(TypeError): + ... 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..e70efc27 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,18 @@ 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 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_params 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 +43,56 @@ 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: + _logger.exception( + "An unknown error has occurred while registering component '%s'.", + dotted_path, + ) + return { + "reactpy_failure": True, + "reactpy_debug_mode": REACTPY_DEBUG_MODE, + "reactpy_dotted_path": dotted_path, + "reactpy_error": type(e).__name__, + } # 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): - 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 + check_component_params(component, *args, **kwargs) + params = ComponentParamData(args, kwargs) + model = models.ComponentSession(uuid=uuid, params=pickle.dumps(params)) + model.full_clean() + model.save(using=REACTPY_DATABASE) + except ComponentParamError as e: + _logger.exception( + "The provided parameters are incompatible with component '%s'.", + dotted_path, + ) + return { + "reactpy_failure": True, + "reactpy_debug_mode": REACTPY_DEBUG_MODE, + "reactpy_dotted_path": dotted_path, + "reactpy_error": type(e).__name__, + } + except Exception as e: + _logger.exception( + "An unknown error has occurred while saving component params for '%s'.", + dotted_path, + ) + return { + "reactpy_failure": True, + "reactpy_debug_mode": REACTPY_DEBUG_MODE, + "reactpy_dotted_path": dotted_path, + "reactpy_error": type(e).__name__, + } + # Return the template rendering context return { "class": class_, "reactpy_websocket_url": REACTPY_WEBSOCKET_URL, diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index da973a60..558bf248 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 ComponentParamError + _logger = logging.getLogger(__name__) _component_tag = r"(?Pcomponent)" @@ -284,20 +286,28 @@ 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_params(func: Callable) -> bool: + """Checks if a function has any args or kwarg parameters.""" signature = inspect.signature(func) # Check if the function has any args/kwargs - if not args and not kwargs: - return str(signature) != "()" + return str(signature) != "()" or True + + +def check_component_params(func: Callable, *args, **kwargs): + """ + Validate whether a set of args/kwargs would work on the given function. - # Check if the function has the given args/kwargs - signature.bind(*args, **kwargs) - return True + Raises `ComponentParamError` if the args/kwargs are invalid. + """ + signature = inspect.signature(func) + + try: + signature.bind(*args, **kwargs) + except TypeError as e: + raise ComponentParamError( + f"Invalid parameters passed to component. {str(e).capitalize()}." + ) from e def create_cache_key(*args): From 2aca7d8c2e29dcecc49e67b7845a613dc8ea4b75 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 24 Jun 2023 21:07:40 -0700 Subject: [PATCH 03/14] fix comment --- docs/python/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 89877880c38fad3070e0449f5bb9f156baa6fe81 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 24 Jun 2023 21:12:38 -0700 Subject: [PATCH 04/14] remove unneeded div from tests --- tests/test_app/templates/base.html | 74 +++++++++++++++--------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index 80d1ab32..6da9af73 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -8,82 +8,82 @@ ReactPy - - - -

ReactPy Test Page

+ + + +

ReactPy Test Page


-
{% component "test_app.components.hello_world" class="hello-world" %}
+ {% component "test_app.components.hello_world" class="hello-world" %}
-
{% component "test_app.components.button" class="button" %}
+ {% component "test_app.components.button" class="button" %}
-
{% component "test_app.components.parameterized_component" class="parametarized-component" x=123 y=456 %}
+ {% 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.object_in_templatetag" my_object %}
-
{% component "test_app.components.simple_button" %}
+ {% component "test_app.components.simple_button" %}
-
{% component "test_app.components.use_connection" %}
+ {% component "test_app.components.use_connection" %}
-
{% component "test_app.components.use_scope" %}
+ {% component "test_app.components.use_scope" %}
-
{% component "test_app.components.use_location" %}
+ {% component "test_app.components.use_location" %}
-
{% component "test_app.components.use_origin" %}
+ {% component "test_app.components.use_origin" %}
-
{% component "test_app.components.django_css" %}
+ {% component "test_app.components.django_css" %}
-
{% component "test_app.components.django_js" %}
+ {% component "test_app.components.django_js" %}
-
{% component "test_app.components.unauthorized_user" %}
+ {% component "test_app.components.unauthorized_user" %}
-
{% component "test_app.components.authorized_user" %}
+ {% component "test_app.components.authorized_user" %}
-
{% component "test_app.components.relational_query" %}
+ {% component "test_app.components.relational_query" %}
-
{% component "test_app.components.async_relational_query" %}
+ {% component "test_app.components.async_relational_query" %}
-
{% component "test_app.components.todo_list" %}
+ {% component "test_app.components.todo_list" %}
-
{% component "test_app.components.async_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_sync_func" %}
-
{% component "test_app.components.view_to_component_async_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_sync_class" %}
-
{% component "test_app.components.view_to_component_async_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_template_view_class" %}
-
{% component "test_app.components.view_to_component_script" %}
+ {% component "test_app.components.view_to_component_script" %}
-
{% component "test_app.components.view_to_component_request" %}
+ {% component "test_app.components.view_to_component_request" %}
-
{% component "test_app.components.view_to_component_args" %}
+ {% component "test_app.components.view_to_component_args" %}
-
{% component "test_app.components.view_to_component_kwargs" %}
+ {% 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_sync_func_compatibility" %}
-
{% component "test_app.components.view_to_component_async_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_sync_class_compatibility" %}
-
{% component "test_app.components.view_to_component_async_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_template_view_class_compatibility" %}
-
{% component "test_app.components.view_to_component_decorator" %}
+ {% component "test_app.components.view_to_component_decorator" %}
-
{% component "test_app.components.view_to_component_decorator_args" %}
+ {% component "test_app.components.view_to_component_decorator_args" %}
From adc32dfceb87b036bf9ab519698b0c9ee09096d6 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 24 Jun 2023 21:32:57 -0700 Subject: [PATCH 05/14] ComponentDoesNotExistError --- src/reactpy_django/exceptions.py | 4 ++++ src/reactpy_django/templatetags/reactpy.py | 14 +++++++++++++- src/reactpy_django/utils.py | 9 +++++++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/reactpy_django/exceptions.py b/src/reactpy_django/exceptions.py index babc6cc3..5c9e8a0f 100644 --- a/src/reactpy_django/exceptions.py +++ b/src/reactpy_django/exceptions.py @@ -1,2 +1,6 @@ class ComponentParamError(TypeError): ... + + +class ComponentDoesNotExistError(TypeError): + ... diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index e70efc27..efa66005 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -12,7 +12,7 @@ REACTPY_RECONNECT_MAX, REACTPY_WEBSOCKET_URL, ) -from reactpy_django.exceptions import ComponentParamError +from reactpy_django.exceptions import ComponentDoesNotExistError, ComponentParamError from reactpy_django.types import ComponentParamData from reactpy_django.utils import _register_component, check_component_params @@ -43,12 +43,24 @@ def component(dotted_path: str, *args, **kwargs): """ + # 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 ComponentDoesNotExistError as e: + _logger.exception( + "The component '%s' does not exist or is not a valid ReactPy component.", + dotted_path, + ) + return { + "reactpy_failure": True, + "reactpy_debug_mode": REACTPY_DEBUG_MODE, + "reactpy_dotted_path": dotted_path, + "reactpy_error": type(e).__name__, + } except Exception as e: _logger.exception( "An unknown error has occurred while registering component '%s'.", diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 558bf248..ccef2d18 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -22,7 +22,7 @@ from django.utils.encoding import smart_str from django.views import View -from reactpy_django.exceptions import ComponentParamError +from reactpy_django.exceptions import ComponentDoesNotExistError, ComponentParamError _logger = logging.getLogger(__name__) @@ -93,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"Component {dotted_path} does not exist." + ) from e _logger.debug("ReactPy has registered component %s", dotted_path) return REACTPY_REGISTERED_COMPONENTS[dotted_path] From 14011adc405701a41416c29859c7cee970550382 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 24 Jun 2023 23:23:33 -0700 Subject: [PATCH 06/14] add tests --- tests/test_app/templates/base.html | 4 ++++ tests/test_app/tests/test_components.py | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index 6da9af73..10eaac27 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -85,6 +85,10 @@

ReactPy Test Page


{% 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" %}
+
diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 2b378e37..8557cb75 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -257,3 +257,13 @@ 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_compoent_param_error(self): + broken_component = self.page.locator("#component_param_error") + broken_component.wait_for() + self.assertIn("ComponentParamError:", broken_component.text_content()) From 676e3087bb9d19d53b7efe132b841a6889df2d01 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 24 Jun 2023 23:28:30 -0700 Subject: [PATCH 07/14] Add changelog entries --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcda688d..51f0abc6 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 + +- Component exception info will now print to 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 From 34ea630f9f3d46e5ee4995b6177b5b9e43b11ff3 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 24 Jun 2023 23:34:01 -0700 Subject: [PATCH 08/14] tweak changelog verbiage --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51f0abc6..ef52d7de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ Using the following categories, list your changes in this order: ### Added -- Component exception info will now print to the webpage when `DEBUG` is enabled. +- Template tag exception details are now rendered on the webpage when `DEBUG` is enabled. ### Fixed From f4d471e71068af718ca4cef2ac16ca992e45c485 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 24 Jun 2023 23:39:25 -0700 Subject: [PATCH 09/14] fix base type of ComponentDoesNotExistError --- src/reactpy_django/exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy_django/exceptions.py b/src/reactpy_django/exceptions.py index 5c9e8a0f..072f1d4f 100644 --- a/src/reactpy_django/exceptions.py +++ b/src/reactpy_django/exceptions.py @@ -2,5 +2,5 @@ class ComponentParamError(TypeError): ... -class ComponentDoesNotExistError(TypeError): +class ComponentDoesNotExistError(AttributeError): ... From 4cf0aa1714f7a18a1be22cf14bffe1c588e61d9a Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 24 Jun 2023 23:41:47 -0700 Subject: [PATCH 10/14] put quotes around dotted path --- src/reactpy_django/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index ccef2d18..9c5c1025 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -97,7 +97,7 @@ def _register_component(dotted_path: str) -> Callable: REACTPY_REGISTERED_COMPONENTS[dotted_path] = import_dotted_path(dotted_path) except AttributeError as e: raise ComponentDoesNotExistError( - f"Component {dotted_path} does not exist." + f"Component '{dotted_path}' does not exist." ) from e _logger.debug("ReactPy has registered component %s", dotted_path) return REACTPY_REGISTERED_COMPONENTS[dotted_path] From 212a8b2c1f2a5cff93c26a05367129b0ffb84f36 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 25 Jun 2023 00:02:57 -0700 Subject: [PATCH 11/14] DRY failure context --- src/reactpy_django/templatetags/reactpy.py | 59 ++++++++-------------- 1 file changed, 21 insertions(+), 38 deletions(-) diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index efa66005..f9dd8850 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -50,28 +50,15 @@ def component(dotted_path: str, *args, **kwargs): uuid = uuid4().hex class_ = kwargs.pop("class", "") kwargs.pop("key", "") # `key` is effectively useless for the root node - except ComponentDoesNotExistError as e: - _logger.exception( - "The component '%s' does not exist or is not a valid ReactPy component.", - dotted_path, - ) - return { - "reactpy_failure": True, - "reactpy_debug_mode": REACTPY_DEBUG_MODE, - "reactpy_dotted_path": dotted_path, - "reactpy_error": type(e).__name__, - } + except Exception as e: - _logger.exception( - "An unknown error has occurred while registering component '%s'.", + _logger.error( + f"Error while fetching '%s'. {(str(e.__cause__).capitalize())}." + if isinstance(e, ComponentDoesNotExistError) + else "An unknown error has occurred while registering component '%s'.", dotted_path, ) - return { - "reactpy_failure": True, - "reactpy_debug_mode": REACTPY_DEBUG_MODE, - "reactpy_dotted_path": dotted_path, - "reactpy_error": type(e).__name__, - } + 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 @@ -81,28 +68,15 @@ def component(dotted_path: str, *args, **kwargs): model = models.ComponentSession(uuid=uuid, params=pickle.dumps(params)) model.full_clean() model.save(using=REACTPY_DATABASE) - except ComponentParamError as e: - _logger.exception( - "The provided parameters are incompatible with component '%s'.", - dotted_path, - ) - return { - "reactpy_failure": True, - "reactpy_debug_mode": REACTPY_DEBUG_MODE, - "reactpy_dotted_path": dotted_path, - "reactpy_error": type(e).__name__, - } + except Exception as e: - _logger.exception( - "An unknown error has occurred while saving component params for '%s'.", + _logger.error( + f"Invalid component parameters for '%s'. {(str(e.__cause__).capitalize())}." + if isinstance(e, ComponentParamError) + else "An unknown error has occurred while saving component params for '%s'.", dotted_path, ) - return { - "reactpy_failure": True, - "reactpy_debug_mode": REACTPY_DEBUG_MODE, - "reactpy_dotted_path": dotted_path, - "reactpy_error": type(e).__name__, - } + return failure_context(dotted_path, e) # Return the template rendering context return { @@ -113,3 +87,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__, + } From 5679caff99fe5f9aa5d8048f968a0b590942c355 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 26 Jun 2023 17:19:38 -0700 Subject: [PATCH 12/14] use error and exception logging differently --- src/reactpy_django/hooks.py | 6 ++--- src/reactpy_django/templatetags/reactpy.py | 30 ++++++++++++---------- src/reactpy_django/utils.py | 20 +++++++++------ src/reactpy_django/websocket/consumer.py | 4 +-- 4 files changed, 32 insertions(+), 28 deletions(-) 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/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index f9dd8850..68d510af 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -14,7 +14,7 @@ ) from reactpy_django.exceptions import ComponentDoesNotExistError, ComponentParamError from reactpy_django.types import ComponentParamData -from reactpy_django.utils import _register_component, check_component_params +from reactpy_django.utils import _register_component, check_component_args REACTPY_WEB_MODULES_URL = reverse("reactpy:web_modules", args=["x"])[:-1][1:] @@ -52,30 +52,32 @@ def component(dotted_path: str, *args, **kwargs): kwargs.pop("key", "") # `key` is effectively useless for the root node except Exception as e: - _logger.error( - f"Error while fetching '%s'. {(str(e.__cause__).capitalize())}." - if isinstance(e, ComponentDoesNotExistError) - else "An unknown error has occurred while registering component '%s'.", - dotted_path, - ) + 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: - check_component_params(component, *args, **kwargs) + check_component_args(component, *args, **kwargs) params = ComponentParamData(args, kwargs) model = models.ComponentSession(uuid=uuid, params=pickle.dumps(params)) model.full_clean() model.save(using=REACTPY_DATABASE) except Exception as e: - _logger.error( - f"Invalid component parameters for '%s'. {(str(e.__cause__).capitalize())}." - if isinstance(e, ComponentParamError) - else "An unknown error has occurred while saving component params for '%s'.", - dotted_path, - ) + 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 diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 9c5c1025..a9edbd3f 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -97,7 +97,7 @@ def _register_component(dotted_path: str) -> Callable: REACTPY_REGISTERED_COMPONENTS[dotted_path] = import_dotted_path(dotted_path) except AttributeError as e: raise ComponentDoesNotExistError( - f"Component '{dotted_path}' does not exist." + 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] @@ -217,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__"): @@ -225,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( @@ -291,15 +294,15 @@ def django_query_postprocessor( return data -def func_has_params(func: Callable) -> bool: - """Checks if a function has any args or kwarg parameters.""" +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 - return str(signature) != "()" or True + return str(signature) != "()" -def check_component_params(func: Callable, *args, **kwargs): +def check_component_args(func: Callable, *args, **kwargs): """ Validate whether a set of args/kwargs would work on the given function. @@ -310,8 +313,9 @@ def check_component_params(func: Callable, *args, **kwargs): try: signature.bind(*args, **kwargs) except TypeError as e: + name = generate_obj_name(func) raise ComponentParamError( - f"Invalid parameters passed to component. {str(e).capitalize()}." + f"Invalid args for '{name}'. {str(e).capitalize()}." ) from e 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)() From c522a2902b9b9be7c483dbb8e59b5dbd6b53e153 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 26 Jun 2023 18:53:28 -0700 Subject: [PATCH 13/14] regression test for component sessions --- src/reactpy_django/templatetags/reactpy.py | 15 ++++++---- tests/test_app/tests/test_components.py | 32 +++++++++++++++++++++- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 68d510af..d1ce87e5 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -14,7 +14,11 @@ ) from reactpy_django.exceptions import ComponentDoesNotExistError, ComponentParamError from reactpy_django.types import ComponentParamData -from reactpy_django.utils import _register_component, check_component_args +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:] @@ -65,10 +69,11 @@ def component(dotted_path: str, *args, **kwargs): # This will be fetched by the websocket consumer later try: check_component_args(component, *args, **kwargs) - params = ComponentParamData(args, kwargs) - model = models.ComponentSession(uuid=uuid, params=pickle.dumps(params)) - model.full_clean() - model.save(using=REACTPY_DATABASE) + 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 Exception as e: if isinstance(e, ComponentParamError): diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 8557cb75..5c6493fc 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. @@ -263,7 +265,35 @@ def test_component_does_not_exist_error(self): broken_component.wait_for() self.assertIn("ComponentDoesNotExistError:", broken_component.text_content()) - def test_compoent_param_error(self): + 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): + """Sssion should exist for components with args.""" + 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.""" + 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) From 0a7ff97aad107b3c86ad35c76b90d7a7db36d11d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 26 Jun 2023 18:55:16 -0700 Subject: [PATCH 14/14] fix typo --- tests/test_app/tests/test_components.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 5c6493fc..fc4ef799 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -271,7 +271,7 @@ def test_component_param_error(self): self.assertIn("ComponentParamError:", broken_component.text_content()) def test_component_session_exists(self): - """Sssion should exist for components with args.""" + """Session should exist for components with args/kwargs.""" from reactpy_django.config import REACTPY_DATABASE component = self.page.locator("#parametrized-component") @@ -285,7 +285,7 @@ def test_component_session_exists(self): self.assertTrue(query_exists) def test_component_session_missing(self): - """No session should exist for components that don't have args.""" + """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")