refactor: make Component.View.public optional (#1451)
Some checks are pending
Docs - build & deploy / docs (push) Waiting to run
Run tests / build (windows-latest, 3.10) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.10) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.11) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.12) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.13) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.8) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.9) (push) Waiting to run
Run tests / build (windows-latest, 3.11) (push) Waiting to run
Run tests / build (windows-latest, 3.12) (push) Waiting to run
Run tests / build (windows-latest, 3.13) (push) Waiting to run
Run tests / build (windows-latest, 3.8) (push) Waiting to run
Run tests / build (windows-latest, 3.9) (push) Waiting to run
Run tests / test_docs (3.13) (push) Waiting to run
Run tests / test_sampleproject (3.13) (push) Waiting to run

Co-authored-by: RohanDisa <105740583+RohanDisa@users.noreply.github.com>
This commit is contained in:
Juro Oravec 2025-10-12 20:33:14 +02:00 committed by GitHub
parent b17214f536
commit 485027b308
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 202 additions and 71 deletions

View file

@ -1,5 +1,45 @@
# Release notes # Release notes
## v0.142.3
#### Refactor
- `Component.View.public = True` is now optional.
Before, to create component endpoints, you had to set both:
1. HTTP handlers on `Component.View`
2. `Component.View.public = True`.
Now, you can set only the HTTP handlers, and the component will be automatically exposed
when any of the HTTP handlers are defined.
You can still explicitly expose/hide the component with `Component.View.public = True/False`.
Before:
```py
class MyTable(Component):
class View:
public = True
def get(self, request):
return self.render_to_response()
url = get_component_url(MyTable)
```
After:
```py
class MyTable(Component):
class View:
def get(self, request):
return self.render_to_response()
url = get_component_url(MyTable)
```
## v0.142.2 ## v0.142.2
_06 Oct 2025_ _06 Oct 2025_

View file

@ -347,13 +347,10 @@ class Calendar(Component):
template_file = "calendar.html" template_file = "calendar.html"
class View: class View:
# Register Component with `urlpatterns`
public = True
# Define handlers # Define handlers
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
page = request.GET.get("page", 1) page = request.GET.get("page", 1)
return self.component.render_to_response( return Calendar.render_to_response(
request=request, request=request,
kwargs={ kwargs={
"page": page, "page": page,

View file

@ -13,7 +13,9 @@ django-components has a suite of features that help you write and manage views a
- Use [`Component.as_view()`](../../../reference/api#django_components.Component.as_view) to be able to use your Components with Django's [`urlpatterns`](https://docs.djangoproject.com/en/5.2/topics/http/urls/). This works the same way as [`View.as_view()`](https://docs.djangoproject.com/en/5.2/ref/class-based-views/base/#django.views.generic.base.View.as_view). - Use [`Component.as_view()`](../../../reference/api#django_components.Component.as_view) to be able to use your Components with Django's [`urlpatterns`](https://docs.djangoproject.com/en/5.2/topics/http/urls/). This works the same way as [`View.as_view()`](https://docs.djangoproject.com/en/5.2/ref/class-based-views/base/#django.views.generic.base.View.as_view).
- To avoid having to manually define the endpoints for each component, you can set the component to be "public" with [`Component.View.public = True`](../../../reference/api#django_components.ComponentView.public). This will automatically create a URL for the component. To retrieve the component URL, use [`get_component_url()`](../../../reference/api#django_components.get_component_url). - Or use [`get_component_url()`](../../../reference/api#django_components.get_component_url) to retrieve the component URL - an anonymous HTTP endpoint that triggers the component's handlers without having to register the component in `urlpatterns`.
To expose a component, simply define a handler, or explicitly expose/hide the component with [`Component.View.public = True/False`](../../../reference/api#django_components.ComponentView.public).
- In addition, [`Component`](../../../reference/api#django_components.Component) has a [`render_to_response()`](../../../reference/api#django_components.Component.render_to_response) method that renders the component template based on the provided input and returns an `HttpResponse` object. - In addition, [`Component`](../../../reference/api#django_components.Component) has a [`render_to_response()`](../../../reference/api#django_components.Component.render_to_response) method that renders the component template based on the provided input and returns an `HttpResponse` object.
@ -114,19 +116,13 @@ class as one of the arguments.
## Register URLs automatically ## Register URLs automatically
If you don't care about the exact URL of the component, you can let django-components manage the URLs for you by setting the [`Component.View.public`](../../../reference/api#django_components.ComponentView.public) attribute to `True`: If you don't care about the exact URL of the component, you can let django-components manage the URLs.
```py Each component has an "anonymous" URL that triggers the component's HTTP handlers without having to define the component in `urlpatterns`.
class MyComponent(Component):
class View:
public = True
def get(self, request): This way you don't have to mix your app URLs with component URLs.
return self.component_cls.render_to_response(request=request)
...
```
Then, to get the URL for the component, use [`get_component_url()`](../../../reference/api#django_components.get_component_url): To obtain such "anonymous" URL, use [`get_component_url()`](../../../reference/api#django_components.get_component_url):
```py ```py
from django_components import get_component_url from django_components import get_component_url
@ -134,7 +130,17 @@ from django_components import get_component_url
url = get_component_url(MyComponent) url = get_component_url(MyComponent)
``` ```
This way you don't have to mix your app URLs with component URLs. The component is automatically registered in `urlpatterns` when you define a handler. You can also explicitly expose/hide the component with [`Component.View.public`](../../../reference/api#django_components.ComponentView.public):
```py
class MyComponent(Component):
class View:
public = False
def get(self, request):
return self.component_cls.render_to_response(request=request)
...
```
!!! info !!! info

View file

@ -55,8 +55,6 @@ class ContactFormComponent(Component):
""" # noqa: E501 """ # noqa: E501
class View: class View:
public = True
# Submit handler # Submit handler
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
# Access the submitted data # Access the submitted data

View file

@ -121,8 +121,6 @@ class FragmentsPage(Component):
""" """
class View: class View:
public = True
# The same GET endpoint handles rendering either the whole page or a fragment. # The same GET endpoint handles rendering either the whole page or a fragment.
# We use the `type` query parameter to determine which one to render. # We use the `type` query parameter to determine which one to render.
def get(self, request: HttpRequest) -> HttpResponse: def get(self, request: HttpRequest) -> HttpResponse:

View file

@ -261,18 +261,22 @@ Next, you need to set the URL for the component.
You can either: You can either:
1. Automatically assign the URL by setting the [`Component.View.public`](../../reference/api#django_components.ComponentView.public) attribute to `True`. 1. Use [`get_component_url()`](../../reference/api#django_components.get_component_url) to retrieve the component URL - an anonymous HTTP endpoint that triggers the component's handlers without having to register the component in `urlpatterns`.
In this case, use [`get_component_url()`](../../reference/api#django_components.get_component_url) to get the URL for the component view. ```py
from django_components import get_component_url
url = get_component_url(Calendar)
```
The component endpoint is automatically registered in `urlpatterns` when you define a handler. To explicitly expose/hide the component, use [`Component.View.public`](../../../reference/api#django_components.ComponentView.public).
```djc_py ```djc_py
from django_components import Component, get_component_url from django_components import Component
class Calendar(Component): class Calendar(Component):
class View: class View:
public = True public = False
url = get_component_url(Calendar)
``` ```
2. Manually assign the URL by setting [`Component.as_view()`](../../reference/api#django_components.Component.as_view) to your `urlpatterns`: 2. Manually assign the URL by setting [`Component.as_view()`](../../reference/api#django_components.Component.as_view) to your `urlpatterns`:

View file

@ -335,13 +335,10 @@ class Calendar(Component):
template_file = "calendar.html" template_file = "calendar.html"
class View: class View:
# Register Component with `urlpatterns`
public = True
# Define handlers # Define handlers
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
page = request.GET.get("page", 1) page = request.GET.get("page", 1)
return self.component.render_to_response( return Calendar.render_to_response(
request=request, request=request,
kwargs={ kwargs={
"page": page, "page": page,

View file

@ -44,6 +44,11 @@ def get_component_url(
Raises `RuntimeError` if the component is not public. Raises `RuntimeError` if the component is not public.
Component is public when:
- You set any of the HTTP methods in the [`Component.View`](../api#django_components.ComponentView) class,
- Or you explicitly set [`Component.View.public = True`](../api#django_components.ComponentView.public).
Read more about [Component views and URLs](../../concepts/fundamentals/component_views_urls). Read more about [Component views and URLs](../../concepts/fundamentals/component_views_urls).
`get_component_url()` optionally accepts `query` and `fragment` arguments. `get_component_url()` optionally accepts `query` and `fragment` arguments.
@ -59,9 +64,10 @@ def get_component_url(
```py ```py
from django_components import Component, get_component_url from django_components import Component, get_component_url
class MyComponent(Component): class MyTable(Component):
class View: class View:
public = True def get(self, request: HttpRequest, **kwargs: Any):
return MyTable.render_to_response()
# Get the URL for the component # Get the URL for the component
url = get_component_url( url = get_component_url(
@ -89,15 +95,18 @@ class ComponentView(ExtensionComponentConfig, View):
This class is a subclass of This class is a subclass of
[`django.views.View`](https://docs.djangoproject.com/en/5.2/ref/class-based-views/base/#view). [`django.views.View`](https://docs.djangoproject.com/en/5.2/ref/class-based-views/base/#view).
The [`Component`](../api#django_components.Component) class is available
via `self.component_cls`.
Override the methods of this class to define the behavior of the component. Override the methods of this class to define the behavior of the component.
Read more about [Component views and URLs](../../concepts/fundamentals/component_views_urls). Read more about [Component views and URLs](../../concepts/fundamentals/component_views_urls).
The [`Component`](../api#django_components.Component) class is available
via `self.component_cls`.
**Example:** **Example:**
Define a handler that runs for GET HTTP requests:
```python ```python
class MyComponent(Component): class MyComponent(Component):
class View: class View:
@ -107,27 +116,26 @@ class ComponentView(ExtensionComponentConfig, View):
**Component URL:** **Component URL:**
If the `public` attribute is set to `True`, the component will have its own URL Use [`get_component_url()`](../api#django_components.get_component_url) to retrieve
that will point to the Component's View. the component URL - an anonymous HTTP endpoint that triggers the component's handlers without having to register
the component in `urlpatterns`.
A component is automatically exposed when you define at least one HTTP handler. To explicitly
expose/hide the component, use
[`Component.View.public = True`](../api#django_components.ComponentView.public).
```py ```py
from django_components import Component from django_components import Component, get_component_url
class MyComponent(Component): class MyComponent(Component):
class View: class View:
public = True
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
return HttpResponse("Hello, world!") return HttpResponse("Hello, world!")
```
Will create a URL route like `/components/ext/view/components/a1b2c3/`.
To get the URL for the component, use [`get_component_url()`](../api#django_components.get_component_url):
```py
url = get_component_url(MyComponent) url = get_component_url(MyComponent)
``` ```
This will create a URL route like `/components/ext/view/components/a1b2c3/`.
""" """
# NOTE: The `component` / `component_cls` attributes are NOT user input, but still must be declared # NOTE: The `component` / `component_cls` attributes are NOT user input, but still must be declared
@ -176,15 +184,17 @@ class ComponentView(ExtensionComponentConfig, View):
The URL for the component. The URL for the component.
Raises `RuntimeError` if the component is not public. Raises `RuntimeError` if the component is not public.
See [`Component.View.public`](../api#django_components.ComponentView.public).
This is the same as calling [`get_component_url()`](../api#django_components.get_component_url) This is the same as calling [`get_component_url()`](../api#django_components.get_component_url)
with the parent [`Component`](../api#django_components.Component) class: with the current [`Component`](../api#django_components.Component) class:
```py ```py
class MyComponent(Component): class MyComponent(Component):
class View: class View:
def get(self, request): def get(self, request):
assert self.url == get_component_url(self.component_cls) component_url = get_component_url(self.component_cls)
assert self.url == component_url
``` ```
""" """
return get_component_url(self.component_cls) return get_component_url(self.component_cls)
@ -193,26 +203,42 @@ class ComponentView(ExtensionComponentConfig, View):
# PUBLIC API (Configurable by users) # PUBLIC API (Configurable by users)
# ##################################### # #####################################
public: ClassVar[bool] = False public: ClassVar[Optional[bool]] = None
""" """
Whether the component should be available via a URL. Whether the component HTTP handlers should be available via a URL.
By default (`None`), the component HTTP handlers are available via a URL
if any of the HTTP methods are defined.
You can explicitly set `public` to `True` or `False` to override this behaviour.
**Example:** **Example:**
Define the component HTTP handlers and get its URL using
[`get_component_url()`](../api#django_components.get_component_url):
```py ```py
from django_components import Component from django_components import Component, get_component_url
class MyComponent(Component): class MyComponent(Component):
class View: class View:
public = True def get(self, request):
return self.component_cls.render_to_response(request=request)
url = get_component_url(MyComponent)
``` ```
Will create a URL route like `/components/ext/view/components/a1b2c3/`. This will create a URL route like `/components/ext/view/components/a1b2c3/`.
To get the URL for the component, use [`get_component_url()`](../api#django_components.get_component_url): To explicitly hide the component, set `public = False`:
```py ```py
url = get_component_url(MyComponent) class MyComponent(Component):
class View:
public = False
def get(self, request):
return self.component_cls.render_to_response(request=request)
``` ```
""" """
@ -222,10 +248,13 @@ class ComponentView(ExtensionComponentConfig, View):
# Each method actually delegates to the component's method of the same name. # Each method actually delegates to the component's method of the same name.
# E.g. When `get()` is called, it delegates to `component.get()`. # E.g. When `get()` is called, it delegates to `component.get()`.
# TODO_V1 - In v1 handlers like `get()` should be defined on the Component.View class, # TODO_V1 - For backwards compatibility, the HTTP methods can be defined directly on
# not the Component class directly. This is to align Views with the extensions API # the Component class, e.g. `Component.post()`.
# where each extension should keep its methods in the extension class. # This should be no longer supported in v1.
# Instead, the defaults for these methods should be something like # In v1, handlers like `get()` should be defined on the Component.View class.
# This is to align Views with the extensions API, where each extension should
# keep its methods in the extension class.
# And instead, the defaults for these methods should be something like
# `return self.component_cls.render_to_response(request, *args, **kwargs)` or similar # `return self.component_cls.render_to_response(request, *args, **kwargs)` or similar
# or raise NotImplementedError. # or raise NotImplementedError.
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
@ -306,4 +335,25 @@ class ViewExtension(ComponentExtension):
def _is_view_public(view_cls: Optional[Type[ComponentView]]) -> bool: def _is_view_public(view_cls: Optional[Type[ComponentView]]) -> bool:
if view_cls is None: if view_cls is None:
return False return False
return getattr(view_cls, "public", False)
# Allow users to skip setting `View.public = True` if any of the HTTP methods
# are defined. Users can still opt-out by explicitly setting `View.public` to `True` or `False`.
public = getattr(view_cls, "public", None)
if public is not None:
return public
# Auto-decide whether the view is public by checking if any of the HTTP methods
# are overridden in the user's View class.
# We do this only once, so if user dynamically adds or removes the methods,
# we will not pick up on that.
http_methods = ["get", "post", "put", "patch", "delete", "head", "options", "trace"]
for method in http_methods:
if not hasattr(view_cls, method):
continue
did_change_method = getattr(view_cls, method) != getattr(ComponentView, method)
if did_change_method:
view_cls.public = True
return True
view_cls.public = False
return False

View file

@ -19,7 +19,6 @@ from pytest_django.asserts import assertHTMLEqual, assertInHTML
from django_components import ( from django_components import (
Component, Component,
ComponentRegistry, ComponentRegistry,
ComponentView,
Slot, Slot,
SlotInput, SlotInput,
all_components, all_components,
@ -1240,12 +1239,12 @@ class TestComponentRender:
def get_template_data(self, args, kwargs, slots, context): def get_template_data(self, args, kwargs, slots, context):
return {"how": kwargs.pop("how")} return {"how": kwargs.pop("how")}
class View(ComponentView): class View:
def get(self, request): def get(self, request):
how = "via GET request" how = "via GET request"
return self.component.render_to_response( return self.component_cls.render_to_response( # type: ignore[attr-defined]
context=RequestContext(self.request), context=RequestContext(request),
kwargs={"how": how}, kwargs={"how": how},
) )

View file

@ -8,7 +8,7 @@ from django.test import Client
from django.urls import path from django.urls import path
from pytest_django.asserts import assertInHTML from pytest_django.asserts import assertInHTML
from django_components import Component, ComponentView, get_component_url, register, types from django_components import Component, get_component_url, register, types
from django_components.testing import djc_test from django_components.testing import djc_test
from django_components.urls import urlpatterns as dc_urlpatterns from django_components.urls import urlpatterns as dc_urlpatterns
from django_components.util.misc import format_url from django_components.util.misc import format_url
@ -96,9 +96,9 @@ class TestComponentAsView:
def get_template_data(self, args, kwargs, slots, context): def get_template_data(self, args, kwargs, slots, context):
return {"inner_var": kwargs["variable"]} return {"inner_var": kwargs["variable"]}
class View(ComponentView): class View:
def get(self, request, *args, **kwargs) -> HttpResponse: def get(self, request, *args, **kwargs) -> HttpResponse:
return self.component.render_to_response(kwargs={"variable": "GET"}) return self.component_cls.render_to_response(kwargs={"variable": "GET"}) # type: ignore[attr-defined]
client = CustomClient(urlpatterns=[path("test/", MockComponentRequest.as_view())]) client = CustomClient(urlpatterns=[path("test/", MockComponentRequest.as_view())])
response = client.get("/test/") response = client.get("/test/")
@ -145,10 +145,10 @@ class TestComponentAsView:
def get_template_data(self, args, kwargs, slots, context): def get_template_data(self, args, kwargs, slots, context):
return {"inner_var": kwargs["variable"]} return {"inner_var": kwargs["variable"]}
class View(ComponentView): class View:
def post(self, request, *args, **kwargs) -> HttpResponse: def post(self, request, *args, **kwargs) -> HttpResponse:
variable = request.POST.get("variable") variable = request.POST.get("variable")
return self.component.render_to_response(kwargs={"variable": variable}) return self.component_cls.render_to_response(kwargs={"variable": variable}) # type: ignore[attr-defined]
client = CustomClient(urlpatterns=[path("test/", MockComponentRequest.as_view())]) client = CustomClient(urlpatterns=[path("test/", MockComponentRequest.as_view())])
response = client.post("/test/", {"variable": "POST"}) response = client.post("/test/", {"variable": "POST"})
@ -366,7 +366,20 @@ class TestComponentAsView:
assert response.content == b"Hello" assert response.content == b"Hello"
assert did_call_get assert did_call_get
def test_non_public_url(self): def test_public_url_implicit(self):
class TestComponent(Component):
template = "Hello"
class View:
def get(self, request: HttpRequest, **kwargs: Any):
component: Component = self.component # type: ignore[attr-defined]
return component.render_to_response()
# Check if the URL is correctly generated
component_url = get_component_url(TestComponent)
assert component_url == f"/components/ext/view/components/{TestComponent.class_id}/"
def test_public_url_disabled(self):
did_call_get = False did_call_get = False
class TestComponent(Component): class TestComponent(Component):

View file

@ -1,5 +1,5 @@
import gc import gc
from typing import Any, Callable, Dict, List, cast from typing import Any, Callable, Dict, List, Optional, cast
import pytest import pytest
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
@ -215,7 +215,7 @@ class OverrideAssetExtension(ComponentExtension):
@djc_test @djc_test
class TestExtension: class TestExtensions:
@djc_test(components_settings={"extensions": [DummyExtension]}) @djc_test(components_settings={"extensions": [DummyExtension]})
def test_extensions_setting(self): def test_extensions_setting(self):
assert len(app_settings.EXTENSIONS) == 6 assert len(app_settings.EXTENSIONS) == 6
@ -258,6 +258,35 @@ class TestExtension:
with pytest.raises(ValueError, match="Multiple extensions cannot have the same name 'test_extension'"): with pytest.raises(ValueError, match="Multiple extensions cannot have the same name 'test_extension'"):
inner() inner()
@djc_test(components_settings={"extensions": [DummyExtension]})
def test_nested_extension_config_inheritance(self):
component: Optional[Component] = None
class TestExtensionParent:
parent_var = "from_parent"
class MyComponent(Component):
template = "hello"
class TestExtension(TestExtensionParent):
nested_var = "from_nested"
def get_template_data(self, args, kwargs, slots, context):
nonlocal component
component = self
# Rendering the component will execute get_template_data
MyComponent.render()
assert component is not None
# Check properties from DummyExtension.ComponentConfig
assert component.test_extension.foo == "1" # type: ignore[attr-defined]
assert component.test_extension.bar == "2" # type: ignore[attr-defined]
# Check properties from nested class
assert component.test_extension.nested_var == "from_nested" # type: ignore[attr-defined]
# Check properties from parent of nested class
assert component.test_extension.parent_var == "from_parent" # type: ignore[attr-defined]
@djc_test @djc_test
class TestExtensionHooks: class TestExtensionHooks: