Resolve media and template files relative to component class dir (#395), thanks @JuroOravec

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Emil Stenström <emil@emilstenstrom.se>
This commit is contained in:
Juro Oravec 2024-03-23 19:01:39 +01:00 committed by GitHub
parent 1de859bd34
commit 37fd901908
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 477 additions and 43 deletions

View file

@ -196,7 +196,7 @@ from django_components import 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
# you can override def get_template_name() instead of specifying the below variable.
template_name = "calendar/calendar.html"
template_name = "calendar.html"
# This component takes one parameter, a date string to show in the template
def get_context_data(self, date):
@ -205,8 +205,8 @@ class Calendar(component.Component):
}
class Media:
css = "calendar/style.css"
js = "calendar/script.js"
css = "style.css"
js = "script.js"
```
And voilá!! We've created our first component.
@ -681,6 +681,36 @@ COMPONENTS = {
}
```
## Logging and debugging
Django components supports [logging with Django](https://docs.djangoproject.com/en/5.0/howto/logging/#logging-how-to). This can help with troubleshooting.
To configure logging for Django components, set the `django_components` logger in `LOGGING` in `settings.py` (below).
Also see the [`settings.py` file in sampleproject](./sampleproject/sampleproject/settings.py) for a real-life example.
```py
import logging
import sys
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
"handlers": {
"console": {
'class': 'logging.StreamHandler',
'stream': sys.stdout,
},
},
"loggers": {
"django_components": {
"level": logging.DEBUG,
"handlers": ["console"],
},
},
}
```
## Management Command
You can use the built-in management command `startcomponent` to create a django component. The command accepts the following arguments and options:
@ -761,7 +791,10 @@ One of our goals with `django-components` is to make it easy to share components
- [django-htmx-components](https://github.com/iwanalabs/django-htmx-components): A set of components for use with [htmx](https://htmx.org/). Try out the [live demo](https://dhc.iwanalabs.com/).
## Install locally and run the tests
## Running django-components project locally
### Install locally and run the tests
Start by forking the project by clicking the **Fork button** up in the right corner in the GitHub . This makes a copy of the repository in your own name. Now you can clone this repository locally and start adding features:
@ -794,3 +827,34 @@ pyenv install -s 3.12
pyenv local 3.6 3.7 3.8 3.9 3.10 3.11 3.12
tox -p
```
### Developing against live Django app
How do you check that your changes to django-components project will work in an actual Django project?
Use the [sampleproject](./sampleproject/) demo project to validate the changes:
1. Navigate to [sampleproject](./sampleproject/) directory:
```sh
cd sampleproject
```
2. Install dependencies from the [requirements.txt](./sampleproject/requirements.txt) file:
```sh
pip install -r requirements.txt
```
3. Link to your local version of django-components:
```sh
pip install -e ..
```
NOTE: The path (in this case `..`) must point to the directory that has the `setup.py` file.
4. Start Django server
```sh
python manage.py runserver
```
Once the server is up, it should be available at <http://127.0.0.1:8000>.
To display individual components, add them to the `urls.py`, like in the case of <http://127.0.0.1:8000/greeting>

View file

@ -1,14 +1,12 @@
import glob
import importlib
import importlib.util
import sys
from pathlib import Path
import django
from django.template.engine import Engine
from django.utils.module_loading import autodiscover_modules
from django_components.template_loader import Loader
from django_components.utils.autodiscover import search
if django.VERSION < (3, 2):
default_app_config = "django_components.apps.ComponentsConfig"
@ -22,11 +20,8 @@ def autodiscover():
autodiscover_modules("components")
# Autodetect a <component>.py file in a components dir
current_engine = Engine.get_default()
loader = Loader(current_engine)
dirs = loader.get_dirs()
for directory in dirs:
for path in glob.iglob(str(directory / "**/*.py"), recursive=True):
component_filepaths = search(search_glob="**/*.py")
for path in component_filepaths:
import_file(path)
for path in app_settings.LIBRARIES:

View file

@ -1,5 +1,6 @@
import difflib
import inspect
import os
from collections import ChainMap
from typing import Any, ClassVar, Dict, Iterable, List, Optional, Set, Tuple, Union
@ -25,6 +26,7 @@ from django_components.component_registry import ( # NOQA
register,
registry,
)
from django_components.logger import logger
from django_components.templatetags.component_tags import (
FILLED_SLOTS_CONTENT_CONTEXT_KEY,
DefaultFillContent,
@ -35,6 +37,7 @@ from django_components.templatetags.component_tags import (
SlotName,
SlotNode,
)
from django_components.utils.autodiscover import search
class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass):
@ -60,9 +63,104 @@ class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass):
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)
def _resolve_component_relative_files(attrs: dict):
"""
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.
"""
module_name = attrs["__module__"]
# Prepare all possible directories we need to check when searching for
# component's template and media files
components_dirs = search()
# Get the directory where the component class is defined
try:
comp_dir_abs, comp_dir_rel = _get_dir_path_from_component_module_path(module_name, 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 '{module_name}'."
" 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):
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_module_path(component_module_path: str, candidate_dirs: List[str]):
# Transform python module notation "pkg.module.name" to file path "pkg/module/name"
# Thus, we should get file path relative to Django project root
comp_path = os.sep.join(component_module_path.split("."))
comp_dir_path = os.path.dirname(comp_path)
# NOTE: We assume that Django project root == current working directory!
cwd = os.getcwd()
comp_dir_path_abs = os.path.join(cwd, comp_dir_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 '{component_module_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
# non-null return.

View file

@ -0,0 +1,3 @@
import logging
logger = logging.getLogger("django_components")

View file

@ -2,34 +2,84 @@
Template loader that loads templates from each Django app's "components" directory.
"""
import os
from pathlib import Path
from typing import Set
from django.apps import apps
from django.conf import settings
from django.template.loaders.filesystem import Loader as FilesystemLoader
from django.template.utils import get_app_template_dirs
from django_components.logger import logger
# Same as `Path.is_relative_to`, defined as standalone function because `Path.is_relative_to`
# is marked for deprecation.
def path_is_relative_to(child_path: str, parent_path: str):
# If the relative path doesn't start with `..`, then child is descendant of parent
# See https://stackoverflow.com/a/7288073/9788634
rel_path = os.path.relpath(child_path, parent_path)
return not rel_path.startswith("..")
class Loader(FilesystemLoader):
def get_dirs(self):
component_dir = "components"
directories = set(get_app_template_dirs(component_dir))
# Allow to configure from settings which dirs should be checked for components
if hasattr(settings, "STATICFILES_DIRS") and len(settings.STATICFILES_DIRS):
component_dirs = settings.STATICFILES_DIRS
else:
component_dirs = ["components"]
logger.debug(
"Template loader will search for valid template dirs from following options:\n"
+ "\n".join([f" - {str(d)}" for d in component_dirs])
)
directories: Set[Path] = set()
for component_dir in component_dirs:
curr_directories: Set[Path] = set()
# For each dir in `settings.STATICFILES_DIRS`, we go over all Django apps
# and, for each app, check if the STATICFILES_DIRS dir is within that app dir.
# If so, we add the dir as a valid source.
# The for loop is based on Django's `get_app_template_dirs`.
for app_config in apps.get_app_configs():
if not app_config.path:
continue
if not Path(component_dir).is_dir():
continue
if path_is_relative_to(component_dir, app_config.path):
curr_directories.add(Path(component_dir).resolve())
if hasattr(settings, "BASE_DIR"):
path = (Path(settings.BASE_DIR) / component_dir).resolve()
if path.is_dir():
directories.add(path)
curr_directories.add(path)
# Add the directory that holds the settings file
if settings.SETTINGS_MODULE:
module_parts = settings.SETTINGS_MODULE.split(".")
module_path = Path(*module_parts)
# - If len() == 2, then path to settings file is <app_name>/<settings_file>.py
# - If len() > 2, then we assume that settings file is in a settings directory,
# e.g. <app_name>/settings/<settings_file>.py
if len(module_parts) > 2:
module_path = Path(*module_parts[:-1])
# Use list() for < Python 3.9
# Get the paths for the nested settings dir like `<app_name>/settings`,
# or for non-nested dir like `<app_name>`
#
# NOTE: Use list() for < Python 3.9
for parent in list(module_path.parents)[:2]:
path = (parent / component_dir).resolve()
if path.is_dir():
directories.add(path)
curr_directories.add(path)
directories.update(curr_directories)
logger.debug(
"Template loader matched following template dirs:\n" + "\n".join([f" - {str(d)}" for d in directories])
)
return list(directories)

View file

@ -0,0 +1,32 @@
import glob
from pathlib import Path
from typing import List, Optional
from django.template.engine import Engine
from django_components.template_loader import Loader
def search(search_glob: Optional[str] = None, engine: Optional[Engine] = None):
"""
Search for directories that may contain components.
If `search_glob` is given, the directories are searched for said glob pattern,
and glob search results are returned as a flattened list.
"""
current_engine = engine
if current_engine is None:
current_engine = Engine.get_default()
loader = Loader(current_engine)
dirs = loader.get_dirs()
if search_glob is None:
return dirs
component_filenames: List[str] = []
for directory in dirs:
for path in glob.iglob(str(Path(directory) / search_glob), recursive=True):
component_filenames.append(path)
return component_filenames

View file

@ -23,3 +23,27 @@ class Calendar(component.Component):
class Media:
css = "calendar/calendar.css"
js = "calendar/calendar.js"
@component.register("calendar_relative")
class CalendarRelative(component.Component):
# Note that Django will look for templates inside `[your apps]/components` dir and
# `[project root]/components` dir. To customize which template to use based on context
# you can override def get_template_name() instead of specifying the below variable.
template_name = "calendar.html"
# This component takes one parameter, a date string to show in the template
def get_context_data(self, date):
return {
"date": date,
}
def get(self, request, *args, **kwargs):
context = {
"date": request.GET.get("date", ""),
}
return self.render_to_response(context)
class Media:
css = "calendar.css"
js = "calendar.js"

View file

@ -0,0 +1,2 @@
.calendar-component { width: 200px; background: pink; }
.calendar-component span { font-weight: bold; }

View file

@ -0,0 +1,19 @@
<div class="calendar-component">
<div>Today's date is <span>{{ date }}</span></div>
<div>Your to-dos:</div>
<ul>
<li>
{% component "todo" %}
{% fill "todo_text" %}
Stop forgetting the milk!
{% endfill %}
{% endcomponent %}
</li>
<li>
{% component "todo" %}
{# As of v0.28, 'fill' tag optional for 1-slot filling if component template specifies a 'default' slot #}
Wear all-white clothes to laser tag tournament.
{% endcomponent %}
</li>
</ul>
</div>

View file

@ -0,0 +1,5 @@
(function(){
if (document.querySelector(".calendar-component")) {
document.querySelector(".calendar-component").onclick = function(){ alert("Clicked calendar!"); };
}
})()

View file

@ -0,0 +1,25 @@
from django_components import component
@component.register("calendar_nested")
class CalendarNested(component.Component):
# Note that Django will look for templates inside `[your apps]/components` dir and
# `[project root]/components` dir. To customize which template to use based on context
# you can override def get_template_name() instead of specifying the below variable.
template_name = "calendar.html"
# This component takes one parameter, a date string to show in the template
def get_context_data(self, date):
return {
"date": date,
}
def get(self, request, *args, **kwargs):
context = {
"date": request.GET.get("date", ""),
}
return self.render_to_response(context)
class Media:
css = "calendar.css"
js = "calendar.js"

View file

@ -1,8 +1,11 @@
from components.calendar.calendar import Calendar
from components.calendar.calendar import Calendar, CalendarRelative
from components.greeting import Greeting
from components.nested.calendar.calendar import CalendarNested
from django.urls import path
urlpatterns = [
path("greeting/", Greeting.as_view(), name="greeting"),
path("calendar/", Calendar.as_view(), name="calendar"),
path("calendar-relative/", CalendarRelative.as_view(), name="calendar-relative"),
path("calendar-nested/", CalendarNested.as_view(), name="calendar-nested"),
]

View file

@ -1,5 +1,7 @@
import logging
import os
import secrets
import sys
from pathlib import Path
from typing import List
@ -133,3 +135,20 @@ STATICFILES_DIRS = [BASE_DIR / "components"]
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
STATIC_ROOT = "staticfiles"
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
"stream": sys.stdout,
},
},
"loggers": {
"django_components": {
"level": logging.DEBUG,
"handlers": ["console"],
},
},
}

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,24 @@
from typing import Any, Dict
from django.http import HttpResponse
from django_components import component
@component.register("relative_file_component")
class RelativeFileComponent(component.Component):
template_name = "relative_file.html"
class Media:
js = "relative_file.js"
css = "relative_file.css"
def post(self, request, *args, **kwargs) -> HttpResponse:
variable = request.POST.get("variable")
return self.render_to_response({"variable": variable})
def get(self, request, *args, **kwargs) -> HttpResponse:
return self.render_to_response({"variable": "GET"})
def get_context_data(self, variable, *args, **kwargs) -> Dict[str, Any]:
return {"variable": variable}

View file

@ -7,7 +7,10 @@ if not settings.configured:
TEMPLATES=[
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": ["tests/templates/"],
"DIRS": [
"tests/templates/",
"tests/components/", # Required for template relative imports in tests
],
}
],
COMPONENTS={"template_cache_size": 128},

View file

@ -40,7 +40,14 @@ class TestLoaderSettingsModule(SimpleTestCase):
current_engine = Engine.get_default()
loader = Loader(current_engine)
dirs = loader.get_dirs()
self.assertEqual(dirs, [Path(__file__).parent.resolve() / "components"])
self.assertEqual(
sorted(dirs),
sorted(
[
Path(__file__).parent.resolve() / "components",
]
),
)
def test_complex_settings_module(self):
settings.SETTINGS_MODULE = "tests.test_structures.test_structure_1.config.settings" # noqa
@ -49,8 +56,12 @@ class TestLoaderSettingsModule(SimpleTestCase):
loader = Loader(current_engine)
dirs = loader.get_dirs()
self.assertEqual(
dirs,
[Path(__file__).parent.resolve() / "test_structures" / "test_structure_1" / "components"],
sorted(dirs),
sorted(
[
Path(__file__).parent.resolve() / "test_structures" / "test_structure_1" / "components",
]
),
)
def test_complex_settings_module_2(self):
@ -60,8 +71,16 @@ class TestLoaderSettingsModule(SimpleTestCase):
loader = Loader(current_engine)
dirs = loader.get_dirs()
self.assertEqual(
dirs,
[Path(__file__).parent.resolve() / "test_structures" / "test_structure_2" / "project" / "components"],
sorted(dirs),
sorted(
[
Path(__file__).parent.resolve()
/ "test_structures"
/ "test_structure_2"
/ "project"
/ "components",
]
),
)
def test_complex_settings_module_3(self):
@ -71,8 +90,8 @@ class TestLoaderSettingsModule(SimpleTestCase):
loader = Loader(current_engine)
dirs = loader.get_dirs()
expected = [
(Path(__file__).parent.resolve() / "test_structures" / "test_structure_3" / "components"),
(Path(__file__).parent.resolve() / "test_structures" / "test_structure_3" / "project" / "components"),
Path(__file__).parent.resolve() / "test_structures" / "test_structure_3" / "components",
Path(__file__).parent.resolve() / "test_structures" / "test_structure_3" / "project" / "components",
]
self.assertEqual(
sorted(dirs),
@ -93,5 +112,7 @@ class TestBaseDir(SimpleTestCase):
current_engine = Engine.get_default()
loader = Loader(current_engine)
dirs = loader.get_dirs()
expected = [Path(__file__).parent.resolve() / "test_structures" / "test_structure_1" / "components"]
self.assertEqual(dirs, expected)
expected = [
Path(__file__).parent.resolve() / "test_structures" / "test_structure_1" / "components",
]
self.assertEqual(sorted(dirs), sorted(expected))

View file

@ -1,7 +1,9 @@
from pathlib import Path
from textwrap import dedent
from django.core.exceptions import ImproperlyConfigured
from django.template import Context, Template
from django.test import override_settings
# isort: off
from .django_test_setup import * # NOQA
@ -383,6 +385,38 @@ class ComponentMediaTests(SimpleTestCase):
),
)
@override_settings(
BASE_DIR=Path(__file__).resolve().parent,
STATICFILES_DIRS=[
Path(__file__).resolve().parent / "components",
],
)
def test_component_media_with_dict_with_relative_paths(self):
from .components.relative_file.relative_file import RelativeFileComponent
comp = RelativeFileComponent("")
self.assertHTMLEqual(
comp.render_dependencies(),
dedent(
"""\
<link href="relative_file/relative_file.css" media="all" rel="stylesheet">
<script src="relative_file/relative_file.js"></script>
"""
),
)
rendered = comp.render(Context(comp.get_context_data(variable="test")))
self.assertHTMLEqual(
rendered,
"""
<form method="post">
<input type="text" name="variable" value="test">
<input type="submit">
</form>
""",
)
class ComponentIsolationTests(SimpleTestCase):
def setUp(self):

View file

@ -246,9 +246,13 @@ class ContextCalledOnceTests(SimpleTestCase):
"{% load component_tags %}{% component_dependencies %}"
"{% component name='incrementer' %}{% endcomponent %}"
)
rendered = template.render(Context()).strip()
self.assertEqual(rendered, '<p class="incrementer">value=1;calls=1</p>', rendered)
rendered = template.render(Context()).strip().replace("\n", "")
self.assertHTMLEqual(
rendered,
'<link href="relative_file/relative_file.css" media="all" rel="stylesheet">'
'<script src="relative_file/relative_file.js"></script>'
'<p class="incrementer">value=1;calls=1</p>',
)
def test_one_context_call_with_simple_component_and_arg(self):
template = Template("{% load component_tags %}{% component name='incrementer' value='2' %}{% endcomponent %}")