mirror of
https://github.com/django-components/django-components.git
synced 2025-10-02 18:31:12 +00:00
feat: add decorator for writing component tests (#1008)
* feat: add decorator for writing component tests * refactor: udpate changelog + update deps pins * refactor: fix deps * refactor: make cached_ref into generic and fix linter errors * refactor: fix coverage testing * refactor: use global var instead of env var for is_testing state
This commit is contained in:
parent
81ac59f7fb
commit
7dfcb447c4
62 changed files with 4428 additions and 3661 deletions
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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"]:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
9
src/django_components/testing.py
Normal file
9
src/django_components/testing.py
Normal file
|
@ -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",
|
||||
]
|
525
src/django_components/util/testing.py
Normal file
525
src/django_components/util/testing.py
Normal file
|
@ -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, "<div>1</div>"], [2, "<div>2</div>"]],
|
||||
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
|
29
src/django_components/util/weakref.py
Normal file
29
src/django_components/util/weakref.py
Normal file
|
@ -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]
|
Loading…
Add table
Add a link
Reference in a new issue