From dc65b4555941a342108e7c0868bcfbe7b5e264e9 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sun, 18 Feb 2024 10:34:21 -0800 Subject: [PATCH 01/13] Add rough setup for a components serve view --- laces/test/tests/test_views.py | 23 +++++++++++++++++++++++ laces/test/urls.py | 3 ++- laces/urls.py | 9 +++++++++ laces/views.py | 16 ++++++++++++++++ 4 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 laces/urls.py create mode 100644 laces/views.py diff --git a/laces/test/tests/test_views.py b/laces/test/tests/test_views.py index caa1fed..c7b6a3e 100644 --- a/laces/test/tests/test_views.py +++ b/laces/test/tests/test_views.py @@ -97,3 +97,26 @@ def test_get(self) -> None: response_html, count=1, ) + + +class TestServeComponent(TestCase): + """ + Test the serve view from the perspective of an external project. + + The functionality is not defined in this project. It's in Laces, but I want to have + some use cases of how this maybe be used. + + This makes some assumptions about what is set up in the project. There will need to + be other more encapsulated tests in Laces directly. + + """ + + def test_get_component(self) -> None: + # Requesting the `laces.test.example.components.RendersTemplateWithFixedContentComponent` + response = self.client.get("/components/renders-fixed-content-template/") + + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertHTMLEqual( + response.content.decode("utf-8"), + "

Hello World

", + ) diff --git a/laces/test/urls.py b/laces/test/urls.py index 85bfc8f..66bd3c8 100644 --- a/laces/test/urls.py +++ b/laces/test/urls.py @@ -1,8 +1,9 @@ -from django.urls import path +from django.urls import include, path from laces.test.example.views import kitchen_sink urlpatterns = [ path("", kitchen_sink), + path("components/", include("laces.urls")), ] diff --git a/laces/urls.py b/laces/urls.py new file mode 100644 index 0000000..41679d2 --- /dev/null +++ b/laces/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from laces.views import serve + + +app_name = "laces" + + +urlpatterns = [path("/", serve, name="serve")] diff --git a/laces/views.py b/laces/views.py new file mode 100644 index 0000000..571a956 --- /dev/null +++ b/laces/views.py @@ -0,0 +1,16 @@ +import logging + +from typing import TYPE_CHECKING + +from django.http import HttpResponse + + +if TYPE_CHECKING: + from django.http import HttpRequest + +logger = logging.getLogger(__name__) + + +def serve(request: "HttpRequest", component_slug: str) -> HttpResponse: + logger.error(component_slug) + return HttpResponse() From 9d134597d02d8bed0f2ee0b1208386d078265cc6 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sun, 18 Feb 2024 11:15:58 -0800 Subject: [PATCH 02/13] Implement basic registration an lookup of servable components --- laces/components.py | 31 ++++++++++++++++++++++++++++++- laces/test/example/components.py | 3 ++- laces/test/tests/test_views.py | 5 +++++ laces/views.py | 14 ++++++++++++-- 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/laces/components.py b/laces/components.py index 918a19b..439a476 100644 --- a/laces/components.py +++ b/laces/components.py @@ -8,7 +8,7 @@ if TYPE_CHECKING: - from typing import Optional + from typing import Callable, Optional from django.utils.safestring import SafeString @@ -104,3 +104,32 @@ def media(self) -> Media: for item in self: media += item.media return media + + +_servables = {} + + +def servable(name: str) -> "Callable[[type[Component]], type[Component]]": + def decorator(component_class: type[Component]) -> type[Component]: + _servables[name] = component_class + return component_class + + return decorator + + +class ServableComponentNotFound(Exception): + def __init__(self, slug: str) -> None: + self.name = slug + super().__init__(self.get_message()) + + def get_message(self) -> str: + return f"No servable component '{self.name}' found." + + +def get_servable(slug: str) -> type[Component]: + try: + component_class = _servables[slug] + except KeyError: + raise ServableComponentNotFound(slug=slug) + else: + return component_class diff --git a/laces/test/example/components.py b/laces/test/example/components.py index 3702159..f650567 100644 --- a/laces/test/example/components.py +++ b/laces/test/example/components.py @@ -10,7 +10,7 @@ from django.utils.html import format_html -from laces.components import Component +from laces.components import Component, servable if TYPE_CHECKING: @@ -21,6 +21,7 @@ from laces.typing import RenderContext +@servable("renders-fixed-content-template") class RendersTemplateWithFixedContentComponent(Component): template_name = "components/hello-world.html" diff --git a/laces/test/tests/test_views.py b/laces/test/tests/test_views.py index c7b6a3e..efce170 100644 --- a/laces/test/tests/test_views.py +++ b/laces/test/tests/test_views.py @@ -120,3 +120,8 @@ def test_get_component(self) -> None: response.content.decode("utf-8"), "

Hello World

", ) + + def test_get_not_registered_component(self) -> None: + response = self.client.get("/components/not-a-component/") + + self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND) diff --git a/laces/views.py b/laces/views.py index 571a956..a370356 100644 --- a/laces/views.py +++ b/laces/views.py @@ -2,7 +2,9 @@ from typing import TYPE_CHECKING -from django.http import HttpResponse +from django.http import Http404, HttpResponse + +from laces.components import ServableComponentNotFound, get_servable if TYPE_CHECKING: @@ -13,4 +15,12 @@ def serve(request: "HttpRequest", component_slug: str) -> HttpResponse: logger.error(component_slug) - return HttpResponse() + + try: + Component = get_servable(component_slug) + except ServableComponentNotFound: + raise Http404 + + component = Component() + + return HttpResponse(content=component.render_html()) From 1b754eb143191ce187bbdb91bf31bd718ad22cc7 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Sun, 18 Feb 2024 11:19:17 -0800 Subject: [PATCH 03/13] Rename registration function --- laces/components.py | 2 +- laces/test/example/components.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/laces/components.py b/laces/components.py index 439a476..b8d3406 100644 --- a/laces/components.py +++ b/laces/components.py @@ -109,7 +109,7 @@ def media(self) -> Media: _servables = {} -def servable(name: str) -> "Callable[[type[Component]], type[Component]]": +def register_servable(name: str) -> "Callable[[type[Component]], type[Component]]": def decorator(component_class: type[Component]) -> type[Component]: _servables[name] = component_class return component_class diff --git a/laces/test/example/components.py b/laces/test/example/components.py index f650567..5731a4c 100644 --- a/laces/test/example/components.py +++ b/laces/test/example/components.py @@ -10,7 +10,7 @@ from django.utils.html import format_html -from laces.components import Component, servable +from laces.components import Component, register_servable if TYPE_CHECKING: @@ -21,7 +21,7 @@ from laces.typing import RenderContext -@servable("renders-fixed-content-template") +@register_servable("renders-fixed-content-template") class RendersTemplateWithFixedContentComponent(Component): template_name = "components/hello-world.html" From 296c436e0d10905a3271fbe38c3d745b12ab1e65 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Mon, 19 Feb 2024 17:34:29 -0800 Subject: [PATCH 04/13] Test component with initializer arguments --- laces/test/example/components.py | 24 +++++++++++++++++++++++- laces/test/tests/test_views.py | 22 ++++++++++++++++++++-- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/laces/test/example/components.py b/laces/test/example/components.py index 5731a4c..d7b26bf 100644 --- a/laces/test/example/components.py +++ b/laces/test/example/components.py @@ -21,7 +21,6 @@ from laces.typing import RenderContext -@register_servable("renders-fixed-content-template") class RendersTemplateWithFixedContentComponent(Component): template_name = "components/hello-world.html" @@ -200,3 +199,26 @@ def render_html( class Media: css = {"all": ("footer.css",)} js = ("footer.js", "common.js") + + +# Servables + + +@register_servable("fixed-content-template") +class ServableWithFixedContentTemplateComponent(Component): + template_name = "components/hello-world.html" + + +@register_servable("with-init-args") +class ServableWithInitilizerArgumentsComponent(Component): + template_name = "components/hello-name.html" + + def __init__(self, name: str) -> None: + super().__init__() + self.name = name + + def get_context_data( + self, + parent_context: "Optional[RenderContext]" = None, + ) -> "RenderContext": + return {"name": self.name} diff --git a/laces/test/tests/test_views.py b/laces/test/tests/test_views.py index efce170..d3d5374 100644 --- a/laces/test/tests/test_views.py +++ b/laces/test/tests/test_views.py @@ -112,8 +112,8 @@ class TestServeComponent(TestCase): """ def test_get_component(self) -> None: - # Requesting the `laces.test.example.components.RendersTemplateWithFixedContentComponent` - response = self.client.get("/components/renders-fixed-content-template/") + # Requesting the `laces.test.example.components.ServableWithWithFixedContentTemplateComponent` + response = self.client.get("/components/fixed-content-template/") self.assertEqual(response.status_code, HTTPStatus.OK) self.assertHTMLEqual( @@ -125,3 +125,21 @@ def test_get_not_registered_component(self) -> None: response = self.client.get("/components/not-a-component/") self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND) + + def test_get_component_with_init_args_and_name_alice(self) -> None: + response = self.client.get("/components/with-init-args/?name=Alice") + + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertHTMLEqual( + response.content.decode("utf-8"), + "

Hello Alice

", + ) + + def test_get_component_with_init_args_and_name_bob(self) -> None: + response = self.client.get("/components/with-init-args/?name=Bob") + + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertHTMLEqual( + response.content.decode("utf-8"), + "

Hello Bob

", + ) From 9c9df1422f22475d3d785531ad0ec3da7114a596 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Mon, 19 Feb 2024 17:51:24 -0800 Subject: [PATCH 05/13] Add request GET parameters as kwargs to constructor --- laces/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/laces/views.py b/laces/views.py index a370356..5e178eb 100644 --- a/laces/views.py +++ b/laces/views.py @@ -21,6 +21,7 @@ def serve(request: "HttpRequest", component_slug: str) -> HttpResponse: except ServableComponentNotFound: raise Http404 - component = Component() + kwargs = request.GET.dict() + component = Component(**kwargs) return HttpResponse(content=component.render_html()) From a257fdf85246b968788a252e64f56fadd134b0cf Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Mon, 19 Feb 2024 18:36:00 -0800 Subject: [PATCH 06/13] BadRequest response for too many/few arguments to default constructor --- laces/test/tests/test_views.py | 16 ++++++++++++++++ laces/views.py | 7 ++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/laces/test/tests/test_views.py b/laces/test/tests/test_views.py index d3d5374..b2212d1 100644 --- a/laces/test/tests/test_views.py +++ b/laces/test/tests/test_views.py @@ -143,3 +143,19 @@ def test_get_component_with_init_args_and_name_bob(self) -> None: response.content.decode("utf-8"), "

Hello Bob

", ) + + def test_get_component_with_init_args_and_name_and_extra_parameter(self) -> None: + response = self.client.get( + "/components/with-init-args/?name=Bob&extra=notexpected" + ) + + # You could argue that we should ignore the extra parameters. But, this seems + # like it would create inconsistent behavior between having too many and too few + # arguments. It's probably cleaner to just response the same and require the + # request to be fixed. + self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) + + def test_get_component_with_init_args_and_no_parameters(self) -> None: + response = self.client.get("/components/with-init-args/") + + self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) diff --git a/laces/views.py b/laces/views.py index 5e178eb..dabf304 100644 --- a/laces/views.py +++ b/laces/views.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING +from django.core.exceptions import BadRequest from django.http import Http404, HttpResponse from laces.components import ServableComponentNotFound, get_servable @@ -22,6 +23,10 @@ def serve(request: "HttpRequest", component_slug: str) -> HttpResponse: raise Http404 kwargs = request.GET.dict() - component = Component(**kwargs) + + try: + component = Component(**kwargs) + except TypeError as e: + raise BadRequest(e) return HttpResponse(content=component.render_html()) From 26b507f2560e3ff4c34e0a037714410ee1d7b40f Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Mon, 19 Feb 2024 18:43:09 -0800 Subject: [PATCH 07/13] Wrong type argument --- laces/test/example/components.py | 6 ++++++ laces/test/tests/test_views.py | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/laces/test/example/components.py b/laces/test/example/components.py index d7b26bf..220d6ba 100644 --- a/laces/test/example/components.py +++ b/laces/test/example/components.py @@ -222,3 +222,9 @@ def get_context_data( parent_context: "Optional[RenderContext]" = None, ) -> "RenderContext": return {"name": self.name} + + +@register_servable("int-adder") +class ServableIntAdderComponent(Component): + def __init__(self, number: int) -> None: + self.number = 0 + number diff --git a/laces/test/tests/test_views.py b/laces/test/tests/test_views.py index b2212d1..9c24429 100644 --- a/laces/test/tests/test_views.py +++ b/laces/test/tests/test_views.py @@ -159,3 +159,8 @@ def test_get_component_with_init_args_and_no_parameters(self) -> None: response = self.client.get("/components/with-init-args/") self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) + + def test_get_component_with_non_string_argument(self) -> None: + response = self.client.get("/components/int-adder/?number=2") + + self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) From b63b9e5b22bd8c0f614c7ad3bd525138ef0e1ee6 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Mon, 19 Feb 2024 18:45:31 -0800 Subject: [PATCH 08/13] Add test for comopnent with custom exception in init --- laces/test/example/components.py | 10 ++++++++++ laces/test/tests/test_views.py | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/laces/test/example/components.py b/laces/test/example/components.py index 220d6ba..9001a9b 100644 --- a/laces/test/example/components.py +++ b/laces/test/example/components.py @@ -228,3 +228,13 @@ def get_context_data( class ServableIntAdderComponent(Component): def __init__(self, number: int) -> None: self.number = 0 + number + + +class CustomException(Exception): + pass + + +@register_servable("with-custom-exception-init") +class ServableWithCustomExceptionInitializerComponent(Component): + def __init__(self) -> None: + raise CustomException diff --git a/laces/test/tests/test_views.py b/laces/test/tests/test_views.py index 9c24429..a124090 100644 --- a/laces/test/tests/test_views.py +++ b/laces/test/tests/test_views.py @@ -164,3 +164,8 @@ def test_get_component_with_non_string_argument(self) -> None: response = self.client.get("/components/int-adder/?number=2") self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) + + def test_get_component_with_custom_exception(self) -> None: + response = self.client.get("/components/with-custom-exception-init/") + + self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) From df97a56d9a240378666a451790734f49d0179b80 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Mon, 26 Feb 2024 19:33:20 -0800 Subject: [PATCH 09/13] Make any exception during init return bad request --- laces/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/laces/views.py b/laces/views.py index dabf304..8b137c3 100644 --- a/laces/views.py +++ b/laces/views.py @@ -26,7 +26,7 @@ def serve(request: "HttpRequest", component_slug: str) -> HttpResponse: try: component = Component(**kwargs) - except TypeError as e: + except Exception as e: raise BadRequest(e) return HttpResponse(content=component.render_html()) From 578add7ddeb9e6aaf9520ec0df26606cf92dd798 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Mon, 26 Feb 2024 19:53:36 -0800 Subject: [PATCH 10/13] Create first test for the from request constructor --- laces/components.py | 10 +++++++++- laces/tests/test_components.py | 16 +++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/laces/components.py b/laces/components.py index b8d3406..92a155f 100644 --- a/laces/components.py +++ b/laces/components.py @@ -8,12 +8,15 @@ if TYPE_CHECKING: - from typing import Callable, Optional + from typing import Callable, Optional, Type, TypeVar + from django.http import HttpRequest from django.utils.safestring import SafeString from laces.typing import RenderContext + T = TypeVar("T", bound="Component") + class Component(metaclass=MediaDefiningClass): """ @@ -30,6 +33,11 @@ class Component(metaclass=MediaDefiningClass): template_name: str + @classmethod + def from_request(cls: "Type[T]", request: "HttpRequest") -> "T": + """Create an instance of this component based on the given request.""" + return cls() + def render_html( self, parent_context: "Optional[RenderContext]" = None, diff --git a/laces/tests/test_components.py b/laces/tests/test_components.py index b5bfeaf..ab03f52 100644 --- a/laces/tests/test_components.py +++ b/laces/tests/test_components.py @@ -7,7 +7,7 @@ from django.conf import settings from django.forms import widgets from django.template import Context -from django.test import SimpleTestCase +from django.test import RequestFactory, SimpleTestCase from django.utils.safestring import SafeString from laces.components import Component, MediaContainer @@ -88,10 +88,24 @@ def setUp(self) -> None: # Write content to the template file to ensure it exists. self.set_example_template_content("") + self.request_factory = RequestFactory() + def set_example_template_content(self, content: str) -> None: with open(self.example_template, "w") as f: f.write(content) + def test_from_request_with_component_wo_init_args(self) -> None: + # ----------------------------------------------------------------------------- + class ExampleComponent(Component): + pass + + # ----------------------------------------------------------------------------- + request = self.request_factory.get("") + + result = ExampleComponent.from_request(request) + + self.assertIsInstance(result, ExampleComponent) + def test_render_html_with_template_name_set(self) -> None: """ Test `render_html` method with a set `template_name` attribute. From 86ff4516fefcbff73fc9c507702f7a0f5d95c86e Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Mon, 26 Feb 2024 19:56:38 -0800 Subject: [PATCH 11/13] Move the argument extraction from view to constructor --- laces/components.py | 5 +++-- laces/views.py | 4 +--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/laces/components.py b/laces/components.py index 92a155f..8caa108 100644 --- a/laces/components.py +++ b/laces/components.py @@ -34,9 +34,10 @@ class Component(metaclass=MediaDefiningClass): template_name: str @classmethod - def from_request(cls: "Type[T]", request: "HttpRequest") -> "T": + def from_request(cls: "Type[T]", request: "HttpRequest", /) -> "T": """Create an instance of this component based on the given request.""" - return cls() + kwargs = request.GET.dict() + return cls(**kwargs) def render_html( self, diff --git a/laces/views.py b/laces/views.py index 8b137c3..4732cb3 100644 --- a/laces/views.py +++ b/laces/views.py @@ -22,10 +22,8 @@ def serve(request: "HttpRequest", component_slug: str) -> HttpResponse: except ServableComponentNotFound: raise Http404 - kwargs = request.GET.dict() - try: - component = Component(**kwargs) + component = Component.from_request(request) except Exception as e: raise BadRequest(e) From 3ff7bace862bc459487202d9b3f4bc7207123d00 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Mon, 26 Feb 2024 20:18:05 -0800 Subject: [PATCH 12/13] Add tests for other class and request data combinations --- laces/tests/test_components.py | 58 ++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/laces/tests/test_components.py b/laces/tests/test_components.py index ab03f52..1ea703f 100644 --- a/laces/tests/test_components.py +++ b/laces/tests/test_components.py @@ -106,6 +106,64 @@ class ExampleComponent(Component): self.assertIsInstance(result, ExampleComponent) + def test_from_request_with_component_w_name_arg_request_wo_name_para(self) -> None: + # ----------------------------------------------------------------------------- + class ExampleComponent(Component): + def __init__(self, name: str) -> None: + self.name = name + + # ----------------------------------------------------------------------------- + request = self.request_factory.get("") + + with self.assertRaises(TypeError) as ctx: + ExampleComponent.from_request(request) + + self.assertIn("required positional argument", str(ctx.exception)) + + def test_from_request_with_component_w_name_arg_request_w_name_para(self) -> None: + # ----------------------------------------------------------------------------- + class ExampleComponent(Component): + def __init__(self, name: str) -> None: + self.name = name + + # ----------------------------------------------------------------------------- + request = self.request_factory.get("", data={"name": "Alice"}) + + result = ExampleComponent.from_request(request) + + self.assertEqual(result.name, "Alice") + + def test_from_request_with_component_w_name_arg_request_w_extra_para(self) -> None: + # ----------------------------------------------------------------------------- + class ExampleComponent(Component): + def __init__(self, name: str) -> None: + self.name = name + + # ----------------------------------------------------------------------------- + request = self.request_factory.get("", data={"name": "Alice", "other": "Bob"}) + + with self.assertRaises(TypeError) as ctx: + ExampleComponent.from_request(request) + + self.assertIn("unexpected keyword argument", str(ctx.exception)) + + def test_from_request_with_component_init_raises_custom_exception(self) -> None: + # ----------------------------------------------------------------------------- + class CustomException(Exception): + pass + + class ExampleComponent(Component): + def __init__(self) -> None: + raise CustomException + + # ----------------------------------------------------------------------------- + request = self.request_factory.get("") + + # No special handling happens in the `from_request` method by default. + # The raised exception should be exposed. + with self.assertRaises(CustomException): + ExampleComponent.from_request(request) + def test_render_html_with_template_name_set(self) -> None: """ Test `render_html` method with a set `template_name` attribute. From 6b436420679ef870feb4ce2d9df946f0fccad747 Mon Sep 17 00:00:00 2001 From: Tibor Leupold Date: Mon, 26 Feb 2024 20:24:48 -0800 Subject: [PATCH 13/13] Add method docstring --- laces/components.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/laces/components.py b/laces/components.py index 8caa108..916e2d2 100644 --- a/laces/components.py +++ b/laces/components.py @@ -35,7 +35,17 @@ class Component(metaclass=MediaDefiningClass): @classmethod def from_request(cls: "Type[T]", request: "HttpRequest", /) -> "T": - """Create an instance of this component based on the given request.""" + """ + Create an instance of this component based on the given request. + + This method is mostly an extension point to add custom logic. If a component has + specific access controls, this would be a good spot to check them. + + By default, the request's querystring parameters are passed as keyword arguments + to the default initializer. No type conversion is applied. This means that the + initializer receives all arguments as strings. To change that behavior, override + this method. + """ kwargs = request.GET.dict() return cls(**kwargs)