refactor: use typevar defaults + raise on conflicting extensions (#1125)

* refactor: use typevar defaults + raise on conflicting extensions

* refactor: fix linter errors
This commit is contained in:
Juro Oravec 2025-04-14 10:00:18 +02:00 committed by GitHub
parent 61528ef0ad
commit 06cad2ec64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 321 additions and 93 deletions

View file

@ -1,6 +1,32 @@
# Release notes
## v0.139
## v0.139.1
#### Refactor
- When typing a Component, you can now specify as few or as many parameters as you want.
```py
Component[Args]
Component[Args, Kwargs]
Component[Args, Kwargs, Slots]
Component[Args, Kwargs, Slots, Data]
Component[Args, Kwargs, Slots, Data, JsData]
Component[Args, Kwargs, Slots, Data, JsData, CssData]
```
All omitted parameters will default to `Any`.
- Added `typing_extensions` to the project as a dependency
- Multiple extensions with the same name (case-insensitive) now raise an error
- Extension names (case-insensitive) also MUST NOT conflict with existing Component class API.
So if you name an extension `render`, it will conflict with the `render()` method of the `Component` class,
and thus raise an error.
## v0.139.0
#### Fix

View file

@ -396,7 +396,7 @@ Components API is fully typed, and supports [static type hints](https://django-c
To opt-in to static type hints, define types for component's args, kwargs, slots, and more:
```py
from typing import NotRequired, Tuple, TypedDict, SlotContent, SlotFunc
from typing import NotRequired, Tuple, TypedDict, SlotContent, Slot
from django_components import Component
@ -407,22 +407,19 @@ class ButtonKwargs(TypedDict):
another: int
maybe_var: NotRequired[int] # May be omitted
class ButtonData(TypedDict):
variable: str
class ButtonSlots(TypedDict):
my_slot: NotRequired[SlotFunc]
# Use `Slot` for slot functions.
my_slot: NotRequired[Slot]
# Use `SlotContent` when you want to allow either `Slot` instance or plain string
another_slot: SlotContent
ButtonType = Component[ButtonArgs, ButtonKwargs, ButtonSlots, ButtonData, JsData, CssData]
ButtonType = Component[ButtonArgs, ButtonKwargs, ButtonSlots]
class Button(ButtonType):
def get_context_data(self, *args, **kwargs):
self.input.args[0] # int
self.input.kwargs["variable"] # str
self.input.slots["my_slot"] # SlotFunc[MySlotData]
return {} # Error: Key "variable" is missing
self.input.slots["my_slot"] # Slot[MySlotData]
```
When you then call

View file

@ -103,7 +103,7 @@ For live examples, see the [Community examples](../../overview/community.md#comm
```djc_py
from typing import Dict, NotRequired, Optional, Tuple, TypedDict
from django_components import Component, SlotFunc, register, types
from django_components import Component, SlotContent, register, types
from myapp.templatetags.mytags import comp_registry
@ -114,7 +114,7 @@ For live examples, see the [Community examples](../../overview/community.md#comm
type MyMenuArgs = Tuple[int, str]
class MyMenuSlots(TypedDict):
default: NotRequired[Optional[SlotFunc[EmptyDict]]]
default: NotRequired[Optional[SlotContent[EmptyDict]]]
class MyMenuProps(TypedDict):
vertical: NotRequired[bool]
@ -124,7 +124,7 @@ For live examples, see the [Community examples](../../overview/community.md#comm
# Define the component
# NOTE: Don't forget to set the `registry`!
@register("my_menu", registry=comp_registry)
class MyMenu(Component[MyMenuArgs, MyMenuProps, MyMenuSlots, Any, Any, Any]):
class MyMenu(Component[MyMenuArgs, MyMenuProps, MyMenuSlots]):
def get_context_data(
self,
*args,

View file

@ -169,6 +169,14 @@ class MyExtension(ComponentExtension):
ctx.component_cls.my_attr = "my_value"
```
!!! warning
The `name` attribute MUST be unique across all extensions.
Moreover, the `name` attribute MUST NOT conflict with existing Component class API.
So if you name an extension `render`, it will conflict with the [`render()`](../../../reference/api/#django_components.Component.render) method of the `Component` class.
### Defining the extension class
In previous sections we've seen the `View` and `Storybook` extensions classes that were nested within the `Component` class:

View file

@ -10,7 +10,7 @@ Use this to add type hints to your components, or to validate component inputs.
```py
from django_components import Component
ButtonType = Component[Args, Kwargs, Slots, Data, JsData, CssData]
ButtonType = Component[Args, Kwargs, Slots, TemplateData, JsData, CssData]
class Button(ButtonType):
template_file = "button.html"
@ -24,16 +24,34 @@ The generic parameters are:
- `Args` - Positional arguments, must be a `Tuple` or `Any`
- `Kwargs` - Keyword arguments, must be a `TypedDict` or `Any`
- `Slots` - Slots, must be a `TypedDict` or `Any`
- `Data` - Data returned from [`get_context_data()`](../../../reference/api#django_components.Component.get_context_data), must be a `TypedDict` or `Any`
- `TemplateData` - Data returned from [`get_context_data()`](../../../reference/api#django_components.Component.get_context_data), must be a `TypedDict` or `Any`
- `JsData` - Data returned from [`get_js_data()`](../../../reference/api#django_components.Component.get_js_data), must be a `TypedDict` or `Any`
- `CssData` - Data returned from [`get_css_data()`](../../../reference/api#django_components.Component.get_css_data), must be a `TypedDict` or `Any`
## Example
You can specify as many or as few of the parameters as you want. The rest will default to `Any`.
All of these are valid:
```py
ButtonType = Component[Args, Kwargs, Slots, TemplateData, JsData, CssData]
ButtonType = Component[Args, Kwargs, Slots, TemplateData, JsData]
ButtonType = Component[Args, Kwargs, Slots, TemplateData]
ButtonType = Component[Args, Kwargs, Slots]
ButtonType = Component[Args, Kwargs]
ButtonType = Component[Args]
```
!!! info
Setting a type parameter to `Any` will disable typing and validation for that part.
## Typing inputs
You can use the first 3 parameters to type inputs.
```python
from typing import NotRequired, Tuple, TypedDict
from pydantic import BaseModel
from django_components import Component, SlotContent, SlotFunc
from typing_extensions import NotRequired, Tuple, TypedDict
from django_components import Component, Slot, SlotContent
###########################################
# 1. Define the types
@ -54,39 +72,17 @@ class ButtonFooterSlotData(TypedDict):
# Slots
class ButtonSlots(TypedDict):
# SlotContent == str or slot func
# Use `SlotContent` when you want to allow either `Slot` instance or plain string
header: SlotContent
# Use SlotFunc for slot functions.
# Use `Slot` for slot functions.
# The generic specifies the data available to the slot function
footer: NotRequired[SlotFunc[ButtonFooterSlotData]]
# Data returned from `get_context_data`
class ButtonData(BaseModel):
data1: str
data2: int
# Data returned from `get_js_data`
class ButtonJsData(BaseModel):
js_data1: str
js_data2: int
# Data returned from `get_css_data`
class ButtonCssData(BaseModel):
css_data1: str
css_data2: int
footer: NotRequired[Slot[ButtonFooterSlotData]]
###########################################
# 2. Define the component with those types
###########################################
ButtonType = Component[
ButtonArgs,
ButtonKwargs,
ButtonSlots,
ButtonData,
ButtonJsData,
ButtonCssData,
]
ButtonType = Component[ButtonArgs, ButtonKwargs, ButtonSlots]
class Button(ButtonType):
def get_context_data(self, *args, **kwargs):
@ -109,23 +105,81 @@ Button.render(
},
slots={
"header": "...",
# ERROR: Expects key "footer"
"footer": lambda ctx, slot_data, slot_ref: slot_data.value + 1,
# ERROR: Unexpected key "foo"
"foo": "invalid",
},
)
```
If you don't want to validate some parts, set them to [`Any`](https://docs.python.org/3/library/typing.html#typing.Any).
If you don't want to validate some parts, set them to [`Any`](https://docs.python.org/3/library/typing.html#typing.Any) or omit them.
The following will validate only the keyword inputs:
```python
ButtonType = Component[
ButtonArgs,
ButtonKwargs,
ButtonSlots,
Any,
Any,
Any,
]
ButtonType = Component[Any, ButtonKwargs]
class Button(ButtonType):
...
```
## Typing data
You can use the last 3 parameters to type the data returned from [`get_context_data()`](../../../reference/api#django_components.Component.get_context_data), [`get_js_data()`](../../../reference/api#django_components.Component.get_js_data), and [`get_css_data()`](../../../reference/api#django_components.Component.get_css_data).
Use this together with the [Pydantic integration](#runtime-input-validation-with-types) to have runtime validation of the data.
```python
from typing_extensions import NotRequired, Tuple, TypedDict
from django_components import Component, Slot, SlotContent
###########################################
# 1. Define the types
###########################################
# Data returned from `get_context_data`
class ButtonData(TypedDict):
data1: str
data2: int
# Data returned from `get_js_data`
class ButtonJsData(TypedDict):
js_data1: str
js_data2: int
# Data returned from `get_css_data`
class ButtonCssData(TypedDict):
css_data1: str
css_data2: int
###########################################
# 2. Define the component with those types
###########################################
ButtonType = Component[Any, Any, Any, ButtonData, ButtonJsData, ButtonCssData]
class Button(ButtonType):
def get_context_data(self, *args, **kwargs):
...
def get_js_data(self, *args, **kwargs):
...
def get_css_data(self, *args, **kwargs):
...
```
When you then call
[`Component.render`](../../../reference/api#django_components.Component.render)
or [`Component.render_to_response`](../../../reference/api#django_components.Component.render_to_response),
the component will raise an error if the data is not valid.
If you don't want to validate some parts, set them to [`Any`](https://docs.python.org/3/library/typing.html#typing.Any).
The following will validate only the data returned from `get_js_data`:
```python
ButtonType = Component[Any, Any, Any, Any, ButtonJsData]
class Button(ButtonType):
...
@ -182,6 +236,9 @@ class Button(Component[EmptyTuple, EmptyDict, EmptyDict, EmptyDict, EmptyDict, E
Since v0.136, input validation is available as a separate extension.
By defualt, when you add types to your component, this will only set up static type hints,
but it will not validate the inputs.
To enable input validation, you need to install the [`djc-ext-pydantic`](https://github.com/django-components/djc-ext-pydantic) extension:
```bash

View file

@ -47,7 +47,7 @@ Component.render(
context: Mapping | django.template.Context | None = None,
args: List[Any] | None = None,
kwargs: Dict[str, Any] | None = None,
slots: Dict[str, str | SafeString | SlotFunc] | None = None,
slots: Dict[str, str | SafeString | SlotContent] | None = None,
escape_slots_content: bool = True
) -> str:
```
@ -60,7 +60,7 @@ Component.render(
- _`slots`_ - Component slot fills. This is the same as pasing `{% fill %}` tags to the component.
Accepts a dictionary of `{ slot_name: slot_content }` where `slot_content` can be a string
or [`SlotFunc`](#slotfunc).
or `Slot`.
- _`escape_slots_content`_ - Whether the content from `slots` should be escaped. `True` by default to prevent XSS attacks. If you disable escaping, you should make sure that any content you pass to the slots is safe, especially if it comes from user input.

View file

@ -386,7 +386,7 @@ Components API is fully typed, and supports [static type hints](https://django-c
To opt-in to static type hints, define types for component's args, kwargs, slots, and more:
```py
from typing import NotRequired, Tuple, TypedDict, SlotContent, SlotFunc
from typing import NotRequired, Tuple, TypedDict, SlotContent, Slot
from django_components import Component
@ -397,22 +397,19 @@ class ButtonKwargs(TypedDict):
another: int
maybe_var: NotRequired[int] # May be omitted
class ButtonData(TypedDict):
variable: str
class ButtonSlots(TypedDict):
my_slot: NotRequired[SlotFunc]
# Use `Slot` for slot functions.
my_slot: NotRequired[Slot]
# Use `SlotContent` when you want to allow either `Slot` instance or plain string
another_slot: SlotContent
ButtonType = Component[ButtonArgs, ButtonKwargs, ButtonSlots, ButtonData, JsData, CssData]
ButtonType = Component[ButtonArgs, ButtonKwargs, ButtonSlots]
class Button(ButtonType):
def get_context_data(self, *args, **kwargs):
self.input.args[0] # int
self.input.kwargs["variable"] # str
self.input.slots["my_slot"] # SlotFunc[MySlotData]
return {} # Error: Key "variable" is missing
self.input.slots["my_slot"] # Slot
```
When you then call

View file

@ -19,10 +19,6 @@
options:
show_if_no_docstring: true
::: django_components.ComponentCommand
options:
show_if_no_docstring: true
::: django_components.ComponentDefaults
options:
show_if_no_docstring: true
@ -35,6 +31,10 @@
options:
show_if_no_docstring: true
::: django_components.ComponentInput
options:
show_if_no_docstring: true
::: django_components.ComponentMediaInput
options:
show_if_no_docstring: true

View file

@ -462,8 +462,8 @@ ProjectDashboardAction project.components.dashboard_action.ProjectDashboardAc
## `upgradecomponent`
```txt
usage: upgradecomponent [-h] [--path PATH] [--version] [-v {0,1,2,3}] [--settings SETTINGS] [--pythonpath PYTHONPATH]
[--traceback] [--no-color] [--force-color] [--skip-checks]
usage: upgradecomponent [-h] [--path PATH] [--version] [-v {0,1,2,3}] [--settings SETTINGS]
[--pythonpath PYTHONPATH] [--traceback] [--no-color] [--force-color] [--skip-checks]
```
@ -507,9 +507,9 @@ Deprecated. Use `components upgrade` instead.
## `startcomponent`
```txt
usage: startcomponent [-h] [--path PATH] [--js JS] [--css CSS] [--template TEMPLATE] [--force] [--verbose] [--dry-run]
[--version] [-v {0,1,2,3}] [--settings SETTINGS] [--pythonpath PYTHONPATH] [--traceback]
[--no-color] [--force-color] [--skip-checks]
usage: startcomponent [-h] [--path PATH] [--js JS] [--css CSS] [--template TEMPLATE] [--force] [--verbose]
[--dry-run] [--version] [-v {0,1,2,3}] [--settings SETTINGS] [--pythonpath PYTHONPATH]
[--traceback] [--no-color] [--force-color] [--skip-checks]
name
```

View file

@ -109,6 +109,7 @@ name | type | description
::: django_components.extension.ComponentExtension.on_component_rendered
options:
heading_level: 3
show_root_heading: true
show_signature: true
separate_signature: true

View file

@ -20,7 +20,7 @@ Import as
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1037" target="_blank">See source code</a>
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1064" target="_blank">See source code</a>
@ -43,7 +43,7 @@ If you insert this tag multiple times, ALL CSS links will be duplicately inserte
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1059" target="_blank">See source code</a>
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1086" target="_blank">See source code</a>
@ -67,7 +67,7 @@ If you insert this tag multiple times, ALL JS scripts will be duplicately insert
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1564" target="_blank">See source code</a>
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1669" target="_blank">See source code</a>

View file

@ -31,6 +31,7 @@ classifiers = [
dependencies = [
'Django>=4.2',
'djc-core-html-parser>=1.0.2',
'typing-extensions>=4.12.2',
]
license = {text = "MIT"}

View file

@ -7,3 +7,4 @@ whitenoise
asv
pytest-asyncio
pytest-django
typing-extensions>=4.12.2

View file

@ -95,6 +95,7 @@ types-requests==2.32.0.20241016
# via -r requirements-ci.in
typing-extensions==4.12.2
# via
# -r requirements-ci.in
# pyee
# tox
urllib3==2.2.3

View file

@ -18,3 +18,4 @@ whitenoise
pygments
pygments-djc
asv
typing-extensions>=4.12.2

View file

@ -150,6 +150,7 @@ types-requests==2.32.0.20241016
# via -r requirements-dev.in
typing-extensions==4.12.2
# via
# -r requirements-dev.in
# asgiref
# black
# mypy

View file

@ -18,7 +18,6 @@ from typing import (
Optional,
Tuple,
Type,
TypeVar,
Union,
cast,
)
@ -34,6 +33,7 @@ from django.template.loader_tags import BLOCK_CONTEXT_KEY, BlockContext
from django.test.signals import template_rendered
from django.utils.html import conditional_escape
from django.views import View
from typing_extensions import TypeVar
from django_components.app_settings import ContextBehavior, app_settings
from django_components.component_media import ComponentMediaInput, ComponentMediaMeta
@ -103,12 +103,12 @@ from django_components.component_registry import registry as registry # NOQA
COMP_ONLY_FLAG = "only"
# Define TypeVars for args and kwargs
ArgsType = TypeVar("ArgsType", bound=tuple, contravariant=True)
KwargsType = TypeVar("KwargsType", bound=Mapping[str, Any], contravariant=True)
SlotsType = TypeVar("SlotsType", bound=Mapping[SlotName, SlotContent])
DataType = TypeVar("DataType", bound=Mapping[str, Any], covariant=True)
JsDataType = TypeVar("JsDataType", bound=Mapping[str, Any])
CssDataType = TypeVar("CssDataType", bound=Mapping[str, Any])
ArgsType = TypeVar("ArgsType", bound=tuple, contravariant=True, default=Any)
KwargsType = TypeVar("KwargsType", bound=Mapping[str, Any], contravariant=True, default=Any)
SlotsType = TypeVar("SlotsType", bound=Mapping[SlotName, SlotContent], default=Any)
DataType = TypeVar("DataType", bound=Mapping[str, Any], covariant=True, default=Any)
JsDataType = TypeVar("JsDataType", bound=Mapping[str, Any], default=Any)
CssDataType = TypeVar("CssDataType", bound=Mapping[str, Any], default=Any)
# NOTE: `ReferenceType` is NOT a generic pre-3.9

View file

@ -1,5 +1,5 @@
from functools import wraps
from typing import TYPE_CHECKING, Any, Callable, Dict, List, NamedTuple, Optional, Tuple, Type, TypeVar, Union
from typing import TYPE_CHECKING, Any, Callable, Dict, List, NamedTuple, Optional, Set, Tuple, Type, TypeVar, Union
import django.urls
from django.template import Context
@ -661,7 +661,20 @@ class ExtensionManager:
# Populate the `urlpatterns` with URLs specified by the extensions
# TODO_V3 - Django-specific logic - replace with hook
urls: List[URLResolver] = []
seen_names: Set[str] = set()
from django_components import Component
for extension in self.extensions:
# Ensure that the extension name won't conflict with existing Component class API
if hasattr(Component, extension.name) or hasattr(Component, extension.class_name):
raise ValueError(f"Extension name '{extension.name}' conflicts with existing Component class API")
if extension.name.lower() in seen_names:
raise ValueError(f"Multiple extensions cannot have the same name '{extension.name}'")
seen_names.add(extension.name.lower())
# NOTE: The empty list is a placeholder for the URLs that will be added later
curr_ext_url_resolver = django.urls.path(f"{extension.name}/", django.urls.include([]))
urls.append(curr_ext_url_resolver)

View file

@ -1,12 +1,10 @@
from typing import Any, Optional, Type, TypeVar
from typing import Any, Optional, Type
from django.template import Origin, Template
from django_components.cache import get_template_cache
from django_components.util.misc import get_import_path
TTemplate = TypeVar("TTemplate", bound=Template)
# Central logic for creating Templates from string, so we can cache the results
def cached_template(

View file

@ -4,7 +4,8 @@ For tests focusing on the `component` tag, see `test_templatetags_component.py`
"""
import re
from typing import Any, Dict, no_type_check
from typing import Any, Dict, Tuple, no_type_check
from typing_extensions import NotRequired, TypedDict
import pytest
from django.conf import settings
@ -16,7 +17,16 @@ from django.test import Client
from django.urls import path
from pytest_django.asserts import assertHTMLEqual, assertInHTML
from django_components import Component, ComponentView, all_components, get_component_by_class_id, register, types
from django_components import (
Component,
ComponentView,
SlotContent,
Slot,
all_components,
get_component_by_class_id,
register,
types,
)
from django_components.slots import SlotRef
from django_components.urls import urlpatterns as dc_urlpatterns
@ -375,6 +385,101 @@ class TestComponent:
assert SimpleComponent.render() == "Hello"
def test_typing(self):
# Types
ButtonArgs = Tuple[str, ...]
class ButtonKwargs(TypedDict):
name: str
age: int
maybe_var: NotRequired[int]
class ButtonFooterSlotData(TypedDict):
value: int
class ButtonSlots(TypedDict):
# Use `SlotContent` when you want to allow either function (`Slot` instance)
# or plain string.
header: SlotContent
# Use `Slot` for slot functions. The generic specifies the data available to the slot function.
footer: NotRequired[Slot[ButtonFooterSlotData]]
# Data returned from `get_context_data`
class ButtonData(TypedDict):
data1: str
data2: int
# Data returned from `get_js_data`
class ButtonJsData(TypedDict):
js_data1: str
js_data2: int
# Data returned from `get_css_data`
class ButtonCssData(TypedDict):
css_data1: str
css_data2: int
# Tests - We simply check that these don't raise any errors
# nor any type errors.
ButtonType1 = Component[ButtonArgs, ButtonKwargs, ButtonSlots, ButtonData, ButtonJsData, ButtonCssData]
ButtonType2 = Component[ButtonArgs, ButtonKwargs, ButtonSlots, ButtonData, ButtonJsData]
ButtonType3 = Component[ButtonArgs, ButtonKwargs, ButtonSlots, ButtonData]
ButtonType4 = Component[ButtonArgs, ButtonKwargs, ButtonSlots]
ButtonType5 = Component[ButtonArgs, ButtonKwargs]
ButtonType6 = Component[ButtonArgs]
class Button1(ButtonType1):
template = "<button>Click me!</button>"
class Button2(ButtonType2):
template = "<button>Click me!</button>"
class Button3(ButtonType3):
template = "<button>Click me!</button>"
class Button4(ButtonType4):
template = "<button>Click me!</button>"
class Button5(ButtonType5):
template = "<button>Click me!</button>"
class Button6(ButtonType6):
template = "<button>Click me!</button>"
Button1.render(
args=("arg1", "arg2"),
kwargs={"name": "name", "age": 123},
slots={"header": "HEADER", "footer": Slot(lambda ctx, slot_data, slot_ref: "FOOTER")},
)
Button2.render(
args=("arg1", "arg2"),
kwargs={"name": "name", "age": 123},
slots={"header": "HEADER", "footer": Slot(lambda ctx, slot_data, slot_ref: "FOOTER")},
)
Button3.render(
args=("arg1", "arg2"),
kwargs={"name": "name", "age": 123},
slots={"header": "HEADER", "footer": Slot(lambda ctx, slot_data, slot_ref: "FOOTER")},
)
Button4.render(
args=("arg1", "arg2"),
kwargs={"name": "name", "age": 123},
slots={"header": "HEADER", "footer": Slot(lambda ctx, slot_data, slot_ref: "FOOTER")},
)
Button5.render(
args=("arg1", "arg2"),
kwargs={"name": "name", "age": 123},
)
Button6.render(
args=("arg1", "arg2"),
)
@djc_test
class TestComponentRender:
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)

View file

@ -1,6 +1,7 @@
import gc
from typing import Any, Callable, Dict, List, cast
import pytest
from django.http import HttpRequest, HttpResponse
from django.template import Context
from django.test import Client
@ -108,6 +109,10 @@ class DummyNestedExtension(ComponentExtension):
]
class RenderExtension(ComponentExtension):
name = "render"
def with_component_cls(on_created: Callable):
class TempComponent(Component):
template = "Hello {{ name }}!"
@ -151,6 +156,22 @@ class TestExtension:
del TestAccessComp
gc.collect()
def test_raises_on_extension_name_conflict(self):
@djc_test(components_settings={"extensions": [RenderExtension]})
def inner():
pass
with pytest.raises(ValueError, match="Extension name 'render' conflicts with existing Component class API"):
inner()
def test_raises_on_multiple_extensions_with_same_name(self):
@djc_test(components_settings={"extensions": [DummyExtension, DummyExtension]})
def inner():
pass
with pytest.raises(ValueError, match="Multiple extensions cannot have the same name 'test_extension'"):
inner()
@djc_test
class TestExtensionHooks: