diff --git a/CHANGELOG.md b/CHANGELOG.md
index 45e60bdd..e1699eb5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,23 @@
# Release notes
+## v0.131
+
+#### Feat
+
+- `@djc_test` decorator for writing tests that involve Components.
+
+ - The decorator manages global state, ensuring that tests don't leak.
+ - If using `pytest`, the decorator allows you to parametrize Django or Components settings.
+ - The decorator also serves as a stand-in for Django's `@override_settings`.
+
+ See the API reference for [`@djc_test`](https://django-components.github.io/django-components/0.131/reference/testing_api/#djc_test) for more details.
+
+#### Internal
+
+- Settings are now loaded only once, and thus are considered immutable once loaded. Previously,
+ django-components would load settings from `settings.COMPONENTS` on each access. The new behavior
+ aligns with Django's settings.
+
## v0.130
#### Feat
diff --git a/docs/concepts/advanced/.nav.yml b/docs/concepts/advanced/.nav.yml
index 5e72f170..2fb50139 100644
--- a/docs/concepts/advanced/.nav.yml
+++ b/docs/concepts/advanced/.nav.yml
@@ -8,4 +8,5 @@ nav:
- Typing and validation: typing_and_validation.md
- Custom template tags: template_tags.md
- Tag formatters: tag_formatter.md
+ - Testing: testing.md
- Authoring component libraries: authoring_component_libraries.md
diff --git a/docs/concepts/advanced/testing.md b/docs/concepts/advanced/testing.md
new file mode 100644
index 00000000..83ee3ee5
--- /dev/null
+++ b/docs/concepts/advanced/testing.md
@@ -0,0 +1,111 @@
+_New in version 0.131_
+
+The [`@djc_test`](../../../reference/testing_api#djc_test) decorator is a powerful tool for testing components created with `django-components`. It ensures that each test is properly isolated, preventing components registered in one test from affecting others.
+
+## Usage
+
+The [`@djc_test`](../../../reference/testing_api#djc_test) decorator can be applied to functions, methods, or classes. When applied to a class, it recursively decorates all methods starting with `test_`, including those in nested classes. This allows for comprehensive testing of component behavior.
+
+### Applying to a Function
+
+To apply [`djc_test`](../../../reference/testing_api#djc_test) to a function,
+simply decorate the function as shown below:
+
+```python
+import django
+from django_components.testing import djc_test
+
+django.setup()
+
+@djc_test
+def test_my_component():
+ @register("my_component")
+ class MyComponent(Component):
+ template = "..."
+ ...
+```
+
+### Applying to a Class
+
+When applied to a class, `djc_test` decorates each `test_` method individually:
+
+```python
+import django
+from django_components.testing import djc_test
+
+django.setup()
+
+@djc_test
+class TestMyComponent:
+ def test_something(self):
+ ...
+
+ class Nested:
+ def test_something_else(self):
+ ...
+```
+
+This is equivalent to applying the decorator to each method individually:
+
+```python
+import django
+from django_components.testing import djc_test
+
+django.setup()
+
+class TestMyComponent:
+ @djc_test
+ def test_something(self):
+ ...
+
+ class Nested:
+ @djc_test
+ def test_something_else(self):
+ ...
+```
+
+### Arguments
+
+See the API reference for [`@djc_test`](../../../reference/testing_api#djc_test) for more details.
+
+### Setting Up Django
+
+Before using [`djc_test`](../../../reference/testing_api#djc_test), ensure Django is set up:
+
+```python
+import django
+from django_components.testing import djc_test
+
+django.setup()
+
+@djc_test
+def test_my_component():
+ ...
+```
+
+## Example: Parametrizing Context Behavior
+
+You can parametrize the [context behavior](../../../reference/settings#django_components.app_settings.ComponentsSettings.context_behavior) using [`djc_test`](../../../reference/testing_api#djc_test):
+
+```python
+from django_components.testing import djc_test
+
+@djc_test(
+ # Settings applied to all cases
+ components_settings={
+ "app_dirs": ["custom_dir"],
+ },
+ # Parametrized settings
+ parametrize=(
+ ["components_settings"],
+ [
+ [{"context_behavior": "django"}],
+ [{"context_behavior": "isolated"}],
+ ],
+ ["django", "isolated"],
+ )
+)
+def test_context_behavior(components_settings):
+ rendered = MyComponent().render()
+ ...
+```
diff --git a/docs/reference/.nav.yml b/docs/reference/.nav.yml
index 41667025..882f0188 100644
--- a/docs/reference/.nav.yml
+++ b/docs/reference/.nav.yml
@@ -11,3 +11,4 @@ nav:
- Template tags: template_tags.md
- Template vars: template_vars.md
- URLs: urls.md
+ - Testing API: testing_api.md
diff --git a/docs/reference/testing_api.md b/docs/reference/testing_api.md
new file mode 100644
index 00000000..2ab0e1e3
--- /dev/null
+++ b/docs/reference/testing_api.md
@@ -0,0 +1,9 @@
+
+
+# Testing API
+
+
+::: django_components.testing.djc_test
+ options:
+ show_if_no_docstring: true
+
diff --git a/docs/scripts/reference.py b/docs/scripts/reference.py
index c15de89f..a286da50 100644
--- a/docs/scripts/reference.py
+++ b/docs/scripts/reference.py
@@ -108,6 +108,40 @@ def gen_reference_api():
f.write("\n")
+def gen_reference_testing_api():
+ """
+ Generate documentation for the Python API of `django_components.testing`.
+
+ This takes all public symbols exported from `django_components.testing`.
+ """
+ module = import_module("django_components.testing")
+
+ preface = "\n\n"
+ preface += (root / "docs/templates/reference_testing_api.md").read_text()
+ out_file = root / "docs/reference/testing_api.md"
+
+ out_file.parent.mkdir(parents=True, exist_ok=True)
+ with out_file.open("w", encoding="utf-8") as f:
+ f.write(preface + "\n\n")
+
+ for name, obj in inspect.getmembers(module):
+ if (
+ name.startswith("_")
+ or inspect.ismodule(obj)
+ ):
+ continue
+
+ # For each entry, generate a mkdocstrings entry, e.g.
+ # ```
+ # ::: django_components.testing.djc_test
+ # options:
+ # show_if_no_docstring: true
+ # ```
+ f.write(f"::: {module.__name__}.{name}\n" f" options:\n" f" show_if_no_docstring: true\n")
+
+ f.write("\n")
+
+
def gen_reference_exceptions():
"""
Generate documentation for the Exception classes included in the Python API of `django_components`.
@@ -739,6 +773,7 @@ def gen_reference():
gen_reference_templatetags()
gen_reference_templatevars()
gen_reference_signals()
+ gen_reference_testing_api()
# This is run when `gen-files` plugin is run in mkdocs.yml
diff --git a/docs/templates/reference_testing_api.md b/docs/templates/reference_testing_api.md
new file mode 100644
index 00000000..4bd65a2c
--- /dev/null
+++ b/docs/templates/reference_testing_api.md
@@ -0,0 +1 @@
+# Testing API
diff --git a/pyproject.toml b/pyproject.toml
index 363c4ce0..563c9f22 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -109,6 +109,7 @@ disallow_untyped_defs = true
testpaths = [
"tests"
]
+asyncio_mode = "auto"
[tool.hatch.env]
requires = [
diff --git a/requirements-ci.in b/requirements-ci.in
index 3efa14b8..dad5079e 100644
--- a/requirements-ci.in
+++ b/requirements-ci.in
@@ -4,4 +4,6 @@ playwright
requests
types-requests
whitenoise
-asv
\ No newline at end of file
+asv
+pytest-asyncio
+pytest-django
\ No newline at end of file
diff --git a/requirements-ci.txt b/requirements-ci.txt
index 783d3c2a..0d56dde5 100644
--- a/requirements-ci.txt
+++ b/requirements-ci.txt
@@ -4,17 +4,23 @@
#
# pip-compile requirements-ci.in
#
-cachetools==5.5.0
+asv==0.6.4
+ # via -r requirements-ci.in
+asv-runner==0.2.1
+ # via asv
+build==1.2.2.post1
+ # via asv
+cachetools==5.5.2
# via tox
-certifi==2024.8.30
+certifi==2025.1.31
# via requests
chardet==5.2.0
# via tox
-charset-normalizer==3.3.2
+charset-normalizer==3.4.1
# via requests
colorama==0.4.6
# via tox
-distlib==0.3.8
+distlib==0.3.9
# via virtualenv
filelock==3.16.1
# via
@@ -24,9 +30,17 @@ greenlet==3.1.1
# via playwright
idna==3.10
# via requests
+importlib-metadata==8.5.0
+ # via asv-runner
+iniconfig==2.0.0
+ # via pytest
+json5==0.10.0
+ # via asv
packaging==24.2
# via
+ # build
# pyproject-api
+ # pytest
# tox
platformdirs==4.3.6
# via
@@ -35,18 +49,36 @@ platformdirs==4.3.6
playwright==1.48.0
# via -r requirements-ci.in
pluggy==1.5.0
- # via tox
+ # via
+ # pytest
+ # tox
pyee==12.0.0
# via playwright
+pympler==1.1
+ # via asv
pyproject-api==1.8.0
# via tox
+pyproject-hooks==1.2.0
+ # via build
+pytest==8.3.4
+ # via
+ # pytest-asyncio
+ # pytest-django
+pytest-asyncio==0.24.0
+ # via -r requirements-ci.in
+pytest-django==4.10.0
+ # via -r requirements-ci.in
+pyyaml==6.0.2
+ # via asv
requests==2.32.3
# via -r requirements-ci.in
+tabulate==0.9.0
+ # via asv
tox==4.24.1
# via
# -r requirements-ci.in
# tox-gh-actions
-tox-gh-actions==3.2.0
+tox-gh-actions==3.3.0
# via -r requirements-ci.in
types-requests==2.32.0.20241016
# via -r requirements-ci.in
@@ -56,7 +88,11 @@ urllib3==2.2.3
# via
# requests
# types-requests
-virtualenv==20.29.1
- # via tox
+virtualenv==20.29.2
+ # via
+ # asv
+ # tox
whitenoise==6.7.0
# via -r requirements-ci.in
+zipp==3.20.2
+ # via importlib-metadata
diff --git a/requirements-dev.in b/requirements-dev.in
index 0a7ce5d9..83cda366 100644
--- a/requirements-dev.in
+++ b/requirements-dev.in
@@ -2,6 +2,8 @@ django
djc-core-html-parser
tox
pytest
+pytest-asyncio
+pytest-django
syrupy
flake8
flake8-pyproject
diff --git a/requirements-dev.txt b/requirements-dev.txt
index f06decbd..2d6bd1b6 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -6,11 +6,17 @@
#
asgiref==3.8.1
# via django
-black==24.10.0
+asv==0.6.4
# via -r requirements-dev.in
-cachetools==5.5.1
+asv-runner==0.2.1
+ # via asv
+black==25.1.0
+ # via -r requirements-dev.in
+build==1.2.2.post1
+ # via asv
+cachetools==5.5.2
# via tox
-certifi==2024.8.30
+certifi==2025.1.31
# via requests
cfgv==3.4.0
# via pre-commit
@@ -40,14 +46,18 @@ flake8-pyproject==1.2.3
# via -r requirements-dev.in
greenlet==3.1.1
# via playwright
-identify==2.6.7
+identify==2.6.8
# via pre-commit
idna==3.10
# via requests
+importlib-metadata==8.5.0
+ # via asv-runner
iniconfig==2.0.0
# via pytest
-isort==6.0.0
+isort==6.0.1
# via -r requirements-dev.in
+json5==0.10.0
+ # via asv
mccabe==0.7.0
# via flake8
mypy==1.15.0
@@ -61,6 +71,7 @@ nodeenv==1.9.1
packaging==24.2
# via
# black
+ # build
# pyproject-api
# pytest
# tox
@@ -71,7 +82,7 @@ platformdirs==4.3.6
# black
# tox
# virtualenv
-playwright==1.49.0
+playwright==1.48.0
# via -r requirements-dev.in
pluggy==1.5.0
# via
@@ -86,19 +97,39 @@ pyee==12.0.0
pyflakes==3.2.0
# via flake8
pygments==2.19.1
- # via pygments-djc
+ # via
+ # -r requirements-dev.in
+ # pygments-djc
pygments-djc==1.0.1
# via -r requirements-dev.in
+pympler==1.1
+ # via asv
pyproject-api==1.8.0
# via tox
+pyproject-hooks==1.2.0
+ # via build
pytest==8.3.4
+ # via
+ # -r requirements-dev.in
+ # pytest-asyncio
+ # pytest-django
+ # syrupy
+pytest-asyncio==0.24.0
+ # via -r requirements-dev.in
+pytest-django==4.10.0
# via -r requirements-dev.in
pyyaml==6.0.2
- # via pre-commit
+ # via
+ # asv
+ # pre-commit
requests==2.32.3
# via -r requirements-dev.in
sqlparse==0.5.3
# via django
+syrupy==4.8.2
+ # via -r requirements-dev.in
+tabulate==0.9.0
+ # via asv
tox==4.24.1
# via -r requirements-dev.in
types-requests==2.32.0.20241016
@@ -111,9 +142,12 @@ urllib3==2.2.3
# via
# requests
# types-requests
-virtualenv==20.28.0
+virtualenv==20.29.2
# via
+ # asv
# pre-commit
# tox
-whitenoise==6.8.2
+whitenoise==6.7.0
# via -r requirements-dev.in
+zipp==3.20.2
+ # via importlib-metadata
diff --git a/src/django_components/app_settings.py b/src/django_components/app_settings.py
index 8546109b..1e1520b1 100644
--- a/src/django_components/app_settings.py
+++ b/src/django_components/app_settings.py
@@ -5,7 +5,9 @@ from os import PathLike
from pathlib import Path
from typing import (
TYPE_CHECKING,
+ Any,
Callable,
+ Dict,
Generic,
List,
Literal,
@@ -671,94 +673,140 @@ defaults = ComponentsSettings(
# fmt: on
+# Interface through which we access the settings.
+#
+# This is the only place where we actually access the settings.
+# The settings are merged with defaults, and then validated.
+#
+# The settings are then available through the `app_settings` object.
+#
+# Settings are loaded from Django settings only once, at `apps.py` in `ready()`.
class InternalSettings:
- @property
- def _settings(self) -> ComponentsSettings:
+ def __init__(self, settings: Optional[Dict[str, Any]] = None):
+ self._settings = ComponentsSettings(**settings) if settings else defaults
+
+ def _load_settings(self) -> None:
data = getattr(settings, "COMPONENTS", {})
- return ComponentsSettings(**data) if not isinstance(data, ComponentsSettings) else data
+ components_settings = ComponentsSettings(**data) if not isinstance(data, ComponentsSettings) else data
- @property
- def AUTODISCOVER(self) -> bool:
- return default(self._settings.autodiscover, cast(bool, defaults.autodiscover))
+ # Merge we defaults and otherwise initialize if necessary
- @property
- def CACHE(self) -> Optional[str]:
- return default(self._settings.cache, defaults.cache)
+ # For DIRS setting, we use a getter for the default value, because the default value
+ # uses Django settings, which may not yet be initialized at the time these settings are generated.
+ dirs_default_fn = cast(Dynamic[Sequence[Union[str, Tuple[str, str]]]], defaults.dirs)
+ dirs_default = dirs_default_fn.getter()
- @property
- def DIRS(self) -> Sequence[Union[str, PathLike, Tuple[str, str], Tuple[str, PathLike]]]:
- # For DIRS we use a getter, because default values uses Django settings,
- # which may not yet be initialized at the time these settings are generated.
- default_fn = cast(Dynamic[Sequence[Union[str, Tuple[str, str]]]], defaults.dirs)
- default_dirs = default_fn.getter()
- return default(self._settings.dirs, default_dirs)
+ self._settings = ComponentsSettings(
+ autodiscover=default(components_settings.autodiscover, defaults.autodiscover),
+ cache=default(components_settings.cache, defaults.cache),
+ dirs=default(components_settings.dirs, dirs_default),
+ app_dirs=default(components_settings.app_dirs, defaults.app_dirs),
+ debug_highlight_components=default(
+ components_settings.debug_highlight_components, defaults.debug_highlight_components
+ ),
+ debug_highlight_slots=default(components_settings.debug_highlight_slots, defaults.debug_highlight_slots),
+ dynamic_component_name=default(
+ components_settings.dynamic_component_name, defaults.dynamic_component_name
+ ),
+ libraries=default(components_settings.libraries, defaults.libraries),
+ multiline_tags=default(components_settings.multiline_tags, defaults.multiline_tags),
+ reload_on_file_change=self._prepare_reload_on_file_change(components_settings),
+ template_cache_size=default(components_settings.template_cache_size, defaults.template_cache_size),
+ static_files_allowed=default(components_settings.static_files_allowed, defaults.static_files_allowed),
+ static_files_forbidden=self._prepare_static_files_forbidden(components_settings),
+ context_behavior=self._prepare_context_behavior(components_settings),
+ tag_formatter=default(components_settings.tag_formatter, defaults.tag_formatter), # type: ignore[arg-type]
+ )
- @property
- def APP_DIRS(self) -> Sequence[str]:
- return default(self._settings.app_dirs, cast(List[str], defaults.app_dirs))
-
- @property
- def DEBUG_HIGHLIGHT_COMPONENTS(self) -> bool:
- return default(self._settings.debug_highlight_components, cast(bool, defaults.debug_highlight_components))
-
- @property
- def DEBUG_HIGHLIGHT_SLOTS(self) -> bool:
- return default(self._settings.debug_highlight_slots, cast(bool, defaults.debug_highlight_slots))
-
- @property
- def DYNAMIC_COMPONENT_NAME(self) -> str:
- return default(self._settings.dynamic_component_name, cast(str, defaults.dynamic_component_name))
-
- @property
- def LIBRARIES(self) -> List[str]:
- return default(self._settings.libraries, cast(List[str], defaults.libraries))
-
- @property
- def MULTILINE_TAGS(self) -> bool:
- return default(self._settings.multiline_tags, cast(bool, defaults.multiline_tags))
-
- @property
- def RELOAD_ON_FILE_CHANGE(self) -> bool:
- val = self._settings.reload_on_file_change
+ def _prepare_reload_on_file_change(self, new_settings: ComponentsSettings) -> bool:
+ val = new_settings.reload_on_file_change
# TODO_REMOVE_IN_V1
if val is None:
- val = self._settings.reload_on_template_change
+ val = new_settings.reload_on_template_change
return default(val, cast(bool, defaults.reload_on_file_change))
- @property
- def TEMPLATE_CACHE_SIZE(self) -> int:
- return default(self._settings.template_cache_size, cast(int, defaults.template_cache_size))
-
- @property
- def STATIC_FILES_ALLOWED(self) -> Sequence[Union[str, re.Pattern]]:
- return default(self._settings.static_files_allowed, cast(List[str], defaults.static_files_allowed))
-
- @property
- def STATIC_FILES_FORBIDDEN(self) -> Sequence[Union[str, re.Pattern]]:
- val = self._settings.static_files_forbidden
+ def _prepare_static_files_forbidden(self, new_settings: ComponentsSettings) -> List[Union[str, re.Pattern]]:
+ val = new_settings.static_files_forbidden
# TODO_REMOVE_IN_V1
if val is None:
- val = self._settings.forbidden_static_files
+ val = new_settings.forbidden_static_files
- return default(val, cast(List[str], defaults.static_files_forbidden))
+ return default(val, cast(List[Union[str, re.Pattern]], defaults.static_files_forbidden))
- @property
- def CONTEXT_BEHAVIOR(self) -> ContextBehavior:
- raw_value = cast(str, default(self._settings.context_behavior, defaults.context_behavior))
- return self._validate_context_behavior(raw_value)
-
- def _validate_context_behavior(self, raw_value: Union[ContextBehavior, str]) -> ContextBehavior:
+ def _prepare_context_behavior(self, new_settings: ComponentsSettings) -> Literal["django", "isolated"]:
+ raw_value = cast(
+ Literal["django", "isolated"],
+ default(new_settings.context_behavior, defaults.context_behavior),
+ )
try:
- return ContextBehavior(raw_value)
+ ContextBehavior(raw_value)
except ValueError:
valid_values = [behavior.value for behavior in ContextBehavior]
raise ValueError(f"Invalid context behavior: {raw_value}. Valid options are {valid_values}")
+ return raw_value
+
+ # TODO REMOVE THE PROPERTIES BELOW? THEY NO LONGER SERVE ANY PURPOSE
+ @property
+ def AUTODISCOVER(self) -> bool:
+ return self._settings.autodiscover # type: ignore[return-value]
+
+ @property
+ def CACHE(self) -> Optional[str]:
+ return self._settings.cache
+
+ @property
+ def DIRS(self) -> Sequence[Union[str, PathLike, Tuple[str, str], Tuple[str, PathLike]]]:
+ return self._settings.dirs # type: ignore[return-value]
+
+ @property
+ def APP_DIRS(self) -> Sequence[str]:
+ return self._settings.app_dirs # type: ignore[return-value]
+
+ @property
+ def DEBUG_HIGHLIGHT_COMPONENTS(self) -> bool:
+ return self._settings.debug_highlight_components # type: ignore[return-value]
+
+ @property
+ def DEBUG_HIGHLIGHT_SLOTS(self) -> bool:
+ return self._settings.debug_highlight_slots # type: ignore[return-value]
+
+ @property
+ def DYNAMIC_COMPONENT_NAME(self) -> str:
+ return self._settings.dynamic_component_name # type: ignore[return-value]
+
+ @property
+ def LIBRARIES(self) -> List[str]:
+ return self._settings.libraries # type: ignore[return-value]
+
+ @property
+ def MULTILINE_TAGS(self) -> bool:
+ return self._settings.multiline_tags # type: ignore[return-value]
+
+ @property
+ def RELOAD_ON_FILE_CHANGE(self) -> bool:
+ return self._settings.reload_on_file_change # type: ignore[return-value]
+
+ @property
+ def TEMPLATE_CACHE_SIZE(self) -> int:
+ return self._settings.template_cache_size # type: ignore[return-value]
+
+ @property
+ def STATIC_FILES_ALLOWED(self) -> Sequence[Union[str, re.Pattern]]:
+ return self._settings.static_files_allowed # type: ignore[return-value]
+
+ @property
+ def STATIC_FILES_FORBIDDEN(self) -> Sequence[Union[str, re.Pattern]]:
+ return self._settings.static_files_forbidden # type: ignore[return-value]
+
+ @property
+ def CONTEXT_BEHAVIOR(self) -> ContextBehavior:
+ return ContextBehavior(self._settings.context_behavior)
+
@property
def TAG_FORMATTER(self) -> Union["TagFormatterABC", str]:
- tag_formatter = default(self._settings.tag_formatter, cast(str, defaults.tag_formatter))
- return cast(Union["TagFormatterABC", str], tag_formatter)
+ return self._settings.tag_formatter # type: ignore[return-value]
app_settings = InternalSettings()
diff --git a/src/django_components/apps.py b/src/django_components/apps.py
index 171e7c19..387a9689 100644
--- a/src/django_components/apps.py
+++ b/src/django_components/apps.py
@@ -19,6 +19,8 @@ class ComponentsConfig(AppConfig):
from django_components.components.dynamic import DynamicComponent
from django_components.util.django_monkeypatch import monkeypatch_template_cls
+ app_settings._load_settings()
+
# NOTE: This monkeypatch is applied here, before Django processes any requests.
# To make django-components work with django-debug-toolbar-template-profiler
# See https://github.com/django-components/django-components/discussions/819
diff --git a/src/django_components/autodiscovery.py b/src/django_components/autodiscovery.py
index 433a0fe5..c4c52130 100644
--- a/src/django_components/autodiscovery.py
+++ b/src/django_components/autodiscovery.py
@@ -3,6 +3,12 @@ from typing import Callable, List, Optional
from django_components.util.loader import get_component_files
from django_components.util.logger import logger
+from django_components.util.testing import is_testing
+
+# In tests, we want to capture which modules have been loaded, so we can
+# clean them up between tests. But there's no need to track this in
+# production.
+LOADED_MODULES: List[str] = []
def autodiscover(
@@ -94,4 +100,10 @@ def _import_modules(
logger.debug(f'Importing module "{module_name}"')
importlib.import_module(module_name)
imported_modules.append(module_name)
+
+ # In tests tagged with `@djc_test`, we want to capture the modules that
+ # are imported so we can clean them up between tests.
+ if is_testing():
+ LOADED_MODULES.append(module_name)
+
return imported_modules
diff --git a/src/django_components/component.py b/src/django_components/component.py
index 4ad219b3..23a27f89 100644
--- a/src/django_components/component.py
+++ b/src/django_components/component.py
@@ -1,3 +1,4 @@
+import sys
import types
from collections import deque
from contextlib import contextmanager
@@ -22,6 +23,7 @@ from typing import (
Union,
cast,
)
+from weakref import ReferenceType
from django.core.exceptions import ImproperlyConfigured
from django.forms.widgets import Media as MediaCls
@@ -77,6 +79,7 @@ from django_components.util.logger import trace_component_msg
from django_components.util.misc import gen_id, get_import_path, hash_comp_cls
from django_components.util.template_tag import TagAttr
from django_components.util.validation import validate_typed_dict, validate_typed_tuple
+from django_components.util.weakref import cached_ref
# TODO_REMOVE_IN_V1 - Users should use top-level import instead
# isort: off
@@ -99,6 +102,17 @@ JsDataType = TypeVar("JsDataType", bound=Mapping[str, Any])
CssDataType = TypeVar("CssDataType", bound=Mapping[str, Any])
+# NOTE: `ReferenceType` is NOT a generic pre-3.9
+if sys.version_info >= (3, 9):
+ AllComponents = List[ReferenceType["ComponentRegistry"]]
+else:
+ AllComponents = List[ReferenceType]
+
+
+# Keep track of all the Component classes created, so we can clean up after tests
+ALL_COMPONENTS: AllComponents = []
+
+
@dataclass(frozen=True)
class RenderInput(Generic[ArgsType, KwargsType, SlotsType]):
context: Context
@@ -613,6 +627,8 @@ class Component(
cls._class_hash = hash_comp_cls(cls)
comp_hash_mapping[cls._class_hash] = cls
+ ALL_COMPONENTS.append(cached_ref(cls)) # type: ignore[arg-type]
+
@contextmanager
def _with_metadata(self, item: MetadataItem) -> Generator[None, None, None]:
self._metadata_stack.append(item)
diff --git a/src/django_components/component_media.py b/src/django_components/component_media.py
index c0b21899..8ae6e771 100644
--- a/src/django_components/component_media.py
+++ b/src/django_components/component_media.py
@@ -1,6 +1,7 @@
import os
import sys
from collections import deque
+from copy import copy
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Literal, Optional, Protocol, Tuple, Type, Union, cast
@@ -238,6 +239,13 @@ class ComponentMedia:
f"Received non-null value from both '{inlined_attr}' and '{file_attr}' in"
f" Component {self.comp_cls.__name__}. Only one of the two must be set."
)
+ # Make a copy of the original state, so we can reset it in tests
+ self._original = copy(self)
+
+ # Return ComponentMedia to its original state before the media was resolved
+ def reset(self) -> None:
+ self.__dict__.update(self._original.__dict__)
+ self.resolved = False
# This metaclass is all about one thing - lazily resolving the media files.
diff --git a/src/django_components/component_registry.py b/src/django_components/component_registry.py
index bfe469ea..6a2c4bd4 100644
--- a/src/django_components/component_registry.py
+++ b/src/django_components/component_registry.py
@@ -1,4 +1,6 @@
+import sys
from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Type, Union
+from weakref import ReferenceType, finalize
from django.template import Library
from django.template.base import Parser, Token
@@ -6,6 +8,7 @@ from django.template.base import Parser, Token
from django_components.app_settings import ContextBehaviorType, app_settings
from django_components.library import is_tag_protected, mark_protected_tags, register_tag
from django_components.tag_formatter import TagFormatterABC, get_tag_formatter
+from django_components.util.weakref import cached_ref
if TYPE_CHECKING:
from django_components.component import (
@@ -19,6 +22,13 @@ if TYPE_CHECKING:
)
+# NOTE: `ReferenceType` is NOT a generic pre-3.9
+if sys.version_info >= (3, 9):
+ AllRegistries = List[ReferenceType["ComponentRegistry"]]
+else:
+ AllRegistries = List[ReferenceType]
+
+
class AlreadyRegistered(Exception):
"""
Raised when you try to register a [Component](../api#django_components#Component),
@@ -130,7 +140,7 @@ class InternalRegistrySettings(NamedTuple):
# We keep track of all registries that exist so that, when users want to
# dynamically resolve component name to component class, they would be able
# to search across all registries.
-all_registries: List["ComponentRegistry"] = []
+ALL_REGISTRIES: AllRegistries = []
class ComponentRegistry:
@@ -223,10 +233,19 @@ class ComponentRegistry:
self._registry: Dict[str, ComponentRegistryEntry] = {} # component name -> component_entry mapping
self._tags: Dict[str, Set[str]] = {} # tag -> list[component names]
self._library = library
- self._settings_input = settings
- self._settings: Optional[Callable[[], InternalRegistrySettings]] = None
+ self._settings = settings
- all_registries.append(self)
+ ALL_REGISTRIES.append(cached_ref(self))
+
+ def __del__(self) -> None:
+ # Unregister all components when the registry is deleted
+ self.clear()
+
+ def __copy__(self) -> "ComponentRegistry":
+ new_registry = ComponentRegistry(self.library, self._settings)
+ new_registry._registry = self._registry.copy()
+ new_registry._tags = self._tags.copy()
+ return new_registry
@property
def library(self) -> Library:
@@ -254,40 +273,24 @@ class ComponentRegistry:
"""
[Registry settings](../api#django_components.RegistrySettings) configured for this registry.
"""
- # This is run on subsequent calls
- if self._settings is not None:
- # NOTE: Registry's settings can be a function, so we always take
- # the latest value from Django's settings.
- settings = self._settings()
-
- # First-time initialization
# NOTE: We allow the settings to be given as a getter function
# so the settings can respond to changes.
- # So we wrapp that in our getter, which assigns default values from the settings.
+ if callable(self._settings):
+ settings_input: Optional[RegistrySettings] = self._settings(self)
else:
+ settings_input = self._settings
- def get_settings() -> InternalRegistrySettings:
- if callable(self._settings_input):
- settings_input: Optional[RegistrySettings] = self._settings_input(self)
- else:
- settings_input = self._settings_input
+ if settings_input:
+ context_behavior = settings_input.context_behavior or settings_input.CONTEXT_BEHAVIOR
+ tag_formatter = settings_input.tag_formatter or settings_input.TAG_FORMATTER
+ else:
+ context_behavior = None
+ tag_formatter = None
- if settings_input:
- context_behavior = settings_input.context_behavior or settings_input.CONTEXT_BEHAVIOR
- tag_formatter = settings_input.tag_formatter or settings_input.TAG_FORMATTER
- else:
- context_behavior = None
- tag_formatter = None
-
- return InternalRegistrySettings(
- context_behavior=context_behavior or app_settings.CONTEXT_BEHAVIOR.value,
- tag_formatter=tag_formatter or app_settings.TAG_FORMATTER,
- )
-
- self._settings = get_settings
- settings = self._settings()
-
- return settings
+ return InternalRegistrySettings(
+ context_behavior=context_behavior or app_settings.CONTEXT_BEHAVIOR.value,
+ tag_formatter=tag_formatter or app_settings.TAG_FORMATTER,
+ )
def register(self, name: str, component: Type["Component"]) -> None:
"""
@@ -330,6 +333,9 @@ class ComponentRegistry:
self._registry[name] = entry
+ # If the component class is deleted, unregister it from this registry.
+ finalize(entry.cls, lambda: self.unregister(name) if name in self._registry else None)
+
def unregister(self, name: str) -> None:
"""
Unregister the [`Component`](../api#django_components.Component) class
@@ -360,22 +366,27 @@ class ComponentRegistry:
entry = self._registry[name]
tag = entry.tag
- # Unregister the tag from library if this was the last component using this tag
- # Unlink component from tag
- self._tags[tag].remove(name)
+ # Unregister the tag from library.
+ # If this was the last component using this tag, unlink component from tag.
+ if tag in self._tags:
+ if name in self._tags[tag]:
+ self._tags[tag].remove(name)
- # Cleanup
- is_tag_empty = not len(self._tags[tag])
- if is_tag_empty:
- del self._tags[tag]
+ # Cleanup
+ is_tag_empty = not len(self._tags[tag])
+ if is_tag_empty:
+ self._tags.pop(tag, None)
+ else:
+ is_tag_empty = True
# Only unregister a tag if it's NOT protected
is_protected = is_tag_protected(self.library, tag)
if not is_protected:
# Unregister the tag from library if this was the last component using this tag
if is_tag_empty and tag in self.library.tags:
- del self.library.tags[tag]
+ self.library.tags.pop(tag, None)
+ entry = self._registry[name]
del self._registry[name]
def get(self, name: str) -> Type["Component"]:
diff --git a/src/django_components/components/dynamic.py b/src/django_components/components/dynamic.py
index f12a7bf5..2ade057a 100644
--- a/src/django_components/components/dynamic.py
+++ b/src/django_components/components/dynamic.py
@@ -4,7 +4,7 @@ from typing import Any, Dict, Optional, Type, Union, cast
from django.template import Context, Template
from django_components import Component, ComponentRegistry, NotRegistered, types
-from django_components.component_registry import all_registries
+from django_components.component_registry import ALL_REGISTRIES
class DynamicComponent(Component):
@@ -170,7 +170,11 @@ class DynamicComponent(Component):
component_cls = registry.get(comp_name_or_class)
else:
# Search all registries for the first match
- for reg in all_registries:
+ for reg_ref in ALL_REGISTRIES:
+ reg = reg_ref()
+ if not reg:
+ continue
+
try:
component_cls = reg.get(comp_name_or_class)
break
diff --git a/src/django_components/expression.py b/src/django_components/expression.py
index f2e28606..9afcbc80 100644
--- a/src/django_components/expression.py
+++ b/src/django_components/expression.py
@@ -106,9 +106,16 @@ class StringifiedNode(Node):
def is_aggregate_key(key: str) -> bool:
+ key = key.strip()
# NOTE: If we get a key that starts with `:`, like `:class`, we do not split it.
# This syntax is used by Vue and AlpineJS.
- return ":" in key and not key.startswith(":")
+ return (
+ ":" in key
+ # `:` or `:class` is NOT ok
+ and not key.startswith(":")
+ # `attrs:class` is OK, but `attrs:` is NOT ok
+ and bool(key.split(":", maxsplit=1)[1])
+ )
# A string that must start and end with quotes, and somewhere inside includes
diff --git a/src/django_components/testing.py b/src/django_components/testing.py
new file mode 100644
index 00000000..262daa20
--- /dev/null
+++ b/src/django_components/testing.py
@@ -0,0 +1,9 @@
+# Public API for test-related functionality
+# isort: off
+from django_components.util.testing import djc_test
+
+# isort: on
+
+__all__ = [
+ "djc_test",
+]
diff --git a/src/django_components/util/testing.py b/src/django_components/util/testing.py
new file mode 100644
index 00000000..95389731
--- /dev/null
+++ b/src/django_components/util/testing.py
@@ -0,0 +1,525 @@
+import inspect
+import sys
+from functools import wraps
+from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Set, Tuple, Type, Union
+from unittest.mock import patch
+from weakref import ReferenceType
+
+from django.conf import settings as _django_settings
+from django.template import engines
+from django.test import override_settings
+
+from django_components.component import ALL_COMPONENTS, Component, component_node_subclasses_by_name
+from django_components.component_media import ComponentMedia
+from django_components.component_registry import ALL_REGISTRIES, ComponentRegistry
+
+# NOTE: `ReferenceType` is NOT a generic pre-3.9
+if sys.version_info >= (3, 9):
+ RegistryRef = ReferenceType[ComponentRegistry]
+ RegistriesCopies = List[Tuple[ReferenceType[ComponentRegistry], List[str]]]
+ InitialComponents = List[ReferenceType[Type[Component]]]
+else:
+ RegistriesCopies = List[Tuple[ReferenceType, List[str]]]
+ InitialComponents = List[ReferenceType]
+ RegistryRef = ReferenceType
+
+
+# Whether we're inside a test that was wrapped with `djc_test`.
+# This is used so that we capture which modules we imported only if inside a test.
+IS_TESTING = False
+
+
+def is_testing() -> bool:
+ return IS_TESTING
+
+
+class GenIdPatcher:
+ def __init__(self) -> None:
+ self._gen_id_count = 10599485
+ self._gen_id_patch: Any = None
+
+ # Mock the `generate` function used inside `gen_id` so it returns deterministic IDs
+ def start(self) -> None:
+ # Random number so that the generated IDs are "hex-looking", e.g. a1bc3d
+ self._gen_id_count = 10599485
+
+ def mock_gen_id(*args: Any, **kwargs: Any) -> str:
+ self._gen_id_count += 1
+ return hex(self._gen_id_count)[2:]
+
+ self._gen_id_patch = patch("django_components.util.misc.generate", side_effect=mock_gen_id)
+ self._gen_id_patch.start()
+
+ def stop(self) -> None:
+ if not self._gen_id_patch:
+ return
+
+ self._gen_id_patch.stop()
+ self._gen_id_patch = None
+
+
+class CsrfTokenPatcher:
+ def __init__(self) -> None:
+ self._csrf_token = "predictabletoken"
+ self._csrf_token_patch: Any = None
+
+ def start(self) -> None:
+ self._csrf_token_patch = patch("django.middleware.csrf.get_token", return_value=self._csrf_token)
+ self._csrf_token_patch.start()
+
+ def stop(self) -> None:
+ if self._csrf_token_patch:
+ self._csrf_token_patch.stop()
+ self._csrf_token_patch = None
+
+
+def djc_test(
+ django_settings: Union[Optional[Dict], Callable, Type] = None,
+ components_settings: Optional[Dict] = None,
+ # Input to `@pytest.mark.parametrize`
+ parametrize: Optional[
+ Union[
+ Tuple[
+ # names
+ Sequence[str],
+ # values
+ Sequence[Sequence[Any]],
+ ],
+ Tuple[
+ # names
+ Sequence[str],
+ # values
+ Sequence[Sequence[Any]],
+ # ids
+ Optional[
+ Union[
+ Iterable[Union[None, str, float, int, bool]],
+ Callable[[Any], Optional[object]],
+ ]
+ ],
+ ],
+ ]
+ ] = None,
+) -> Callable:
+ """
+ Decorator for testing components from django-components.
+
+ `@djc_test` manages the global state of django-components, ensuring that each test is properly
+ isolated and that components registered in one test do not affect other tests.
+
+ This decorator can be applied to a function, method, or a class. If applied to a class,
+ it will search for all methods that start with `test_`, and apply the decorator to them.
+ This is applied recursively to nested classes as well.
+
+ Examples:
+
+ Applying to a function:
+ ```python
+ from django_components.testing import djc_test
+
+ @djc_test
+ def test_my_component():
+ @register("my_component")
+ class MyComponent(Component):
+ template = "..."
+ ...
+ ```
+
+ Applying to a class:
+ ```python
+ from django_components.testing import djc_test
+
+ @djc_test
+ class TestMyComponent:
+ def test_something(self):
+ ...
+
+ class Nested:
+ def test_something_else(self):
+ ...
+ ```
+
+ Applying to a class is the same as applying the decorator to each `test_` method individually:
+ ```python
+ from django_components.testing import djc_test
+
+ class TestMyComponent:
+ @djc_test
+ def test_something(self):
+ ...
+
+ class Nested:
+ @djc_test
+ def test_something_else(self):
+ ...
+ ```
+
+ To use `@djc_test`, Django must be set up first:
+
+ ```python
+ import django
+ from django_components.testing import djc_test
+
+ django.setup()
+
+ @djc_test
+ def test_my_component():
+ ...
+ ```
+
+ **Arguments:**
+
+ - `django_settings`: Django settings, a dictionary passed to Django's
+ [`@override_settings`](https://docs.djangoproject.com/en/5.1/topics/testing/tools/#django.test.override_settings).
+ The test runs within the context of these overridden settings.
+
+ If `django_settings` contains django-components settings (`COMPONENTS` field), these are merged.
+ Other Django settings are simply overridden.
+
+ - `components_settings`: Instead of defining django-components settings under `django_settings["COMPONENTS"]`,
+ you can simply set the Components settings here.
+
+ These settings are merged with the django-components settings from `django_settings["COMPONENTS"]`.
+
+ Fields in `components_settings` override fields in `django_settings["COMPONENTS"]`.
+
+ - `parametrize`: Parametrize the test function with
+ [`pytest.mark.parametrize`](https://docs.pytest.org/en/stable/how-to/parametrize.html#pytest-mark-parametrize).
+ This requires [pytest](https://docs.pytest.org/) to be installed.
+
+ The input is a tuple of:
+
+ - `(param_names, param_values)` or
+ - `(param_names, param_values, ids)`
+
+ Example:
+
+ ```py
+ from django_components.testing import djc_test
+
+ @djc_test(
+ parametrize=(
+ ["input", "expected"],
+ [[1, "
1
"], [2, "
2
"]],
+ ids=["1", "2"]
+ )
+ )
+ def test_component(input, expected):
+ rendered = MyComponent(input=input).render()
+ assert rendered == expected
+ ```
+
+ You can parametrize the Django or Components settings by setting up parameters called
+ `django_settings` and `components_settings`. These will be merged with the respetive settings
+ from the decorator.
+
+ Example of parametrizing context_behavior:
+ ```python
+ from django_components.testing import djc_test
+
+ @djc_test(
+ components_settings={
+ # Settings shared by all tests
+ "app_dirs": ["custom_dir"],
+ },
+ parametrize=(
+ # Parametrized settings
+ ["components_settings"],
+ [
+ [{"context_behavior": "django"}],
+ [{"context_behavior": "isolated"}],
+ ],
+ ["django", "isolated"],
+ )
+ )
+ def test_context_behavior(components_settings):
+ rendered = MyComponent().render()
+ ...
+ ```
+
+ **Settings resolution:**
+
+ `@djc_test` accepts settings from different sources. The settings are resolved in the following order:
+
+ - Django settings:
+
+ 1. The defaults are the Django settings that Django was set up with.
+ 2. Those are then overriden with fields in the `django_settings` kwarg.
+ 3. The parametrized `django_settings` override the fields on the `django_settings` kwarg.
+
+ Priority: `django_settings` (parametrized) > `django_settings` > `django.conf.settings`
+
+ - Components settings:
+
+ 1. Same as above, except that the `django_settings["COMPONENTS"]` field is merged instead of overridden.
+ 2. The `components_settings` kwarg is then merged with the `django_settings["COMPONENTS"]` field.
+ 3. The parametrized `components_settings` override the fields on the `components_settings` kwarg.
+
+ Priority: `components_settings` (parametrized) > `components_settings` > `django_settings["COMPONENTS"]` > `django.conf.settings.COMPONENTS`
+ """ # noqa: E501
+
+ def decorator(func: Callable) -> Callable:
+ if isinstance(func, type):
+ # If `djc_test` is applied to a class, we need to apply it to each test method
+ # individually.
+ # The rest of this function addresses `func` being a function
+ decorator = djc_test(
+ # Check for callable in case `@djc_test` was applied without calling it as `djc_test(settings)`.
+ django_settings=django_settings if not callable(django_settings) else None,
+ components_settings=components_settings,
+ parametrize=parametrize,
+ )
+ for name, attr in func.__dict__.items():
+ if isinstance(attr, type):
+ # If the attribute is a class, apply the decorator to its methods:
+ djc_test(
+ django_settings=django_settings if not callable(django_settings) else None,
+ components_settings=components_settings,
+ parametrize=parametrize,
+ )(attr)
+ if callable(attr) and name.startswith("test_"):
+ method = decorator(attr)
+ setattr(func, name, method)
+ return func
+
+ if getattr(func, "_djc_test_wrapped", False):
+ return func
+
+ gen_id_patcher = GenIdPatcher()
+ csrf_token_patcher = CsrfTokenPatcher()
+
+ # Contents of this function will run as the test
+ def _wrapper_impl(*args: Any, **kwargs: Any) -> Any:
+ # Merge the settings
+ current_django_settings = django_settings if not callable(django_settings) else None
+ current_django_settings = current_django_settings.copy() if current_django_settings else {}
+ if parametrize and "django_settings" in kwargs:
+ current_django_settings.update(kwargs["django_settings"])
+
+ current_component_settings = components_settings.copy() if components_settings else {}
+ if parametrize and "components_settings" in kwargs:
+ # We've received a parametrized test function, so we need to
+ # apply the parametrized settings to the test function.
+ current_component_settings.update(kwargs["components_settings"])
+
+ merged_settings = _merge_django_settings(
+ current_django_settings,
+ current_component_settings,
+ )
+
+ with override_settings(**merged_settings):
+ # Make a copy of `ALL_COMPONENTS` and `ALL_REGISTRIES` as they were before the test.
+ # Since the tests require Django to be configured, this should contain any
+ # components that were registered with autodiscovery / at `AppConfig.ready()`.
+ _ALL_COMPONENTS = ALL_COMPONENTS.copy()
+ _ALL_REGISTRIES_COPIES: RegistriesCopies = []
+ for reg_ref in ALL_REGISTRIES:
+ reg = reg_ref()
+ if not reg:
+ continue
+ _ALL_REGISTRIES_COPIES.append((reg_ref, list(reg._registry.keys())))
+
+ # Prepare global state
+ _setup_djc_global_state(gen_id_patcher, csrf_token_patcher)
+
+ def cleanup() -> None:
+ _clear_djc_global_state(
+ gen_id_patcher,
+ csrf_token_patcher,
+ _ALL_COMPONENTS, # type: ignore[arg-type]
+ _ALL_REGISTRIES_COPIES,
+ )
+
+ try:
+ # Execute
+ result = func(*args, **kwargs)
+ except Exception as err:
+ # On failure
+ cleanup()
+ raise err from None
+
+ # On success
+ cleanup()
+ return result
+
+ # Handle async test functions
+ if inspect.iscoroutinefunction(func):
+
+ async def wrapper_outer(*args: Any, **kwargs: Any) -> Any:
+ return await _wrapper_impl(*args, **kwargs)
+
+ else:
+
+ def wrapper_outer(*args: Any, **kwargs: Any) -> Any: # type: ignore[misc]
+ return _wrapper_impl(*args, **kwargs)
+
+ wrapper = wraps(func)(wrapper_outer)
+
+ # Allow to parametrize tests with pytest. This will effectively run the test multiple times,
+ # with the different parameter values, and will display these as separte tests in the report.
+ if parametrize:
+ # We optionally allow to pass in the `ids` kwarg. Since `ids` is kwarg-only,
+ # we can't just spread all tuple elements into the `parametrize` call, but we have
+ # to manually apply it.
+ if len(parametrize) == 3:
+ # @pytest.mark.parametrize("a,b,expected", testdata, ids=["forward", "backward"])
+ param_names, values, ids = parametrize
+ else:
+ # @pytest.mark.parametrize("a,b,expected", testdata)
+ param_names, values = parametrize
+ ids = None
+
+ for value in values:
+ # Validate that the first user-provided element in each value tuple is a dictionary,
+ # since it's meant to be used for overriding django-components settings
+ value_overrides = value[0]
+ if not isinstance(value_overrides, dict):
+ raise ValueError(
+ "The first element in each value tuple in `parametrize`"
+ f"must be a dictionary, but got {value_overrides}"
+ )
+
+ # NOTE: Lazily import pytest, so user can still run tests with plain `unittest`
+ # if they choose not to use parametrization.
+ import pytest
+
+ wrapper = pytest.mark.parametrize(param_names, values, ids=ids)(wrapper)
+
+ wrapper._djc_test_wrapped = True # type: ignore[attr-defined]
+ return wrapper
+
+ # Handle `@djc_test` (no arguments, func passed directly)
+ if callable(django_settings):
+ return decorator(django_settings)
+
+ # Handle `@djc_test(settings)`
+ return decorator
+
+
+# Merge settings such that the fields in the `COMPONENTS` setting are merged.
+def _merge_django_settings(
+ django_settings: Optional[Dict] = None,
+ components_settings: Optional[Dict] = None,
+) -> Dict:
+ merged_settings = {} if not django_settings else django_settings.copy()
+
+ merged_settings["COMPONENTS"] = {
+ # Use the Django settings as they were before the `override_settings`
+ # as the defaults.
+ **(_django_settings.COMPONENTS if _django_settings.configured else {}),
+ **merged_settings.get("COMPONENTS", {}),
+ **(components_settings or {}),
+ }
+
+ return merged_settings
+
+
+def _setup_djc_global_state(
+ gen_id_patcher: GenIdPatcher,
+ csrf_token_patcher: CsrfTokenPatcher,
+) -> None:
+ # Declare that the code is running in test mode - this is used
+ # by the import / autodiscover mechanism to clean up loaded modules
+ # between tests.
+ global IS_TESTING
+ IS_TESTING = True
+
+ gen_id_patcher.start()
+ csrf_token_patcher.start()
+
+ # Re-load the settings, so that the test-specific settings overrides are applied
+ from django_components.app_settings import app_settings
+
+ app_settings._load_settings()
+
+
+def _clear_djc_global_state(
+ gen_id_patcher: GenIdPatcher,
+ csrf_token_patcher: CsrfTokenPatcher,
+ initial_components: InitialComponents,
+ initial_registries_copies: RegistriesCopies,
+) -> None:
+ gen_id_patcher.stop()
+ csrf_token_patcher.stop()
+
+ # Clear loader cache - That is, templates that were loaded with Django's `get_template()`.
+ # This applies to components that had the `template_name` / `template_field` field set.
+ # See https://stackoverflow.com/a/77531127/9788634
+ #
+ # If we don't do this, then, since the templates are cached, the next test might fail
+ # beause the IDs count will reset to 0, but we won't generate IDs for the Nodes of the cached
+ # templates. Thus, the IDs will be out of sync between the tests.
+ for engine in engines.all():
+ engine.engine.template_loaders[0].reset()
+
+ # NOTE: There are 1-2 tests which check Templates, so we need to clear the cache
+ from django_components.cache import component_media_cache, template_cache
+
+ if template_cache:
+ template_cache.clear()
+
+ if component_media_cache:
+ component_media_cache.clear()
+
+ # Remove cached Node subclasses
+ component_node_subclasses_by_name.clear()
+
+ # Clean up any loaded media (HTML, JS, CSS)
+ for comp_cls_ref in ALL_COMPONENTS:
+ comp_cls = comp_cls_ref()
+ if comp_cls is None:
+ continue
+
+ for file_attr, value_attr in [("template_file", "template"), ("js_file", "js"), ("css_file", "css")]:
+ # If both fields are set, then the value was set from the file field.
+ # Since we have some tests that check for these, we need to reset the state.
+ comp_media: ComponentMedia = comp_cls._component_media # type: ignore[attr-defined]
+ if getattr(comp_media, file_attr, None) and getattr(comp_media, value_attr, None):
+ # Remove the value field, so it's not used in the next test
+ setattr(comp_media, value_attr, None)
+ comp_media.reset()
+
+ # Remove components that were created during the test
+ initial_components_set = set(initial_components)
+ all_comps_len = len(ALL_COMPONENTS)
+ for index in range(all_comps_len):
+ reverse_index = all_comps_len - index - 1
+ comp_cls_ref = ALL_COMPONENTS[reverse_index]
+ if comp_cls_ref not in initial_components_set:
+ del ALL_COMPONENTS[reverse_index]
+
+ # Remove registries that were created during the test
+ initial_registries_set: Set[RegistryRef] = set([reg_ref for reg_ref, init_keys in initial_registries_copies])
+ for index in range(len(ALL_REGISTRIES)):
+ registry_ref = ALL_REGISTRIES[len(ALL_REGISTRIES) - index - 1]
+ if registry_ref not in initial_registries_set:
+ del ALL_REGISTRIES[len(ALL_REGISTRIES) - index - 1]
+
+ # For the remaining registries, unregistr components that were registered
+ # during tests.
+ # NOTE: The approach below does NOT take into account:
+ # - If a component was UNregistered during the test
+ # - If a previously-registered component was overwritten with different registration.
+ for reg_ref, init_keys in initial_registries_copies:
+ registry_original = reg_ref()
+ if not registry_original:
+ continue
+
+ # Get the keys that were registered during the test
+ initial_registered_keys = set(init_keys)
+ after_test_registered_keys = set(registry_original._registry.keys())
+ keys_registered_during_test = after_test_registered_keys - initial_registered_keys
+ # Remove them
+ for key in keys_registered_during_test:
+ registry_original.unregister(key)
+
+ # Delete autoimported modules from memory, so the module
+ # is executed also the next time one of the tests calls `autodiscover`.
+ from django_components.autodiscovery import LOADED_MODULES
+
+ for mod in LOADED_MODULES:
+ sys.modules.pop(mod, None)
+ LOADED_MODULES.clear()
+
+ global IS_TESTING
+ IS_TESTING = False
diff --git a/src/django_components/util/weakref.py b/src/django_components/util/weakref.py
new file mode 100644
index 00000000..55a56396
--- /dev/null
+++ b/src/django_components/util/weakref.py
@@ -0,0 +1,29 @@
+import sys
+from typing import Any, Dict, TypeVar, overload
+from weakref import ReferenceType, finalize, ref
+
+GLOBAL_REFS: Dict[Any, ReferenceType] = {}
+
+
+T = TypeVar("T")
+
+# NOTE: `ReferenceType` is NOT a generic pre-3.9
+if sys.version_info >= (3, 9):
+
+ @overload # type: ignore[misc]
+ def cached_ref(obj: T) -> ReferenceType[T]: ... # noqa: E704
+
+
+def cached_ref(obj: Any) -> ReferenceType:
+ """
+ Same as `weakref.ref()`, creating a weak reference to a given objet.
+ But unlike `weakref.ref()`, this function also caches the result,
+ so it returns the same reference for the same object.
+ """
+ if obj not in GLOBAL_REFS:
+ GLOBAL_REFS[obj] = ref(obj)
+
+ # Remove this entry from GLOBAL_REFS when the object is deleted.
+ finalize(obj, lambda: GLOBAL_REFS.pop(obj))
+
+ return GLOBAL_REFS[obj]
diff --git a/tests/django_test_setup.py b/tests/django_test_setup.py
deleted file mode 100644
index 63807704..00000000
--- a/tests/django_test_setup.py
+++ /dev/null
@@ -1,54 +0,0 @@
-from pathlib import Path
-from typing import Dict, Optional
-
-import django
-from django.conf import settings
-
-
-def setup_test_config(
- components: Optional[Dict] = None,
- extra_settings: Optional[Dict] = None,
-):
- if settings.configured:
- return
-
- default_settings = {
- "BASE_DIR": Path(__file__).resolve().parent,
- "INSTALLED_APPS": ("django_components", "tests.test_app"),
- "TEMPLATES": [
- {
- "BACKEND": "django.template.backends.django.DjangoTemplates",
- "DIRS": [
- "tests/templates/",
- "tests/components/", # Required for template relative imports in tests
- ],
- "OPTIONS": {
- "builtins": [
- "django_components.templatetags.component_tags",
- ]
- },
- }
- ],
- "COMPONENTS": {
- "template_cache_size": 128,
- **(components or {}),
- },
- "MIDDLEWARE": ["django_components.middleware.ComponentDependencyMiddleware"],
- "DATABASES": {
- "default": {
- "ENGINE": "django.db.backends.sqlite3",
- "NAME": ":memory:",
- }
- },
- "SECRET_KEY": "secret",
- "ROOT_URLCONF": "django_components.urls",
- }
-
- settings.configure(
- **{
- **default_settings,
- **(extra_settings or {}),
- }
- )
-
- django.setup()
diff --git a/tests/test_attributes.py b/tests/test_attributes.py
index cf106df8..054c7176 100644
--- a/tests/test_attributes.py
+++ b/tests/test_attributes.py
@@ -1,86 +1,66 @@
+import re
+
+import pytest
from django.template import Context, Template, TemplateSyntaxError
from django.utils.safestring import SafeString, mark_safe
+from pytest_django.asserts import assertHTMLEqual
from django_components import Component, register, types
from django_components.attributes import append_attributes, attributes_to_string
+from django_components.testing import djc_test
-from .django_test_setup import setup_test_config
-from .testutils import BaseTestCase, parametrize_context_behavior
+from .testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config
setup_test_config({"autodiscover": False})
-class AttributesToStringTest(BaseTestCase):
+@djc_test
+class TestAttributesToString:
def test_simple_attribute(self):
- self.assertEqual(
- attributes_to_string({"foo": "bar"}),
- 'foo="bar"',
- )
+ assert attributes_to_string({"foo": "bar"}) == 'foo="bar"'
def test_multiple_attributes(self):
- self.assertEqual(
- attributes_to_string({"class": "foo", "style": "color: red;"}),
- 'class="foo" style="color: red;"',
- )
+ assert attributes_to_string({"class": "foo", "style": "color: red;"}) == 'class="foo" style="color: red;"'
def test_escapes_special_characters(self):
- self.assertEqual(
- attributes_to_string({"x-on:click": "bar", "@click": "'baz'"}),
- 'x-on:click="bar" @click="'baz'"',
- )
+ assert attributes_to_string({"x-on:click": "bar", "@click": "'baz'"}) == 'x-on:click="bar" @click="'baz'"' # noqa: E501
def test_does_not_escape_special_characters_if_safe_string(self):
- self.assertEqual(
- attributes_to_string({"foo": mark_safe("'bar'")}),
- "foo=\"'bar'\"",
- )
+ assert attributes_to_string({"foo": mark_safe("'bar'")}) == "foo=\"'bar'\""
def test_result_is_safe_string(self):
result = attributes_to_string({"foo": mark_safe("'bar'")})
- self.assertTrue(isinstance(result, SafeString))
+ assert isinstance(result, SafeString)
def test_attribute_with_no_value(self):
- self.assertEqual(
- attributes_to_string({"required": None}),
- "",
- )
+ assert attributes_to_string({"required": None}) == ""
def test_attribute_with_false_value(self):
- self.assertEqual(
- attributes_to_string({"required": False}),
- "",
- )
+ assert attributes_to_string({"required": False}) == ""
def test_attribute_with_true_value(self):
- self.assertEqual(
- attributes_to_string({"required": True}),
- "required",
- )
+ assert attributes_to_string({"required": True}) == "required"
-class AppendAttributesTest(BaseTestCase):
+@djc_test
+class TestAppendAttributes:
def test_single_dict(self):
- self.assertEqual(
- append_attributes(("foo", "bar")),
- {"foo": "bar"},
- )
+ assert append_attributes(("foo", "bar")) == {"foo": "bar"}
def test_appends_dicts(self):
- self.assertEqual(
- append_attributes(("class", "foo"), ("id", "bar"), ("class", "baz")),
- {"class": "foo baz", "id": "bar"},
- )
+ assert append_attributes(("class", "foo"), ("id", "bar"), ("class", "baz")) == {"class": "foo baz", "id": "bar"} # noqa: E501
-class HtmlAttrsTests(BaseTestCase):
+@djc_test
+class TestHtmlAttrs:
template_str: types.django_html = """
{% load component_tags %}
{% component "test" attrs:@click.stop="dispatch('click_event')" attrs:x-data="{hello: 'world'}" attrs:class=class_var %}
{% endcomponent %}
""" # noqa: E501
- @parametrize_context_behavior(["django", "isolated"])
- def test_tag_positional_args(self):
+ @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
+ def test_tag_positional_args(self, components_settings):
@register("test")
class AttrsComponent(Component):
template: types.django_html = """
@@ -98,7 +78,7 @@ class HtmlAttrsTests(BaseTestCase):
template = Template(self.template_str)
rendered = template.render(Context({"class_var": "padding-top-8"}))
- self.assertHTMLEqual(
+ assertHTMLEqual(
rendered,
"""
@@ -106,7 +86,7 @@ class HtmlAttrsTests(BaseTestCase):
""", # noqa: E501
)
- self.assertNotIn("override-me", rendered)
+ assert "override-me" not in rendered
def test_tag_raises_on_extra_positional_args(self):
@register("test")
@@ -127,8 +107,9 @@ class HtmlAttrsTests(BaseTestCase):
template = Template(self.template_str)
- with self.assertRaisesMessage(
- TypeError, "Invalid parameters for tag 'html_attrs': takes 2 positional argument(s) but more were given"
+ with pytest.raises(
+ TypeError,
+ match=re.escape("Invalid parameters for tag 'html_attrs': takes 2 positional argument(s) but more were given"), # noqa: E501
):
template.render(Context({"class_var": "padding-top-8"}))
@@ -150,7 +131,7 @@ class HtmlAttrsTests(BaseTestCase):
template = Template(self.template_str)
rendered = template.render(Context({"class_var": "padding-top-8"}))
- self.assertHTMLEqual(
+ assertHTMLEqual(
rendered,
"""
@@ -158,7 +139,7 @@ class HtmlAttrsTests(BaseTestCase):
@@ -218,7 +199,7 @@ class HtmlAttrsTests(BaseTestCase):
""", # noqa: E501
)
- self.assertNotIn("override-me", rendered)
+ assert "override-me" not in rendered
def test_tag_aggregate_args(self):
@register("test")
@@ -237,7 +218,7 @@ class HtmlAttrsTests(BaseTestCase):
rendered = template.render(Context({"class_var": "padding-top-8"}))
# NOTE: The attrs from self.template_str should be ignored because they are not used.
- self.assertHTMLEqual(
+ assertHTMLEqual(
rendered,
"""
@@ -245,7 +226,7 @@ class HtmlAttrsTests(BaseTestCase):
""", # noqa: E501
)
- self.assertNotIn("override-me", rendered)
+ assert "override-me" not in rendered
# Note: Because there's both `attrs:class` and `defaults:class`, the `attrs`,
# it's as if the template tag call was (ignoring the `class` and `data-id` attrs):
@@ -268,8 +249,9 @@ class HtmlAttrsTests(BaseTestCase):
template = Template(self.template_str)
- with self.assertRaisesMessage(
- TypeError, "Invalid parameters for tag 'html_attrs': got multiple values for argument 'attrs'"
+ with pytest.raises(
+ TypeError,
+ match=re.escape("Invalid parameters for tag 'html_attrs': got multiple values for argument 'attrs'"),
):
template.render(Context({"class_var": "padding-top-8"}))
@@ -295,9 +277,9 @@ class HtmlAttrsTests(BaseTestCase):
template = Template(self.template_str)
- with self.assertRaisesMessage(
+ with pytest.raises(
TemplateSyntaxError,
- "Received argument 'defaults' both as a regular input",
+ match=re.escape("Received argument 'defaults' both as a regular input"),
):
template.render(Context({"class_var": "padding-top-8"}))
@@ -316,7 +298,7 @@ class HtmlAttrsTests(BaseTestCase):
template = Template(self.template_str)
rendered = template.render(Context({"class_var": "padding-top-8"}))
- self.assertHTMLEqual(
+ assertHTMLEqual(
rendered,
"""
"
@@ -14,12 +15,12 @@ class ComponentHighlightTests(BaseTestCase):
result = apply_component_highlight("component", test_html, component_name)
# Check that the output contains the component name
- self.assertIn(component_name, result)
+ assert component_name in result
# Check that the output contains the original HTML
- self.assertIn(test_html, result)
+ assert test_html in result
# Check that the component colors are used
- self.assertIn(COLORS["component"].text_color, result)
- self.assertIn(COLORS["component"].border_color, result)
+ assert COLORS["component"].text_color in result
+ assert COLORS["component"].border_color in result
def test_slot_highlight(self):
# Test slot highlighting
@@ -28,9 +29,9 @@ class ComponentHighlightTests(BaseTestCase):
result = apply_component_highlight("slot", test_html, slot_name)
# Check that the output contains the slot name
- self.assertIn(slot_name, result)
+ assert slot_name in result
# Check that the output contains the original HTML
- self.assertIn(test_html, result)
+ assert test_html in result
# Check that the slot colors are used
- self.assertIn(COLORS["slot"].text_color, result)
- self.assertIn(COLORS["slot"].border_color, result)
+ assert COLORS["slot"].text_color in result
+ assert COLORS["slot"].border_color in result
diff --git a/tests/test_component_media.py b/tests/test_component_media.py
index 24201f4f..6fb2caa1 100644
--- a/tests/test_component_media.py
+++ b/tests/test_component_media.py
@@ -1,83 +1,72 @@
import os
import sys
from pathlib import Path
+from textwrap import dedent
from django.forms.widgets import Media
from django.template import Context, Template
from django.templatetags.static import static
-from django.test import override_settings
from django.utils.html import format_html, html_safe
from django.utils.safestring import mark_safe
+from pytest_django.asserts import assertHTMLEqual, assertInHTML
-from django_components import Component, registry, render_dependencies, types
+from django_components import Component, autodiscover, registry, render_dependencies, types
-from .django_test_setup import setup_test_config
-from .testutils import BaseTestCase, autodiscover_with_cleanup
+from django_components.testing import djc_test
+from .testutils import setup_test_config
setup_test_config({"autodiscover": False})
# "Main media" refer to the HTML, JS, and CSS set on the Component class itself
# (as opposed via the `Media` class). These have special handling in the Component.
-class MainMediaTest(BaseTestCase):
+@djc_test
+class TestMainMedia:
def test_html_js_css_inlined(self):
class TestComponent(Component):
- template = """
+ template = dedent("""
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}