From 204b47c1ec75d2f4b665b7d89d59aa6b7c1d6b6d Mon Sep 17 00:00:00 2001 From: Mark Bakhit <16909269+Archmonger@users.noreply.github.com> Date: Mon, 26 Jun 2023 23:15:15 -0700 Subject: [PATCH 1/4] 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 `
` 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 --- CHANGELOG.md | 8 +- docs/python/settings.py | 2 +- src/reactpy_django/exceptions.py | 6 + src/reactpy_django/hooks.py | 6 +- .../templates/reactpy/component.html | 6 + src/reactpy_django/templatetags/reactpy.py | 58 +++++++-- src/reactpy_django/utils.py | 45 +++++-- src/reactpy_django/websocket/consumer.py | 4 +- tests/test_app/components.py | 68 +++-------- tests/test_app/templates/base.html | 112 ++++++++++++------ .../test_app/templates/view_to_component.html | 2 +- tests/test_app/tests/test_components.py | 40 +++++++ 12 files changed, 233 insertions(+), 124 deletions(-) create mode 100644 src/reactpy_django/exceptions.py 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) From a59e3a7570a2f13cfa7151cb4c3766f9434ca02a Mon Sep 17 00:00:00 2001 From: Mark Bakhit <16909269+Archmonger@users.noreply.github.com> Date: Tue, 27 Jun 2023 14:25:39 -0700 Subject: [PATCH 2/4] Create codeql.yml (#155) --- .github/workflows/codeql.yml | 78 ++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..0f26793e --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,78 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ "main" ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "main" ] + schedule: + # Runs at 22:21 on Monday. + - cron: '21 22 * * 1' + +jobs: + analyze: + name: Analyze + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'javascript', 'python' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift' ] + # Use only 'java' to analyze code written in Java, Kotlin or both + # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" From 1cd6d39e6275338bb8c3e74a93ff22e60619600a Mon Sep 17 00:00:00 2001 From: Mark Bakhit <16909269+Archmonger@users.noreply.github.com> Date: Tue, 27 Jun 2023 23:59:01 -0700 Subject: [PATCH 3/4] Simplified regex expressions (#157) - Clean up component regex, which can potentially increase regex performance. - Remove loops from component tests --- src/reactpy_django/utils.py | 6 +- tests/test_app/tests/test_regex.py | 173 +++++++++++++++++++---------- 2 files changed, 115 insertions(+), 64 deletions(-) diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index a9edbd3f..219f5c3e 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -27,9 +27,9 @@ _logger = logging.getLogger(__name__) _component_tag = r"(?Pcomponent)" -_component_path = r"(?P(\"[^\"'\s]+\")|('[^\"'\s]+'))" -_component_kwargs = r"(?P(.*?|\s*?)*)" -COMMENT_REGEX = re.compile(r"()") +_component_path = r"(?P\"[^\"'\s]+\"|'[^\"'\s]+')" +_component_kwargs = r"(?P[\s\S]*?)" +COMMENT_REGEX = re.compile(r"") COMPONENT_REGEX = re.compile( r"{%\s*" + _component_tag diff --git a/tests/test_app/tests/test_regex.py b/tests/test_app/tests/test_regex.py index 096107b5..cee32751 100644 --- a/tests/test_app/tests/test_regex.py +++ b/tests/test_app/tests/test_regex.py @@ -5,81 +5,132 @@ class RegexTests(TestCase): def test_component_regex(self): - for component in { - r'{%component "my.component"%}', - r'{%component "my.component"%}', - r"{%component 'my.component'%}", - r'{% component "my.component" %}', - r"{% component 'my.component' %}", - r'{% component "my.component" class="my_thing" %}', + # Real component matches + self.assertRegex(r'{%component "my.component"%}', COMPONENT_REGEX) + self.assertRegex(r'{%component "my.component"%}', COMPONENT_REGEX) + self.assertRegex(r"{%component 'my.component'%}", COMPONENT_REGEX) + self.assertRegex(r'{% component "my.component" %}', COMPONENT_REGEX) + self.assertRegex(r"{% component 'my.component' %}", COMPONENT_REGEX) + self.assertRegex( + r'{% component "my.component" class="my_thing" %}', COMPONENT_REGEX + ) + self.assertRegex( r'{% component "my.component" class="my_thing" attr="attribute" %}', + COMPONENT_REGEX, + ) + self.assertRegex( r"""{% + component + "my.component" + class="my_thing" + attr="attribute" - component - "my.component" - class="my_thing" - attr="attribute" + %}""", # noqa: W291 + COMPONENT_REGEX, + ) - %}""", # noqa: W291 - }: - self.assertRegex(component, COMPONENT_REGEX) + # Fake component matches + self.assertNotRegex(r'{% not_a_real_thing "my.component" %}', COMPONENT_REGEX) + self.assertNotRegex(r"{% component my.component %}", COMPONENT_REGEX) + self.assertNotRegex(r"""{% component 'my.component" %}""", COMPONENT_REGEX) + self.assertNotRegex(r'{ component "my.component" }', COMPONENT_REGEX) + self.assertNotRegex(r'{{ component "my.component" }}', COMPONENT_REGEX) + self.assertNotRegex(r"component", COMPONENT_REGEX) + self.assertNotRegex(r"{%%}", COMPONENT_REGEX) + self.assertNotRegex(r" ", COMPONENT_REGEX) + self.assertNotRegex(r"", COMPONENT_REGEX) + self.assertNotRegex(r'{% component " my.component " %}', COMPONENT_REGEX) + self.assertNotRegex( + r"""{% component "my.component COMPONENT_REGEX) + self.assertNotRegex( " %}""", + COMPONENT_REGEX, + ) + self.assertNotRegex(r'{{ component """ }}', COMPONENT_REGEX) + self.assertNotRegex(r'{{ component "" }}', COMPONENT_REGEX) - for fake_component in { - r'{% not_a_real_thing "my.component" %}', - r"{% component my.component %}", - r"""{% component 'my.component" %}""", - r'{ component "my.component" }', - r'{{ component "my.component" }}', - r"component", - r"{%%}", - r" ", - r"", - r'{% component " my.component " %}', - r"""{% component "my.component - " %}""", - r'{{ component """ }}', - r'{{ component "" }}', - }: - self.assertNotRegex(fake_component, COMPONENT_REGEX) + # Make sure back-to-back components are not merged into one match + double_component_match = COMPONENT_REGEX.search( + r'{% component "my.component" %} {% component "my.component" %}' + ) + self.assertTrue(double_component_match[0] == r'{% component "my.component" %}') # type: ignore def test_comment_regex(self): - for comment in { - r"", + # Real comment matches + self.assertRegex(r"", COMMENT_REGEX) + self.assertRegex( r"""""", + -->""", + COMMENT_REGEX, + ) + self.assertRegex( r"""""", + comment -->""", + COMMENT_REGEX, + ) + self.assertRegex( r"""""", + comment + -->""", + COMMENT_REGEX, + ) + self.assertRegex( r"""""", # noqa: W291 - }: - self.assertRegex(comment, COMMENT_REGEX) + a comment + another comments + drink some cement + -->""", # noqa: W291 + COMMENT_REGEX, + ) - for fake_comment in { - r"", - r"", - r'{% component "my.component" %}', - }: - self.assertNotRegex(fake_comment, COMMENT_REGEX) + # Fake comment matches + self.assertNotRegex(r"", COMMENT_REGEX) + self.assertNotRegex(r"", COMMENT_REGEX) + self.assertNotRegex(r'{% component "my.component" %}', COMMENT_REGEX) - for embedded_comment in { - r'{% component "my.component" %} ', - r' {% component "my.component" %}', - r' {% component "my.component" %} ', - r"""' + ).strip(), + '{% component "my.component" %}', + ) + self.assertEquals( + COMMENT_REGEX.sub( + "", r' {% component "my.component" %}' + ).strip(), + '{% component "my.component" %}', + ) + self.assertEquals( + COMMENT_REGEX.sub( + "", r' {% component "my.component" %} ' + ).strip(), + '{% component "my.component" %}', + ) + self.assertEquals( + COMMENT_REGEX.sub( + "", + r""" {% component "my.component" %} """, - }: # noqa: W291 - text = COMMENT_REGEX.sub("", embedded_comment) - if text.strip() != '{% component "my.component" %}': - raise self.failureException( - f"Regex pattern {COMMENT_REGEX.pattern} failed to remove comment from {embedded_comment}" - ) + ).strip(), + '{% component "my.component" %}', + ) + + # Components surrounded by comments + self.assertEquals( + COMMENT_REGEX.sub("", r''), + "", + ) + self.assertEquals( + COMMENT_REGEX.sub( + "", + r"""""", # noqa: W291 + ), + "", + ) From 9acb074f7b0ce51df9b988086a79cf433791b5f7 Mon Sep 17 00:00:00 2001 From: Mark Bakhit <16909269+Archmonger@users.noreply.github.com> Date: Thu, 29 Jun 2023 12:59:26 -0700 Subject: [PATCH 4/4] v3.2.1 (#156) --- CHANGELOG.md | 9 +++++++-- src/reactpy_django/__init__.py | 2 +- src/reactpy_django/websocket/consumer.py | 5 ++--- tests/test_app/tests/test_regex.py | 4 ++-- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef52d7de..10bb1d3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,9 +34,13 @@ Using the following categories, list your changes in this order: ## [Unreleased] +- Nothing yet! + +## [3.2.1] - 2023-06-29 + ### Added -- Template tag exception details are now rendered on the webpage when `DEBUG` is enabled. +- Template tag exception details are now rendered on the webpage when `settings.py:DEBUG` is enabled. ### Fixed @@ -295,7 +299,8 @@ Using the following categories, list your changes in this order: - Support for IDOM within the Django -[unreleased]: https://github.com/reactive-python/reactpy-django/compare/3.2.0...HEAD +[unreleased]: https://github.com/reactive-python/reactpy-django/compare/3.2.1...HEAD +[3.2.1]: https://github.com/reactive-python/reactpy-django/compare/3.2.0...3.2.1 [3.2.0]: https://github.com/reactive-python/reactpy-django/compare/3.1.0...3.2.0 [3.1.0]: https://github.com/reactive-python/reactpy-django/compare/3.0.1...3.1.0 [3.0.1]: https://github.com/reactive-python/reactpy-django/compare/3.0.0-reactpy...3.0.1 diff --git a/src/reactpy_django/__init__.py b/src/reactpy_django/__init__.py index 0d8bb188..8d026baa 100644 --- a/src/reactpy_django/__init__.py +++ b/src/reactpy_django/__init__.py @@ -2,7 +2,7 @@ from reactpy_django.websocket.paths import REACTPY_WEBSOCKET_PATH -__version__ = "3.2.0" +__version__ = "3.2.1" __all__ = [ "REACTPY_WEBSOCKET_PATH", "hooks", diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index 90ee0632..c9f32bce 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -130,9 +130,8 @@ async def _run_dispatch_loop(self): )() except models.ComponentSession.DoesNotExist: _logger.warning( - f"Browser has attempted to access '{dotted_path}', " - f"but the component has already expired beyond REACTPY_RECONNECT_MAX. " - "If this was expected, this warning can be ignored." + f"Component session for '{dotted_path}:{uuid}' not found. The " + "session may have already expired beyond REACTPY_RECONNECT_MAX." ) return component_params: ComponentParamData = pickle.loads(params_query.params) diff --git a/tests/test_app/tests/test_regex.py b/tests/test_app/tests/test_regex.py index cee32751..61b72e4a 100644 --- a/tests/test_app/tests/test_regex.py +++ b/tests/test_app/tests/test_regex.py @@ -41,8 +41,8 @@ def test_component_regex(self): self.assertNotRegex(r"", COMPONENT_REGEX) self.assertNotRegex(r'{% component " my.component " %}', COMPONENT_REGEX) self.assertNotRegex( - r"""{% component "my.component COMPONENT_REGEX) - self.assertNotRegex( " %}""", + r"""{% component "my.component + " %}""", COMPONENT_REGEX, ) self.assertNotRegex(r'{{ component """ }}', COMPONENT_REGEX)