feat: paths as objects + user-provided Media cls + handle static (#526)

Co-authored-by: Emil Stenström <emil@emilstenstrom.se>
This commit is contained in:
Juro Oravec 2024-06-21 19:36:53 +02:00 committed by GitHub
parent 1d0d960211
commit 3c5a7ad823
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1106 additions and 146 deletions

219
README.md
View file

@ -35,7 +35,8 @@ Read on to learn about the details!
- [Rendering HTML attributes](#rendering-html-attributes) - [Rendering HTML attributes](#rendering-html-attributes)
- [Prop drilling and dependency injection (provide / inject)](#prop-drilling-and-dependency-injection-provide--inject) - [Prop drilling and dependency injection (provide / inject)](#prop-drilling-and-dependency-injection-provide--inject)
- [Component context and scope](#component-context-and-scope) - [Component context and scope](#component-context-and-scope)
- [Rendering JS and CSS dependencies](#rendering-js-and-css-dependencies) - [Defining HTML/JS/CSS files](#defining-htmljscss-files)
- [Rendering JS/CSS dependencies](#rendering-jscss-dependencies)
- [Available settings](#available-settings) - [Available settings](#available-settings)
- [Logging and debugging](#logging-and-debugging) - [Logging and debugging](#logging-and-debugging)
- [Management Command](#management-command) - [Management Command](#management-command)
@ -262,9 +263,12 @@ from django_components import component
@component.register("calendar") @component.register("calendar")
class Calendar(component.Component): class Calendar(component.Component):
# Templates inside `[your apps]/components` dir and `[project root]/components` dir will be automatically found. To customize which template to use based on context # Templates inside `[your apps]/components` dir and `[project root]/components` dir
# you can override def get_template_name() instead of specifying the below variable. # will be automatically found. To customize which template to use based on context
template_name = "calendar/template.html" # you can override method `get_template_name` instead of specifying `template_name`.
#
# `template_name` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS
template_name = "template.html"
# This component takes one parameter, a date string to show in the template # This component takes one parameter, a date string to show in the template
def get_context_data(self, date): def get_context_data(self, date):
@ -272,9 +276,10 @@ class Calendar(component.Component):
"date": date, "date": date,
} }
# Both `css` and `js` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS
class Media: class Media:
css = "calendar/style.css" css = "style.css"
js = "calendar/script.js" js = "script.js"
``` ```
And voilá!! We've created our first component. And voilá!! We've created our first component.
@ -1645,7 +1650,207 @@ If you find yourself using the `only` modifier often, you can set the [context_b
Components can also access the outer context in their context methods like `get_context_data` by accessing the property `self.outer_context`. Components can also access the outer context in their context methods like `get_context_data` by accessing the property `self.outer_context`.
## Rendering JS and CSS dependencies ## Defining HTML/JS/CSS files
django_component's management of files builds on top of [Django's `Media` class](https://docs.djangoproject.com/en/5.0/topics/forms/media/).
To be familiar with how Django handles static files, we recommend reading also:
- [How to manage static files (e.g. images, JavaScript, CSS)](https://docs.djangoproject.com/en/5.0/howto/static-files/)
### Defining file paths relative to component or static dirs
As seen in the [getting started example](#create-your-first-component), to associate HTML/JS/CSS
files with a component, you set them as `template_name`, `Media.js` and `Media.css` respectively:
```py
# In a file [project root]/components/calendar/calendar.py
from django_components import component
@component.register("calendar")
class Calendar(component.Component):
template_name = "template.html"
class Media:
css = "style.css"
js = "script.js"
```
In the example above, the files are defined relative to the directory where `component.py` is.
Alternatively, you can specify the file paths relative to the directories set in `STATICFILES_DIRS`.
Assuming that `STATICFILES_DIRS` contains path `[project root]/components`, we can rewrite the example as:
```py
# In a file [project root]/components/calendar/calendar.py
from django_components import component
@component.register("calendar")
class Calendar(component.Component):
template_name = "calendar/template.html"
class Media:
css = "calendar/style.css"
js = "calendar/script.js"
```
NOTE: In case of conflict, the preference goes to resolving the files relative to the component's directory.
### Defining multiple paths
Each component can have only a single template. However, you can define as many JS or CSS files as you want using a list.
```py
class MyComponent(component.Component):
class Media:
js = ["path/to/script1.js", "path/to/script2.js"]
css = ["path/to/style1.css", "path/to/style2.css"]
```
### Configuring CSS Media Types
You can define which stylesheets will be associated with which
[CSS Media types](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_media_queries/Using_media_queries#targeting_media_types). You do so by defining CSS files as a dictionary.
See the corresponding [Django Documentation](https://docs.djangoproject.com/en/5.0/topics/forms/media/#css).
Again, you can set either a single file or a list of files per media type:
```py
class MyComponent(component.Component):
class Media:
css = {
"all": "path/to/style1.css",
"print": "path/to/style2.css",
}
```
```py
class MyComponent(component.Component):
class Media:
css = {
"all": ["path/to/style1.css", "path/to/style2.css"],
"print": ["path/to/style3.css", "path/to/style4.css"],
}
```
NOTE: When you define CSS as a string or a list, the `all` media type is implied.
### Supported types for file paths
File paths can be any of:
- `str`
- `bytes`
- `PathLike` (`__fspath__` method)
- `SafeData` (`__html__` method)
- `Callable` that returns any of the above, evaluated at class creation (`__new__`)
```py
from pathlib import Path
from django.utils.safestring import mark_safe
class SimpleComponent(component.Component):
class Media:
css = [
mark_safe('<link href="/static/calendar/style.css" rel="stylesheet" />'),
Path("calendar/style1.css"),
"calendar/style2.css",
b"calendar/style3.css",
lambda: "calendar/style4.css",
]
js = [
mark_safe('<script src="/static/calendar/script.js"></script>'),
Path("calendar/script1.js"),
"calendar/script2.js",
b"calendar/script3.js",
lambda: "calendar/script4.js",
]
```
### Path as objects
In the example [above](#supported-types-for-file-paths), you could see that when we used `mark_safe` to mark a string as a `SafeString`, we had to define the full `<script>`/`<link>` tag.
This is an extension of Django's [Paths as objects](https://docs.djangoproject.com/en/5.0/topics/forms/media/#paths-as-objects) feature, where "safe" strings are taken as is, and accessed only at render time.
Because of that, the paths defined as "safe" strings are NEVER resolved, neither relative to component's directory, nor relative to `STATICFILES_DIRS`.
"Safe" strings can be used to lazily resolve a path, or to customize the `<script>` or `<link>` tag for individual paths:
```py
class LazyJsPath:
def __init__(self, static_path: str) -> None:
self.static_path = static_path
def __html__(self):
full_path = static(self.static_path)
return format_html(
f'<script type="module" src="{full_path}"></script>'
)
@component.register("calendar")
class Calendar(component.Component):
template_name = "calendar/template.html"
def get_context_data(self, date):
return {
"date": date,
}
class Media:
css = "calendar/style.css"
js = [
# <script> tag constructed by Media class
"calendar/script1.js",
# Custom <script> tag
LazyJsPath("calendar/script2.js"),
]
```
### Customize how paths are rendered into HTML tags with `media_class`
Sometimes you may need to change how all CSS `<link>` or JS `<script>` tags are rendered for a given component. You can achieve this by providing your own subclass of [Django's `Media` class](https://docs.djangoproject.com/en/5.0/topics/forms/media) to component's `media_class` attribute.
Normally, the JS and CSS paths are passed to `Media` class, which decides how the paths are resolved and how the `<link>` and `<script>` tags are constructed.
To change how the tags are constructed, you can override the [`Media.render_js` and `Media.render_css` methods](https://github.com/django/django/blob/fa7848146738a9fe1d415ee4808664e54739eeb7/django/forms/widgets.py#L102):
```py
from django.forms.widgets import Media
from django_components import component
class MyMedia(Media):
# Same as original Media.render_js, except
# the `<script>` tag has also `type="module"`
def render_js(self):
tags = []
for path in self._js:
if hasattr(path, "__html__"):
tag = path.__html__()
else:
tag = format_html(
'<script type="module" src="{}"></script>',
self.absolute_path(path)
)
return tags
@component.register("calendar")
class Calendar(component.Component):
template_name = "calendar/template.html"
class Media:
css = "calendar/style.css"
js = "calendar/script.js"
# Override the behavior of Media class
media_class = MyMedia
```
NOTE: The instance of the `Media` class (or it's subclass) is available under `Component.media` after the class creation (`__new__`).
## Rendering JS/CSS dependencies
The JS and CSS files included in components are not automatically rendered. The JS and CSS files included in components are not automatically rendered.
Instead, use the following tags to specify where to render the dependencies: Instead, use the following tags to specify where to render the dependencies:

View file

@ -1,12 +1,9 @@
import inspect import inspect
import os
import sys
import types import types
from pathlib import Path from typing import Any, ClassVar, Dict, List, Mapping, Optional, Tuple, Type, Union
from typing import Any, ClassVar, Dict, List, Mapping, MutableMapping, Optional, Tuple, Type, Union
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.forms.widgets import Media, MediaDefiningClass from django.forms.widgets import Media
from django.http import HttpResponse from django.http import HttpResponse
from django.template.base import FilterExpression, Node, NodeList, Template, TextNode from django.template.base import FilterExpression, Node, NodeList, Template, TextNode
from django.template.context import Context from django.template.context import Context
@ -17,6 +14,8 @@ from django.utils.html import escape
from django.utils.safestring import SafeString, mark_safe from django.utils.safestring import SafeString, mark_safe
from django.views import View from django.views import View
from django_components.component_media import ComponentMediaInput, MediaMeta
# Global registry var and register() function moved to separate module. # Global registry var and register() function moved to separate module.
# Defining them here made little sense, since 1) component_tags.py and component.py # Defining them here made little sense, since 1) component_tags.py and component.py
# rely on them equally, and 2) it made it difficult to avoid circularity in the # rely on them equally, and 2) it made it difficult to avoid circularity in the
@ -35,7 +34,7 @@ from django_components.context import (
prepare_context, prepare_context,
) )
from django_components.expression import safe_resolve_dict, safe_resolve_list from django_components.expression import safe_resolve_dict, safe_resolve_list
from django_components.logger import logger, trace_msg from django_components.logger import trace_msg
from django_components.middleware import is_dependency_middleware_active from django_components.middleware import is_dependency_middleware_active
from django_components.slots import ( from django_components.slots import (
DEFAULT_SLOT_KEY, DEFAULT_SLOT_KEY,
@ -49,145 +48,22 @@ from django_components.slots import (
resolve_slots, resolve_slots,
) )
from django_components.template_parser import process_aggregate_kwargs from django_components.template_parser import process_aggregate_kwargs
from django_components.utils import gen_id, search from django_components.utils import gen_id
RENDERED_COMMENT_TEMPLATE = "<!-- _RENDERED {name} -->" RENDERED_COMMENT_TEMPLATE = "<!-- _RENDERED {name} -->"
class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass): class ComponentMeta(MediaMeta):
def __new__(mcs, name: str, bases: Tuple[Type, ...], attrs: Dict[str, Any]) -> Type: def __new__(mcs, name: str, bases: Tuple[Type, ...], attrs: Dict[str, Any]) -> Type:
# NOTE: Skip template/media file resolution when then Component class ITSELF # NOTE: Skip template/media file resolution when then Component class ITSELF
# is being created. # is being created.
if "__module__" in attrs and attrs["__module__"] == "django_components.component": if "__module__" in attrs and attrs["__module__"] == "django_components.component":
return super().__new__(mcs, name, bases, attrs) return super().__new__(mcs, name, bases, attrs)
if "Media" in attrs:
media: Component.Media = attrs["Media"]
# Allow: class Media: css = "style.css"
if hasattr(media, "css") and isinstance(media.css, str):
media.css = [media.css]
# Allow: class Media: css = ["style.css"]
if hasattr(media, "css") and isinstance(media.css, list):
media.css = {"all": media.css}
# Allow: class Media: css = {"all": "style.css"}
if hasattr(media, "css") and isinstance(media.css, dict):
for media_type, path_list in media.css.items():
if isinstance(path_list, str):
media.css[media_type] = [path_list] # type: ignore
# Allow: class Media: js = "script.js"
if hasattr(media, "js") and isinstance(media.js, str):
media.js = [media.js]
_resolve_component_relative_files(attrs)
return super().__new__(mcs, name, bases, attrs) return super().__new__(mcs, name, bases, attrs)
def _resolve_component_relative_files(attrs: MutableMapping) -> None: class Component(View, metaclass=ComponentMeta):
"""
Check if component's HTML, JS and CSS files refer to files in the same directory
as the component class. If so, modify the attributes so the class Django's rendering
will pick up these files correctly.
"""
component_name = attrs["__qualname__"]
# Derive the full path of the file where the component was defined
module_name = attrs["__module__"]
module_obj = sys.modules[module_name]
file_path = module_obj.__file__
if not file_path:
logger.debug(
f"Could not resolve the path to the file for component '{component_name}'."
" Paths for HTML, JS or CSS templates will NOT be resolved relative to the component file."
)
return
# Prepare all possible directories we need to check when searching for
# component's template and media files
components_dirs = search().searched_dirs
# Get the directory where the component class is defined
try:
comp_dir_abs, comp_dir_rel = _get_dir_path_from_component_path(file_path, components_dirs)
except RuntimeError:
# If no dir was found, we assume that the path is NOT relative to the component dir
logger.debug(
f"No component directory found for component '{component_name}' in {file_path}"
" If this component defines HTML, JS or CSS templates relatively to the component file,"
" then check that the component's directory is accessible from one of the paths"
" specified in the Django's 'STATICFILES_DIRS' settings."
)
return
# Check if filepath refers to a file that's in the same directory as the component class.
# If yes, modify the path to refer to the relative file.
# If not, don't modify anything.
def resolve_file(filepath: str) -> str:
maybe_resolved_filepath = os.path.join(comp_dir_abs, filepath)
component_import_filepath = os.path.join(comp_dir_rel, filepath)
if os.path.isfile(maybe_resolved_filepath):
logger.debug(
f"Interpreting template '{filepath}' of component '{module_name}' relatively to component file"
)
return component_import_filepath
logger.debug(
f"Interpreting template '{filepath}' of component '{module_name}' relatively to components directory"
)
return filepath
# Check if template name is a local file or not
if "template_name" in attrs and attrs["template_name"]:
attrs["template_name"] = resolve_file(attrs["template_name"])
if "Media" in attrs:
media = attrs["Media"]
# Now check the same for CSS files
if hasattr(media, "css") and isinstance(media.css, dict):
for media_type, path_list in media.css.items():
media.css[media_type] = [resolve_file(filepath) for filepath in path_list]
# And JS
if hasattr(media, "js") and isinstance(media.js, list):
media.js = [resolve_file(filepath) for filepath in media.js]
def _get_dir_path_from_component_path(
abs_component_file_path: str,
candidate_dirs: Union[List[str], List[Path]],
) -> Tuple[str, str]:
comp_dir_path_abs = os.path.dirname(abs_component_file_path)
# From all dirs defined in settings.STATICFILES_DIRS, find one that's the parent
# to the component file.
root_dir_abs = None
for candidate_dir in candidate_dirs:
candidate_dir_abs = os.path.abspath(candidate_dir)
if comp_dir_path_abs.startswith(candidate_dir_abs):
root_dir_abs = candidate_dir_abs
break
if root_dir_abs is None:
raise RuntimeError(
f"Failed to resolve template directory for component file '{abs_component_file_path}'",
)
# Derive the path from matched STATICFILES_DIRS to the dir where the current component file is.
comp_dir_path_rel = os.path.relpath(comp_dir_path_abs, candidate_dir_abs)
# Return both absolute and relative paths:
# - Absolute path is used to check if the file exists
# - Relative path is used for defining the import on the component class
return comp_dir_path_abs, comp_dir_path_rel
class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
# Either template_name or template must be set on subclass OR subclass must implement get_template() with # Either template_name or template must be set on subclass OR subclass must implement get_template() with
# non-null return. # non-null return.
_class_hash: ClassVar[int] _class_hash: ClassVar[int]
@ -206,15 +82,13 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
NOTE: This field is generated from Component.Media class. NOTE: This field is generated from Component.Media class.
""" """
media_class: Media = Media
response_class = HttpResponse response_class = HttpResponse
"""This allows to configure what class is used to generate response from `render_to_response`""" """This allows to configure what class is used to generate response from `render_to_response`"""
class Media: Media = ComponentMediaInput
"""Defines JS and CSS media files associated with this component.""" """Defines JS and CSS media files associated with this component."""
css: Optional[Union[str, List[str], Dict[str, str], Dict[str, List[str]]]] = None
js: Optional[Union[str, List[str]]] = None
def __init__( def __init__(
self, self,
registered_name: Optional[str] = None, registered_name: Optional[str] = None,

View file

@ -0,0 +1,350 @@
import os
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Dict, List, MutableMapping, Optional, Tuple, Type, Union
from django.forms.widgets import Media, MediaDefiningClass
from django.utils.safestring import SafeData
from django_components.logger import logger
from django_components.utils import search
if TYPE_CHECKING:
from django_components.component import Component
class ComponentMediaInput:
"""Defines JS and CSS media files associated with this component."""
css: Optional[Union[str, List[str], Dict[str, str], Dict[str, List[str]]]] = None
js: Optional[Union[str, List[str]]] = None
class MediaMeta(MediaDefiningClass):
"""
Metaclass for handling media files for components.
Similar to `MediaDefiningClass`, this class supports the use of `Media` attribute
to define associated JS/CSS files, which are then available under `media`
attribute as a instance of `Media` class.
This subclass has following changes:
### 1. Support for multiple interfaces of JS/CSS
1. As plain strings
```py
class MyComponent(component.Component):
class Media:
js = "path/to/script.js"
css = "path/to/style.css"
```
2. As lists
```py
class MyComponent(component.Component):
class Media:
js = ["path/to/script1.js", "path/to/script2.js"]
css = ["path/to/style1.css", "path/to/style2.css"]
```
3. [CSS ONLY] Dicts of strings
```py
class MyComponent(component.Component):
class Media:
css = {
"all": "path/to/style1.css",
"print": "path/to/style2.css",
}
```
4. [CSS ONLY] Dicts of lists
```py
class MyComponent(component.Component):
class Media:
css = {
"all": ["path/to/style1.css"],
"print": ["path/to/style2.css"],
}
```
### 2. Media are first resolved relative to class definition file
E.g. if in a directory `my_comp` you have `script.js` and `my_comp.py`,
and `my_comp.py` looks like this:
```py
class MyComponent(component.Component):
class Media:
js = "script.js"
```
Then `script.js` will be resolved as `my_comp/script.js`.
### 3. Media can be defined as str, bytes, PathLike, SafeString, or function of thereof
E.g.:
```py
def lazy_eval_css():
# do something
return path
class MyComponent(component.Component):
class Media:
js = b"script.js"
css = lazy_eval_css
```
### 4. Subclass `Media` class with `media_class`
Normal `MediaDefiningClass` creates an instance of `Media` class under the `media` attribute.
This class allows to override which class will be instantiated with `media_class` attribute:
```py
class MyMedia(Media):
def render_js(self):
...
class MyComponent(component.Component):
media_class = MyMedia
def get_context_data(self):
assert isinstance(self.media, MyMedia)
```
"""
def __new__(mcs, name: str, bases: Tuple[Type, ...], attrs: Dict[str, Any]) -> Type:
if "Media" in attrs:
media_data: ComponentMediaInput = attrs["Media"]
# Normalize the various forms of Media inputs we allow
_normalize_media(media_data)
# Given a predictable structure of Media class, get all the various JS/CSS paths
# that user has defined, and normalize them too.
#
# Because we can accept:
# str, bytes, PathLike, SafeData (AKA Django's "path as object") or a callable
#
# And we want to convert that to:
# str and SafeData
_map_media_filepaths(media_data, _normalize_media_filepath)
# Once the inputs are normalized, attempt to resolve the JS/CSS filepaths
# as relative to the directory where the component class is defined.
_resolve_component_relative_files(attrs)
# Since we're inheriting from `MediaDefiningClass`, it should take the inputs
# from `cls.Media`, and set the `cls.media` to an instance of Django's `Media` class
cls = super().__new__(mcs, name, bases, attrs)
# Lastly, if the class defines `media_class` attribute, transform `cls.media`
# to the instance of `media_class`.
_monkeypatch_media_property(cls)
return cls
# Allow users to provide custom subclasses of Media via `media_class`.
# `MediaDefiningClass` defines `media` as a getter (defined in django.forms.widgets.media_property).
# So we reused that and convert it to user-defined Media class
def _monkeypatch_media_property(comp_cls: Type["Component"]) -> None:
if not hasattr(comp_cls, "media_class"):
return
media_prop: property = comp_cls.media
media_getter = media_prop.fget
def media_wrapper(self: "Component") -> Any:
if not media_getter:
return None
media: Media = media_getter(self)
return self.media_class(js=media._js, css=media._css)
comp_cls.media = property(media_wrapper)
def _normalize_media(media: ComponentMediaInput) -> None:
if hasattr(media, "css") and media.css:
# Allow: class Media: css = "style.css"
if _is_media_filepath(media.css):
media.css = [media.css] # type: ignore[list-item]
# Allow: class Media: css = ["style.css"]
if isinstance(media.css, (list, tuple)):
media.css = {"all": media.css}
# Allow: class Media: css = {"all": "style.css"}
if isinstance(media.css, dict):
for media_type, path_list in media.css.items():
if _is_media_filepath(path_list):
media.css[media_type] = [path_list] # type: ignore
if hasattr(media, "js") and media.js:
# Allow: class Media: js = "script.js"
if _is_media_filepath(media.js):
media.js = [media.js] # type: ignore[list-item]
def _map_media_filepaths(media: ComponentMediaInput, map_fn: Callable[[Any], Any]) -> None:
if hasattr(media, "css") and media.css:
if not isinstance(media.css, dict):
raise ValueError(f"Media.css must be a dict, got {type(media.css)}")
for media_type, path_list in media.css.items():
media.css[media_type] = list(map(map_fn, path_list)) # type: ignore[assignment]
if hasattr(media, "js") and media.js:
if not isinstance(media.js, (list, tuple)):
raise ValueError(f"Media.css must be a list, got {type(media.css)}")
media.js = list(map(map_fn, media.js))
def _is_media_filepath(filepath: Any) -> bool:
if callable(filepath):
return True
if isinstance(filepath, SafeData) or hasattr(filepath, "__html__"):
return True
elif isinstance(filepath, (Path, os.PathLike)) or hasattr(filepath, "__fspath__"):
return True
if isinstance(filepath, bytes):
return True
if isinstance(filepath, str):
return True
return False
def _normalize_media_filepath(filepath: Any) -> Union[str, SafeData]:
if callable(filepath):
filepath = filepath()
if isinstance(filepath, SafeData) or hasattr(filepath, "__html__"):
return filepath
if isinstance(filepath, (Path, os.PathLike)) or hasattr(filepath, "__fspath__"):
filepath = filepath.__fspath__()
if isinstance(filepath, bytes):
filepath = filepath.decode("utf-8")
if isinstance(filepath, str):
return filepath
raise ValueError(
"Unknown filepath. Must be str, bytes, PathLike, SafeString, or a function that returns one of the former"
)
def _resolve_component_relative_files(attrs: MutableMapping) -> None:
"""
Check if component's HTML, JS and CSS files refer to files in the same directory
as the component class. If so, modify the attributes so the class Django's rendering
will pick up these files correctly.
"""
# First check if we even need to resolve anything. If the class doesn't define any
# JS/CSS files, just skip.
will_resolve_files = False
if attrs.get("template_name", None):
will_resolve_files = True
if not will_resolve_files and "Media" in attrs:
media: ComponentMediaInput = attrs["Media"]
if getattr(media, "css", None) or getattr(media, "js", None):
will_resolve_files = True
if not will_resolve_files:
return
component_name = attrs["__qualname__"]
# Derive the full path of the file where the component was defined
module_name = attrs["__module__"]
module_obj = sys.modules[module_name]
file_path = module_obj.__file__
if not file_path:
logger.debug(
f"Could not resolve the path to the file for component '{component_name}'."
" Paths for HTML, JS or CSS templates will NOT be resolved relative to the component file."
)
return
# Prepare all possible directories we need to check when searching for
# component's template and media files
components_dirs = search().searched_dirs
# Get the directory where the component class is defined
try:
comp_dir_abs, comp_dir_rel = _get_dir_path_from_component_path(file_path, components_dirs)
except RuntimeError:
# If no dir was found, we assume that the path is NOT relative to the component dir
logger.debug(
f"No component directory found for component '{component_name}' in {file_path}"
" If this component defines HTML, JS or CSS templates relatively to the component file,"
" then check that the component's directory is accessible from one of the paths"
" specified in the Django's 'STATICFILES_DIRS' settings."
)
return
# Check if filepath refers to a file that's in the same directory as the component class.
# If yes, modify the path to refer to the relative file.
# If not, don't modify anything.
def resolve_file(filepath: Union[str, SafeData]) -> Union[str, SafeData]:
if isinstance(filepath, str):
maybe_resolved_filepath = os.path.join(comp_dir_abs, filepath)
component_import_filepath = os.path.join(comp_dir_rel, filepath)
if os.path.isfile(maybe_resolved_filepath):
# NOTE: It's important to use `repr`, so we don't trigger __str__ on SafeStrings
logger.debug(
f"Interpreting template '{repr(filepath)}' of component '{module_name}'"
" relatively to component file"
)
return component_import_filepath
return filepath
logger.debug(
f"Interpreting template '{repr(filepath)}' of component '{module_name}'"
" relatively to components directory"
)
return filepath
# Check if template name is a local file or not
if "template_name" in attrs and attrs["template_name"]:
attrs["template_name"] = resolve_file(attrs["template_name"])
if "Media" in attrs:
media = attrs["Media"]
_map_media_filepaths(media, resolve_file)
def _get_dir_path_from_component_path(
abs_component_file_path: str,
candidate_dirs: Union[List[str], List[Path]],
) -> Tuple[str, str]:
comp_dir_path_abs = os.path.dirname(abs_component_file_path)
# From all dirs defined in settings.STATICFILES_DIRS, find one that's the parent
# to the component file.
root_dir_abs = None
for candidate_dir in candidate_dirs:
candidate_dir_abs = os.path.abspath(candidate_dir)
if comp_dir_path_abs.startswith(candidate_dir_abs):
root_dir_abs = candidate_dir_abs
break
if root_dir_abs is None:
raise RuntimeError(
f"Failed to resolve template directory for component file '{abs_component_file_path}'",
)
# Derive the path from matched STATICFILES_DIRS to the dir where the current component file is.
comp_dir_path_rel = os.path.relpath(comp_dir_path_abs, candidate_dir_abs)
# Return both absolute and relative paths:
# - Absolute path is used to check if the file exists
# - Relative path is used for defining the import on the component class
return comp_dir_path_abs, comp_dir_path_rel

View file

@ -0,0 +1,3 @@
.html-css-only {
color: blue;
}

View file

@ -0,0 +1,5 @@
<form method="post">
{% csrf_token %}
<input type="text" name="variable" value="{{ variable }}">
<input type="submit">
</form>

View file

@ -0,0 +1 @@
console.log("JS file");

View file

@ -0,0 +1,33 @@
from typing import Any, Dict
from django.templatetags.static import static
from django.utils.html import format_html, html_safe
from django_components import component
# Format as mentioned in https://github.com/EmilStenstrom/django-components/issues/522#issuecomment-2173577094
@html_safe
class PathObj:
def __init__(self, static_path: str) -> None:
self.static_path = static_path
self.throw_on_calling_str = True
def __str__(self):
# This error will notify us when we've hit __str__ when we shouldn't have
if self.throw_on_calling_str:
raise RuntimeError("__str__ method of 'relative_file_pathobj_component' was triggered when not allow to")
return format_html('<script type="module" src="{}"></script>', static(self.static_path))
@component.register("relative_file_pathobj_component")
class RelativeFileWithPathObjComponent(component.Component):
template_name = "relative_file_pathobj.html"
class Media:
js = PathObj("relative_file_pathobj.js")
css = PathObj("relative_file_pathobj.css")
def get_context_data(self, variable, *args, **kwargs) -> Dict[str, Any]:
return {"variable": variable}

View file

@ -0,0 +1,12 @@
{
"__COMMENT__": "This file is used by tests in `test_component_media.py` to test integration with Django's `staticfiles`",
"__COMMENT2__": "Under normal conditions, this JSON would be generated by running Django's `collectstatic` with `ManifestStaticFilesStorage`",
"__COMMENT3__": "See https://docs.djangoproject.com/en/5.0/ref/contrib/staticfiles/#manifeststaticfilesstorage",
"paths": {
"calendar/script.js": "calendar/script.e1815e23e0ec.js",
"calendar/style.css": "calendar/style.0eeb72042b59.css"
},
"version": "1.1",
"hash": "f53e7ffd18c4"
}

View file

@ -26,6 +26,8 @@ class TestAutodiscover(BaseTestCase):
def tearDown(self) -> None: def tearDown(self) -> None:
del settings.SETTINGS_MODULE # noqa del settings.SETTINGS_MODULE # noqa
# TODO: As part of this test, check that `autoimport()` imports the components
# from the `tests/components` dir?
def test_autodiscover_with_components_as_views(self): def test_autodiscover_with_components_as_views(self):
all_components_before = component_registry.registry.all().copy() all_components_before = component_registry.registry.all().copy()
@ -36,7 +38,7 @@ class TestAutodiscover(BaseTestCase):
all_components_after = component_registry.registry.all().copy() all_components_after = component_registry.registry.all().copy()
imported_components_count = len(all_components_after) - len(all_components_before) imported_components_count = len(all_components_after) - len(all_components_before)
self.assertEqual(imported_components_count, 1) self.assertEqual(imported_components_count, 2)
class TestLoaderSettingsModule(BaseTestCase): class TestLoaderSettingsModule(BaseTestCase):

View file

@ -1,8 +1,13 @@
import os
import sys import sys
from pathlib import Path from pathlib import Path
from django.forms.widgets import Media
from django.template import Context, Template from django.template import Context, Template
from django.templatetags.static import static
from django.test import override_settings from django.test import override_settings
from django.utils.html import format_html, html_safe
from django.utils.safestring import mark_safe
# isort: off # isort: off
from .django_test_setup import * # NOQA from .django_test_setup import * # NOQA
@ -261,7 +266,7 @@ class ComponentMediaTests(BaseTestCase):
""", """,
) )
def test_css_js_as_dict_and_list(self): def test_css_as_dict(self):
class SimpleComponent(component.Component): class SimpleComponent(component.Component):
class Media: class Media:
css = { css = {
@ -282,6 +287,432 @@ class ComponentMediaTests(BaseTestCase):
""", """,
) )
def test_media_custom_render_js(self):
class MyMedia(Media):
def render_js(self):
tags: list[str] = []
for path in self._js: # type: ignore[attr-defined]
abs_path = self.absolute_path(path) # type: ignore[attr-defined]
tags.append(f'<my_script_tag src="{abs_path}"></my_script_tag>')
return tags
class SimpleComponent(component.Component):
media_class = MyMedia
class Media:
js = ["path/to/script.js", "path/to/script2.js"]
comp = SimpleComponent()
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<my_script_tag src="path/to/script.js"></my_script_tag>
<my_script_tag src="path/to/script2.js"></my_script_tag>
""",
)
def test_media_custom_render_css(self):
class MyMedia(Media):
def render_css(self):
tags: list[str] = []
media = sorted(self._css) # type: ignore[attr-defined]
for medium in media:
for path in self._css[medium]: # type: ignore[attr-defined]
tags.append(f'<my_link href="{path}" media="{medium}" rel="stylesheet" />')
return tags
class SimpleComponent(component.Component):
media_class = MyMedia
class Media:
css = {
"all": "path/to/style.css",
"print": ["path/to/style2.css"],
"screen": "path/to/style3.css",
}
comp = SimpleComponent()
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<my_link href="path/to/style.css" media="all" rel="stylesheet" />
<my_link href="path/to/style2.css" media="print" rel="stylesheet" />
<my_link href="path/to/style3.css" media="screen" rel="stylesheet" />
""",
)
class MediaPathAsObjectTests(BaseTestCase):
def test_safestring(self):
"""
Test that media work with paths defined as instances of classes that define
the `__html__` method.
See https://docs.djangoproject.com/en/5.0/topics/forms/media/#paths-as-objects
"""
# NOTE: @html_safe adds __html__ method from __str__
@html_safe
class JSTag:
def __init__(self, path: str) -> None:
self.path = path
def __str__(self):
return f'<script js_tag src="{self.path}" type="module"></script>'
@html_safe
class CSSTag:
def __init__(self, path: str) -> None:
self.path = path
def __str__(self):
return f'<link css_tag href="{self.path}" rel="stylesheet" />'
# Format as mentioned in https://github.com/EmilStenstrom/django-components/issues/522#issuecomment-2173577094
@html_safe
class PathObj:
def __init__(self, static_path: str) -> None:
self.static_path = static_path
def __str__(self):
return format_html('<script type="module" src="{}"></script>', static(self.static_path))
class SimpleComponent(component.Component):
class Media:
css = {
"all": [
CSSTag("path/to/style.css"), # Formatted by CSSTag
mark_safe('<link hi href="path/to/style2.css" rel="stylesheet" />'), # Literal
],
"print": [
CSSTag("path/to/style3.css"), # Formatted by CSSTag
],
"screen": "path/to/style4.css", # Formatted by Media.render_css
}
js = [
JSTag("path/to/script.js"), # Formatted by JSTag
mark_safe('<script hi src="path/to/script2.js"></script>'), # Literal
PathObj("path/to/script3.js"), # Literal
"path/to/script4.js", # Formatted by Media.render_js
]
comp = SimpleComponent()
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<link css_tag href="path/to/style.css" rel="stylesheet" />
<link hi href="path/to/style2.css" rel="stylesheet" />
<link css_tag href="path/to/style3.css" rel="stylesheet" />
<link href="path/to/style4.css" media="screen" rel="stylesheet">
<script js_tag src="path/to/script.js" type="module"></script>
<script hi src="path/to/script2.js"></script>
<script type="module" src="path/to/script3.js"></script>
<script src="path/to/script4.js"></script>
""",
)
def test_pathlike(self):
"""
Test that media work with paths defined as instances of classes that define
the `__fspath__` method.
"""
class MyPath(os.PathLike):
def __init__(self, path: str) -> None:
self.path = path
def __fspath__(self):
return self.path
class SimpleComponent(component.Component):
class Media:
css = {
"all": [
MyPath("path/to/style.css"),
Path("path/to/style2.css"),
],
"print": [
MyPath("path/to/style3.css"),
],
"screen": "path/to/style4.css",
}
js = [
MyPath("path/to/script.js"),
Path("path/to/script2.js"),
"path/to/script3.js",
]
comp = SimpleComponent()
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<link href="path/to/style.css" media="all" rel="stylesheet">
<link href="path/to/style2.css" media="all" rel="stylesheet">
<link href="path/to/style3.css" media="print" rel="stylesheet">
<link href="path/to/style4.css" media="screen" rel="stylesheet">
<script src="path/to/script.js"></script>
<script src="path/to/script2.js"></script>
<script src="path/to/script3.js"></script>
""",
)
def test_str(self):
"""
Test that media work with paths defined as instances of classes that
subclass 'str'.
"""
class MyStr(str):
pass
class SimpleComponent(component.Component):
class Media:
css = {
"all": [
MyStr("path/to/style.css"),
"path/to/style2.css",
],
"print": [
MyStr("path/to/style3.css"),
],
"screen": "path/to/style4.css",
}
js = [
MyStr("path/to/script.js"),
"path/to/script2.js",
]
comp = SimpleComponent()
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<link href="path/to/style.css" media="all" rel="stylesheet">
<link href="path/to/style2.css" media="all" rel="stylesheet">
<link href="path/to/style3.css" media="print" rel="stylesheet">
<link href="path/to/style4.css" media="screen" rel="stylesheet">
<script src="path/to/script.js"></script>
<script src="path/to/script2.js"></script>
""",
)
def test_bytes(self):
"""
Test that media work with paths defined as instances of classes that
subclass 'bytes'.
"""
class MyBytes(bytes):
pass
class SimpleComponent(component.Component):
class Media:
css = {
"all": [
MyBytes(b"path/to/style.css"),
b"path/to/style2.css",
],
"print": [
MyBytes(b"path/to/style3.css"),
],
"screen": b"path/to/style4.css",
}
js = [
MyBytes(b"path/to/script.js"),
"path/to/script2.js",
]
comp = SimpleComponent()
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<link href="path/to/style.css" media="all" rel="stylesheet">
<link href="path/to/style2.css" media="all" rel="stylesheet">
<link href="path/to/style3.css" media="print" rel="stylesheet">
<link href="path/to/style4.css" media="screen" rel="stylesheet">
<script src="path/to/script.js"></script>
<script src="path/to/script2.js"></script>
""",
)
def test_function(self):
class SimpleComponent(component.Component):
class Media:
css = [
lambda: mark_safe('<link hi href="calendar/style.css" rel="stylesheet" />'), # Literal
lambda: Path("calendar/style1.css"),
lambda: "calendar/style2.css",
lambda: b"calendar/style3.css",
]
js = [
lambda: mark_safe('<script hi src="calendar/script.js"></script>'), # Literal
lambda: Path("calendar/script1.js"),
lambda: "calendar/script2.js",
lambda: b"calendar/script3.js",
]
comp = SimpleComponent()
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<link hi href="calendar/style.css" rel="stylesheet" />
<link href="calendar/style1.css" media="all" rel="stylesheet">
<link href="calendar/style2.css" media="all" rel="stylesheet">
<link href="calendar/style3.css" media="all" rel="stylesheet">
<script hi src="calendar/script.js"></script>
<script src="calendar/script1.js"></script>
<script src="calendar/script2.js"></script>
<script src="calendar/script3.js"></script>
""",
)
@override_settings(STATIC_URL="static/")
def test_works_with_static(self):
"""Test that all the different ways of defining media files works with Django's staticfiles"""
class SimpleComponent(component.Component):
class Media:
css = [
mark_safe(f'<link hi href="{static("calendar/style.css")}" rel="stylesheet" />'), # Literal
Path("calendar/style1.css"),
"calendar/style2.css",
b"calendar/style3.css",
lambda: "calendar/style4.css",
]
js = [
mark_safe(f'<script hi src="{static("calendar/script.js")}"></script>'), # Literal
Path("calendar/script1.js"),
"calendar/script2.js",
b"calendar/script3.js",
lambda: "calendar/script4.js",
]
comp = SimpleComponent()
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<link hi href="/static/calendar/style.css" rel="stylesheet" />
<link href="/static/calendar/style1.css" media="all" rel="stylesheet">
<link href="/static/calendar/style2.css" media="all" rel="stylesheet">
<link href="/static/calendar/style3.css" media="all" rel="stylesheet">
<link href="/static/calendar/style4.css" media="all" rel="stylesheet">
<script hi src="/static/calendar/script.js"></script>
<script src="/static/calendar/script1.js"></script>
<script src="/static/calendar/script2.js"></script>
<script src="/static/calendar/script3.js"></script>
<script src="/static/calendar/script4.js"></script>
""",
)
class MediaStaticfilesTests(BaseTestCase):
# For context see https://github.com/EmilStenstrom/django-components/issues/522
@override_settings(
# Configure static files. The dummy files are set up in the `./static_root` dir.
# The URL should have path prefix /static/.
# NOTE: We don't need STATICFILES_DIRS, because we don't run collectstatic
# See https://docs.djangoproject.com/en/5.0/ref/settings/#std-setting-STATICFILES_DIRS
STATIC_URL="static/",
STATIC_ROOT=os.path.join(Path(__file__).resolve().parent, "static_root"),
# Either `django.contrib.staticfiles` or `django_components.safer_staticfiles` MUST
# be installed for staticfiles resolution to work.
INSTALLED_APPS=[
"django_components.safer_staticfiles", # Or django.contrib.staticfiles
"django_components",
],
)
def test_default_static_files_storage(self):
"""Test integration with Django's staticfiles app"""
class MyMedia(Media):
def render_js(self):
tags: list[str] = []
for path in self._js: # type: ignore[attr-defined]
abs_path = self.absolute_path(path) # type: ignore[attr-defined]
tags.append(f'<my_script_tag src="{abs_path}"></my_script_tag>')
return tags
class SimpleComponent(component.Component):
media_class = MyMedia
class Media:
css = "calendar/style.css"
js = "calendar/script.js"
comp = SimpleComponent()
# NOTE: Since we're using the default storage class for staticfiles, the files should
# be searched as specified above (e.g. `calendar/script.js`) inside `static_root` dir.
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<link href="/static/calendar/style.css" media="all" rel="stylesheet">
<my_script_tag src="/static/calendar/script.js"></my_script_tag>
""",
)
# For context see https://github.com/EmilStenstrom/django-components/issues/522
@override_settings(
# Configure static files. The dummy files are set up in the `./static_root` dir.
# The URL should have path prefix /static/.
# NOTE: We don't need STATICFILES_DIRS, because we don't run collectstatic
# See https://docs.djangoproject.com/en/5.0/ref/settings/#std-setting-STATICFILES_DIRS
STATIC_URL="static/",
STATIC_ROOT=os.path.join(Path(__file__).resolve().parent, "static_root"),
# NOTE: STATICFILES_STORAGE is deprecated since 5.1, use STORAGES instead
# See https://docs.djangoproject.com/en/5.0/ref/settings/#staticfiles-storage
STORAGES={
# This was NOT changed
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
# This WAS changed so that static files are looked up by the `staticfiles.json`
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage",
},
},
# Either `django.contrib.staticfiles` or `django_components.safer_staticfiles` MUST
# be installed for staticfiles resolution to work.
INSTALLED_APPS=[
"django_components.safer_staticfiles", # Or django.contrib.staticfiles
"django_components",
],
)
def test_manifest_static_files_storage(self):
"""Test integration with Django's staticfiles app and ManifestStaticFilesStorage"""
class MyMedia(Media):
def render_js(self):
tags: list[str] = []
for path in self._js: # type: ignore[attr-defined]
abs_path = self.absolute_path(path) # type: ignore[attr-defined]
tags.append(f'<my_script_tag src="{abs_path}"></my_script_tag>')
return tags
class SimpleComponent(component.Component):
media_class = MyMedia
class Media:
css = "calendar/style.css"
js = "calendar/script.js"
comp = SimpleComponent()
# NOTE: Since we're using ManifestStaticFilesStorage, we expect the rendered media to link
# to the files as defined in staticfiles.json
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<link href="/static/calendar/style.0eeb72042b59.css" media="all" rel="stylesheet">
<my_script_tag src="/static/calendar/script.e1815e23e0ec.js"></my_script_tag>
""",
)
class MediaRelativePathTests(BaseTestCase): class MediaRelativePathTests(BaseTestCase):
class ParentComponent(component.Component): class ParentComponent(component.Component):
@ -339,6 +770,8 @@ class MediaRelativePathTests(BaseTestCase):
# Fix the paths, since the "components" dir is nested # Fix the paths, since the "components" dir is nested
with autodiscover_with_cleanup(map_import_paths=lambda p: f"tests.{p}"): with autodiscover_with_cleanup(map_import_paths=lambda p: f"tests.{p}"):
component.registry.unregister("relative_file_pathobj_component")
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %} {% load component_tags %}{% component_dependencies %}
{% component name='relative_file_component' variable=variable %} {% component name='relative_file_component' variable=variable %}
@ -372,6 +805,8 @@ class MediaRelativePathTests(BaseTestCase):
# Fix the paths, since the "components" dir is nested # Fix the paths, since the "components" dir is nested
with autodiscover_with_cleanup(map_import_paths=lambda p: f"tests.{p}"): with autodiscover_with_cleanup(map_import_paths=lambda p: f"tests.{p}"):
component.registry.unregister("relative_file_pathobj_component")
template_str: types.django_html = """ template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %} {% load component_tags %}{% component_dependencies %}
{% component 'parent_component' %} {% component 'parent_component' %}
@ -384,3 +819,43 @@ class MediaRelativePathTests(BaseTestCase):
template = Template(template_str) template = Template(template_str)
rendered = template.render(Context({})) rendered = template.render(Context({}))
self.assertIn('<input type="text" name="variable" value="hello">', rendered, rendered) self.assertIn('<input type="text" name="variable" value="hello">', rendered, rendered)
# Settings required for autodiscover to work
@override_settings(
BASE_DIR=Path(__file__).resolve().parent,
STATICFILES_DIRS=[
Path(__file__).resolve().parent / "components",
],
)
def test_component_with_relative_media_does_not_trigger_safestring_path_at__new__(self):
"""
Test that, for the __html__ objects are not coerced into string throughout
the class creation. This is important to allow to call `collectstatic` command.
Because some users use `static` inside the `__html__` or `__str__` methods.
So if we "render" the safestring using str() during component class creation (__new__),
then we force to call `static`. And if this happens during `collectstatic` run,
then this triggers an error, because `static` is called before the static files exist.
https://github.com/EmilStenstrom/django-components/issues/522#issuecomment-2173577094
"""
# Ensure that the module is executed again after import in autodiscovery
if "tests.components.relative_file_pathobj.relative_file_pathobj" in sys.modules:
del sys.modules["tests.components.relative_file_pathobj.relative_file_pathobj"]
# Fix the paths, since the "components" dir is nested
with autodiscover_with_cleanup(map_import_paths=lambda p: f"tests.{p}"):
# Mark the PathObj instances of 'relative_file_pathobj_component' so they won raise
# error PathObj.__str__ is triggered.
CompCls = component.registry.get("relative_file_pathobj_component")
CompCls.Media.js[0].throw_on_calling_str = False # type: ignore
CompCls.Media.css["all"][0].throw_on_calling_str = False # type: ignore
rendered = CompCls().render_dependencies()
self.assertHTMLEqual(
rendered,
"""
<script type="module" src="relative_file_pathobj.css"></script>
<script type="module" src="relative_file_pathobj.js"></script>
""",
)