feat: allow globs when specifynigg additionall JS and CSS (#1043)

* feat: allow globs when specifynigg additionall JS and CSS

* refactor: fix tests and linter errors
This commit is contained in:
Juro Oravec 2025-03-21 10:23:38 +01:00 committed by GitHub
parent 73e94b6714
commit ab75cfdb8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 282 additions and 97 deletions

View file

@ -1,5 +1,19 @@
# Release notes
## v0.132
#### Feat
- Allow to use glob patterns as paths for additional JS / CSS in
`Component.Media.js` and `Component.Media.css`
```py
class MyComponent(Component):
class Media:
js = ["*.js"]
css = ["*.css"]
```
## v0.131
#### Feat

View file

@ -143,17 +143,22 @@ However, there's a few differences from Django's Media class:
2. Individual JS / CSS files can be any of `str`, `bytes`, `Path`,
[`SafeString`](https://docs.djangoproject.com/en/5.1/ref/utils/#django.utils.safestring.SafeString), or a function
(See [`ComponentMediaInputPath`](../../../reference/api#django_components.ComponentMediaInputPath)).
3. Individual JS / CSS files can be glob patterns, e.g. `*.js` or `styles/**/*.css`.
4. If you set [`Media.extend`](../../../reference/api/#django_components.ComponentMediaInput.extend) to a list,
it should be a list of [`Component`](../../../reference/api/#django_components.Component) classes.
```py
class MyTable(Component):
class Media:
js = [
"path/to/script.js",
"path/to/*.js", # Or as a glob
"https://unpkg.com/alpinejs@3.14.7/dist/cdn.min.js", # AlpineJS
]
css = {
"all": [
"path/to/style.css",
"path/to/*.css", # Or as a glob
"https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css", # TailwindCSS
],
"print": ["path/to/style2.css"],

View file

@ -230,7 +230,9 @@ with a few differences:
1. Our Media class accepts various formats for the JS and CSS files: either a single file, a list, or (CSS-only) a dictonary (see below).
2. Individual JS / CSS files can be any of `str`, `bytes`, `Path`, [`SafeString`](https://dev.to/doridoro/django-safestring-afj), or a function.
3. If you set `Media.extend` to a list, it should be a list of `Component` classes.
3. Individual JS / CSS files can be glob patterns, e.g. `*.js` or `styles/**/*.css`.
4. If you set [`Media.extend`](../../reference/api/#django_components.ComponentMediaInput.extend) to a list,
it should be a list of [`Component`](../../reference/api/#django_components.Component) classes.
[Learn more](../../concepts/fundamentals/defining_js_css_html_files) about using Media.
@ -245,10 +247,12 @@ class Calendar(Component):
class Media: # <--- new
js = [
"path/to/shared.js",
"path/to/*.js", # Or as a glob
"https://unpkg.com/alpinejs@3.14.7/dist/cdn.min.js", # AlpineJS
]
css = [
"path/to/shared.css",
"path/to/*.css", # Or as a glob
"https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css", # Tailwind
]

View file

@ -1,10 +1,26 @@
import glob
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
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
Iterable,
List,
Literal,
Optional,
Protocol,
Sequence,
Tuple,
Type,
Union,
cast,
)
from weakref import WeakKeyDictionary
from django.contrib.staticfiles import finders
@ -16,7 +32,7 @@ from django.utils.safestring import SafeData
from django_components.util.loader import get_component_dirs, resolve_file
from django_components.util.logger import logger
from django_components.util.misc import get_import_path
from django_components.util.misc import flatten, get_import_path, get_module_info, is_glob
if TYPE_CHECKING:
from django_components.component import Component
@ -649,19 +665,19 @@ def _normalize_media(media: Type[ComponentMediaInput]) -> None:
_map_media_filepaths(media, _normalize_media_filepath)
def _map_media_filepaths(media: Type[ComponentMediaInput], map_fn: Callable[[Any], Any]) -> None:
def _map_media_filepaths(media: Type[ComponentMediaInput], map_fn: Callable[[Sequence[Any]], Iterable[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]
media.css[media_type] = list(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))
media.js = list(map_fn(media.js))
def _is_media_filepath(filepath: Any) -> bool:
@ -683,26 +699,33 @@ def _is_media_filepath(filepath: Any) -> bool:
return False
def _normalize_media_filepath(filepath: ComponentMediaInputPath) -> Union[str, SafeData]:
if callable(filepath):
filepath = filepath()
def _normalize_media_filepath(filepaths: Sequence[ComponentMediaInputPath]) -> List[Union[str, SafeData]]:
normalized: List[Union[str, SafeData]] = []
for filepath in filepaths:
if callable(filepath):
filepath = filepath()
if isinstance(filepath, SafeData) or hasattr(filepath, "__html__"):
return filepath
if isinstance(filepath, SafeData) or hasattr(filepath, "__html__"):
normalized.append(filepath)
continue
if isinstance(filepath, (Path, os.PathLike)) or hasattr(filepath, "__fspath__"):
# In case of Windows OS, convert to forward slashes
filepath = Path(filepath.__fspath__()).as_posix()
if isinstance(filepath, (Path, os.PathLike)) or hasattr(filepath, "__fspath__"):
# In case of Windows OS, convert to forward slashes
filepath = Path(filepath.__fspath__()).as_posix()
if isinstance(filepath, bytes):
filepath = filepath.decode("utf-8")
if isinstance(filepath, bytes):
filepath = filepath.decode("utf-8")
if isinstance(filepath, str):
return filepath
if isinstance(filepath, str):
normalized.append(filepath)
continue
raise ValueError(
"Unknown filepath. Must be str, bytes, PathLike, SafeString, or a function that returns one of the former"
)
raise ValueError(
f"Unknown filepath {filepath} of type {type(filepath)}. Must be str, bytes, PathLike, SafeString,"
" or a function that returns one of the former"
)
return normalized
def _resolve_component_relative_files(
@ -730,12 +753,9 @@ def _resolve_component_relative_files(
return
component_name = comp_cls.__qualname__
# Derive the full path of the file where the component was defined
module_name = comp_cls.__module__
module_obj = sys.modules[module_name]
file_path = module_obj.__file__
if not file_path:
# Get the full path of the file where the component was defined
module, module_name, module_file_path = get_module_info(comp_cls)
if not module_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."
@ -743,83 +763,147 @@ def _resolve_component_relative_files(
return
# Get the directory where the component class is defined
try:
comp_dir_abs, comp_dir_rel = _get_dir_path_from_component_path(file_path, comp_dirs)
except RuntimeError:
# If no dir was found, we assume that the path is NOT relative to the component dir
matched_component_dir = _find_component_dir_containing_file(comp_dirs, module_file_path)
# If no dir was found (e.g. the component was defined at runtime), we assume that the media paths
# are NOT relative.
if matched_component_dir is None:
logger.debug(
f"No component directory found for component '{component_name}' in {file_path}"
f"No component directory found for component '{component_name}' in {module_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 'COMPONENTS.dirs' settings."
)
return
matched_component_dir_abs = os.path.abspath(matched_component_dir)
# Derive the path from matched `COMPONENTS.dirs` to the dir where the current component file is.
component_module_dir_path_abs = os.path.dirname(module_file_path)
# 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_media_file(filepath: Union[str, SafeData]) -> Union[str, SafeData]:
if isinstance(filepath, str):
filepath_abs = os.path.join(comp_dir_abs, filepath)
# NOTE: The paths to resources need to use POSIX (forward slashes) for Django to wor
# See https://github.com/django-components/django-components/issues/796
filepath_rel_to_comp_dir = Path(os.path.join(comp_dir_rel, filepath)).as_posix()
if os.path.isfile(filepath_abs):
# 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 filepath_rel_to_comp_dir
# If resolved absolute path does NOT exist or filepath is NOT a string, then return as is
logger.debug(
f"Interpreting template '{repr(filepath)}' of component '{module_name}'"
" relatively to components directory"
def resolve_relative_media_file(
filepath: Union[str, SafeData],
allow_glob: bool,
) -> List[Union[str, SafeData]]:
resolved_filepaths, has_matched = resolve_media_file(
filepath,
allow_glob,
static_files_dir=matched_component_dir_abs,
media_root_dir=component_module_dir_path_abs,
)
return filepath
# NOTE: It's important to use `repr`, so we don't trigger __str__ on SafeStrings
if has_matched:
logger.debug(
f"Interpreting file '{repr(filepath)}' of component '{module_name}'" " relatively to component file"
)
else:
logger.debug(
f"Interpreting file '{repr(filepath)}' of component '{module_name}'"
" relatively to components directory"
)
return resolved_filepaths
# Check if filepath is a glob pattern that points to files relative to the components directory
# (as defined by `COMPONENTS.dirs` and `COMPONENTS.app_dirs` settings) in which the component is defined.
# If yes, modify the path to refer to the globbed files.
# If not, don't modify anything.
def resolve_static_media_file(
filepath: Union[str, SafeData],
allow_glob: bool,
) -> List[Union[str, SafeData]]:
resolved_filepaths, _ = resolve_media_file(
filepath,
allow_glob,
static_files_dir=matched_component_dir_abs,
media_root_dir=matched_component_dir_abs,
)
return resolved_filepaths
# Check if template name is a local file or not
if getattr(comp_media, "template_file", None):
comp_media.template_file = resolve_media_file(comp_media.template_file)
comp_media.template_file = resolve_relative_media_file(comp_media.template_file, False)[0]
if getattr(comp_media, "js_file", None):
comp_media.js_file = resolve_media_file(comp_media.js_file)
comp_media.js_file = resolve_relative_media_file(comp_media.js_file, False)[0]
if getattr(comp_media, "css_file", None):
comp_media.css_file = resolve_media_file(comp_media.css_file)
comp_media.css_file = resolve_relative_media_file(comp_media.css_file, False)[0]
if hasattr(comp_media, "Media") and comp_media.Media:
_map_media_filepaths(comp_media.Media, resolve_media_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.COMPONENTS.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}'",
_map_media_filepaths(
comp_media.Media,
# Media files can be defined as a glob patterns that match multiple files.
# Thus, flatten the list of lists returned by `resolve_relative_media_file`.
lambda filepaths: flatten(resolve_relative_media_file(f, True) for f in filepaths),
)
# Derive the path from matched COMPONENTS.dirs to the dir where the current component file is.
comp_dir_path_rel = os.path.relpath(comp_dir_path_abs, candidate_dir_abs)
# Go over the JS / CSS media files again, but this time, if there are still any globs,
# try to resolve them relative to the root directory (as defined by `COMPONENTS.dirs
# and `COMPONENTS.app_dirs` settings).
_map_media_filepaths(
comp_media.Media,
# Media files can be defined as a glob patterns that match multiple files.
# Thus, flatten the list of lists returned by `resolve_static_media_file`.
lambda filepaths: flatten(resolve_static_media_file(f, True) for f in filepaths),
)
# 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
# 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_media_file(
filepath: Union[str, SafeData],
allow_glob: bool,
static_files_dir: str,
media_root_dir: str,
) -> Tuple[List[Union[str, SafeData]], bool]:
# If filepath is NOT a string, then return as is
if not isinstance(filepath, str):
return [filepath], False
filepath_abs_or_glob = os.path.join(media_root_dir, filepath)
# The path may be a glob. So before we check if the file exists,
# we need to resolve the glob.
if allow_glob and is_glob(filepath_abs_or_glob):
matched_abs_filepaths = glob.glob(filepath_abs_or_glob)
else:
matched_abs_filepaths = [filepath_abs_or_glob]
# If there are no matches, return the original filepath
if not matched_abs_filepaths:
return [filepath], False
resolved_filepaths: List[str] = []
for matched_filepath_abs in matched_abs_filepaths:
# Derive the path from matched `COMPONENTS.dirs` to the media file.
# NOTE: The paths to resources need to use POSIX (forward slashes) for Django to work
# See https://github.com/django-components/django-components/issues/796
# NOTE: Since these paths matched the glob, we know that these files exist.
filepath_rel_to_comp_dir = Path(os.path.relpath(matched_filepath_abs, static_files_dir)).as_posix()
resolved_filepaths.append(filepath_rel_to_comp_dir)
return resolved_filepaths, True
def _find_component_dir_containing_file(
component_dirs: Sequence[Union[str, Path]],
target_file_path: str,
) -> Optional[Union[str, Path]]:
"""
From all directories that may contain components (such as those defined in `COMPONENTS.dirs`),
find the one that's the parent to the given file.
"""
abs_target_file_path = os.path.abspath(target_file_path)
for component_dir in component_dirs:
component_dir_abs = os.path.abspath(component_dir)
if abs_target_file_path.startswith(component_dir_abs):
return component_dir
return None
def _get_asset(

View file

@ -2,8 +2,9 @@ import re
import sys
from hashlib import md5
from importlib import import_module
from itertools import chain
from types import ModuleType
from typing import TYPE_CHECKING, Any, Callable, List, Optional, Tuple, Type, TypeVar, Union
from typing import TYPE_CHECKING, Any, Callable, Iterable, List, Optional, Tuple, Type, TypeVar, Union
from django_components.util.nanoid import generate
@ -115,3 +116,15 @@ def hash_comp_cls(comp_cls: Type["Component"]) -> str:
full_name = get_import_path(comp_cls)
comp_cls_hash = md5(full_name.encode()).hexdigest()[0:6]
return comp_cls.__name__ + "_" + comp_cls_hash
# String is a glob if it contains at least one of `?`, `*`, or `[`
is_glob_re = re.compile(r"[?*[]")
def is_glob(filepath: str) -> bool:
return is_glob_re.search(filepath) is not None
def flatten(lst: Iterable[Iterable[T]]) -> List[T]:
return list(chain.from_iterable(lst))

View file

@ -0,0 +1,23 @@
from django_components import Component
# The Media JS / CSS glob and are relative to the component directory
class GlobComponent(Component):
template = """
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
"""
class Media:
css = "glob_*.css"
js = "glob_*.js"
# The Media JS / CSS glob and are relative to the directory given in
# `COMPONENTS.dirs` and `COMPONENTS.app_dirs`
class GlobComponentRootDir(GlobComponent):
class Media:
css = "glob/glob_*.css"
js = "glob/glob_*.js"

View file

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

View file

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

View file

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

View file

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

View file

@ -386,6 +386,34 @@ class TestComponentMedia:
assertInHTML('<link abc href="path/to/style2.css" media="print" rel="stylesheet">', rendered)
assertInHTML('<link abc href="path/to/style3.css" media="screen" rel="stylesheet">', rendered)
@djc_test(
django_settings={
"INSTALLED_APPS": ("django_components", "tests"),
}
)
def test_glob_pattern_relative_to_component(self):
from tests.components.glob.glob import GlobComponent
rendered = GlobComponent.render()
assertInHTML('<link href="glob/glob_1.css" media="all" rel="stylesheet">', rendered)
assertInHTML('<link href="glob/glob_2.css" media="all" rel="stylesheet">', rendered)
assertInHTML('<script src="glob/glob_1.js"></script>', rendered)
assertInHTML('<script src="glob/glob_2.js"></script>', rendered)
@djc_test(
django_settings={
"INSTALLED_APPS": ("django_components", "tests"),
}
)
def test_glob_pattern_relative_to_root_dir(self):
from tests.components.glob.glob import GlobComponentRootDir
rendered = GlobComponentRootDir.render()
assertInHTML('<link href="glob/glob_1.css" media="all" rel="stylesheet">', rendered)
assertInHTML('<link href="glob/glob_2.css" media="all" rel="stylesheet">', rendered)
assertInHTML('<script src="glob/glob_1.js"></script>', rendered)
assertInHTML('<script src="glob/glob_2.js"></script>', rendered)
@djc_test
class TestMediaPathAsObject:

View file

@ -247,6 +247,7 @@ class TestComponentFiles:
assert dot_paths == [
"components",
"components.glob.glob",
"components.multi_file.multi_file",
"components.relative_file.relative_file",
"components.relative_file_pathobj.relative_file_pathobj",
@ -260,15 +261,16 @@ class TestComponentFiles:
# NOTE: Compare parts so that the test works on Windows too
assert file_paths[0].parts[-3:] == ("tests", "components", "__init__.py")
assert file_paths[1].parts[-4:] == ("tests", "components", "multi_file", "multi_file.py")
assert file_paths[2].parts[-4:] == ("tests", "components", "relative_file", "relative_file.py")
assert file_paths[3].parts[-4:] == ("tests", "components", "relative_file_pathobj", "relative_file_pathobj.py")
assert file_paths[4].parts[-3:] == ("tests", "components", "single_file.py")
assert file_paths[5].parts[-4:] == ("tests", "components", "staticfiles", "staticfiles.py")
assert file_paths[6].parts[-3:] == ("tests", "components", "urls.py")
assert file_paths[7].parts[-3:] == ("django_components", "components", "__init__.py")
assert file_paths[8].parts[-3:] == ("django_components", "components", "dynamic.py")
assert file_paths[9].parts[-5:] == ("tests", "test_app", "components", "app_lvl_comp", "app_lvl_comp.py")
assert file_paths[1].parts[-4:] == ("tests", "components", "glob", "glob.py")
assert file_paths[2].parts[-4:] == ("tests", "components", "multi_file", "multi_file.py")
assert file_paths[3].parts[-4:] == ("tests", "components", "relative_file", "relative_file.py")
assert file_paths[4].parts[-4:] == ("tests", "components", "relative_file_pathobj", "relative_file_pathobj.py")
assert file_paths[5].parts[-3:] == ("tests", "components", "single_file.py")
assert file_paths[6].parts[-4:] == ("tests", "components", "staticfiles", "staticfiles.py")
assert file_paths[7].parts[-3:] == ("tests", "components", "urls.py")
assert file_paths[8].parts[-3:] == ("django_components", "components", "__init__.py")
assert file_paths[9].parts[-3:] == ("django_components", "components", "dynamic.py")
assert file_paths[10].parts[-5:] == ("tests", "test_app", "components", "app_lvl_comp", "app_lvl_comp.py")
@djc_test(
django_settings={
@ -282,6 +284,8 @@ class TestComponentFiles:
file_paths = [f.filepath for f in files]
assert dot_paths == [
"components.glob.glob_1",
"components.glob.glob_2",
"components.relative_file.relative_file",
"components.relative_file_pathobj.relative_file_pathobj",
"components.staticfiles.staticfiles",
@ -289,10 +293,12 @@ class TestComponentFiles:
]
# NOTE: Compare parts so that the test works on Windows too
assert file_paths[0].parts[-4:] == ("tests", "components", "relative_file", "relative_file.js")
assert file_paths[1].parts[-4:] == ("tests", "components", "relative_file_pathobj", "relative_file_pathobj.js")
assert file_paths[2].parts[-4:] == ("tests", "components", "staticfiles", "staticfiles.js")
assert file_paths[3].parts[-5:] == ("tests", "test_app", "components", "app_lvl_comp", "app_lvl_comp.js")
assert file_paths[0].parts[-4:] == ("tests", "components", "glob", "glob_1.js")
assert file_paths[1].parts[-4:] == ("tests", "components", "glob", "glob_2.js")
assert file_paths[2].parts[-4:] == ("tests", "components", "relative_file", "relative_file.js")
assert file_paths[3].parts[-4:] == ("tests", "components", "relative_file_pathobj", "relative_file_pathobj.js")
assert file_paths[4].parts[-4:] == ("tests", "components", "staticfiles", "staticfiles.js")
assert file_paths[5].parts[-5:] == ("tests", "test_app", "components", "app_lvl_comp", "app_lvl_comp.js")
@djc_test