refactor: Remove safer_staticfiles, replace STATICFILES_DIRS with COMPONENTS.dirs, support [app]/components (#652)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Juro Oravec 2024-09-11 08:45:55 +02:00 committed by GitHub
parent 728b4ffad7
commit e1382d3ccd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1034 additions and 264 deletions

249
README.md
View file

@ -76,6 +76,16 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
## Release notes
🚨📢 **Version 0.100**
- BREAKING CHANGE:
- `django_components.safer_staticfiles` app was removed. It is no longer needed.
- Installation changes:
- Instead of defining component directories in `STATICFILES_DIRS`, set them to [`COMPONENTS.dirs`](#dirs).
- You now must define `STATICFILES_FINDERS`
- [See here how to migrate your settings.py](https://github.com/EmilStenstrom/django-components/blob/master/docs/migrating_from_safer_staticfiles.md)
- Beside the top-level `/components` directory, you can now define also app-level components dirs, e.g. `[app]/components`
(See [`COMPONENTS.app_dirs`](#app_dirs)).
**Version 0.97**
- Fixed template caching. You can now also manually create cached templates with [`cached_template()`](#template_cache_size---tune-the-template-cache)
- The previously undocumented `get_template` was made private.
@ -196,14 +206,25 @@ _You are advised to read this section before using django-components in producti
Components can be organized however you prefer.
That said, our prefered way is to keep the files of a component close together by bundling them in the same directory.
This means that files containing backend logic, such as Python modules and HTML templates, live in the same directory as static files, e.g. JS and CSS.
If your are using _django.contrib.staticfiles_ to collect static files, no distinction is made between the different kinds of files.
From v0.100 onwards, we keep component files (as defined by [`COMPONENTS.dirs`](#dirs) and [`COMPONENTS.app_dirs`](#app_dirs)) separate from the rest of the static
files (defined by `STATICFILES_DIRS`). That way, the Python and HTML files are NOT exposed by the server. Only the static JS, CSS, and [other common formats](#static_files_allowed).
> NOTE: If you need to expose different file formats, you can configure these with [`COMPONENTS.static_files_allowed`](#static_files_allowed)
and [`COMPONENTS.static_files_forbidden`](#static_files_forbidden).
<!-- # TODO_REMOVE_IN_V1 - Remove mentions of safer_staticfiles in V1 -->
#### Static files prior to v0.100
Prior to v0.100, if your were using _django.contrib.staticfiles_ to collect static files, no distinction was made between the different kinds of files.
As a result, your Python code and templates may inadvertently become available on your static file server.
You probably don't want this, as parts of your backend logic will be exposed, posing a **potential security vulnerability**.
As of _v0.27_, django-components ships with an additional installable app _django_components.**safer_staticfiles**_.
It is a drop-in replacement for _django.contrib.staticfiles_.
From _v0.27_ until _v0.100_, django-components shipped with an additional installable app _django_components.**safer_staticfiles**_.
It was a drop-in replacement for _django.contrib.staticfiles_.
Its behavior is 100% identical except it ignores .py and .html files, meaning these will not end up on your static files server.
To use it, add it to INSTALLED_APPS and remove _django.contrib.staticfiles_.
@ -235,11 +256,11 @@ For a step-by-step guide on deploying production server with static files,
## Installation
1. Install the app into your environment:
1. Install `django_components` into your environment:
> `pip install django_components`
2. Then add the app into `INSTALLED_APPS` in settings.py
2. Load `django_components` into Django by adding it into `INSTALLED_APPS` in settings.py:
```python
INSTALLED_APPS = [
@ -248,13 +269,33 @@ For a step-by-step guide on deploying production server with static files,
]
```
3. Ensure that `BASE_DIR` setting is defined in settings.py:
3. `BASE_DIR` setting is required. Ensure that it is defined in settings.py:
```py
BASE_DIR = Path(__file__).resolve().parent.parent
```
4. Modify `TEMPLATES` section of settings.py as follows:
4. Add / modify [`COMPONENTS.dirs`](#dirs) and / or [`COMPONENTS.app_dirs`](#app_dirs) so django_components knows where to find component HTML, JS and CSS files:
```python
COMPONENTS = {
"dirs": [
...,
os.path.join(BASE_DIR, "components"),
],
}
```
If `COMPONENTS.dirs` is omitted, django-components will by default look for a top-level `/components` directory,
`{BASE_DIR}/components`.
Irrespective of `COMPONENTS.dirs`, django_components will also load components from app-level directories, e.g. `my-app/components/`.
The directories within apps are configured with [`COMPONENTS.app_dirs`](#app_dirs), and the default is `[app]/components`.
NOTE: The input to `COMPONENTS.dirs` is the same as for `STATICFILES_DIRS`, and the paths must be full paths. [See Django docs](https://docs.djangoproject.com/en/5.0/ref/settings/#staticfiles-dirs).
5. Next, to make Django load component HTML files as Django templates, modify `TEMPLATES` section of settings.py as follows:
- _Remove `'APP_DIRS': True,`_
- NOTE: Instead of APP_DIRS, for the same effect, we will use [`django.template.loaders.app_directories.Loader`](https://docs.djangoproject.com/en/5.1/ref/templates/api/#django.template.loaders.app_directories.Loader)
@ -283,19 +324,18 @@ For a step-by-step guide on deploying production server with static files,
]
```
5. Modify `STATICFILES_DIRS` (or add it if you don't have it) so django can find your static JS and CSS files:
6. Lastly, be able to serve the component JS and CSS files as static files, modify `STATICFILES_FINDERS` section of settings.py as follows:
```python
STATICFILES_DIRS = [
...,
os.path.join(BASE_DIR, "components"),
]
```
```py
STATICFILES_FINDERS = [
# Default finders
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
# Django components
"django_components.finders.ComponentsFileSystemFinder",
]
```
If `STATICFILES_DIRS` is omitted or empty, django-components will by default look for
`{BASE_DIR}/components`
NOTE: The paths in `STATICFILES_DIRS` must be full paths. [See Django docs](https://docs.djangoproject.com/en/5.0/ref/settings/#staticfiles-dirs).
### Optional
@ -397,7 +437,7 @@ class Calendar(Component):
# Templates inside `[your apps]/components` dir and `[project root]/components` dir
# will be automatically found.
#
# `template_name` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS
# `template_name` can be relative to dir where `calendar.py` is, or relative to COMPONENTS.dirs
template_name = "template.html"
# Or
def get_template_name(context):
@ -409,7 +449,7 @@ class Calendar(Component):
"date": date,
}
# Both `css` and `js` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS
# Both `css` and `js` can be relative to dir where `calendar.py` is, or relative to COMPONENTS.dirs
class Media:
css = "style.css"
js = "script.js"
@ -1230,7 +1270,7 @@ class MyAppConfig(AppConfig):
However, there's a simpler way!
By default, the Python files in the `STATICFILES_DIRS` directories are auto-imported in order to auto-register the components.
By default, the Python files in the `COMPONENTS.dirs` directories (or app-level `[app]/components/`) are auto-imported in order to auto-register the components.
Autodiscovery occurs when Django is loaded, during the `ready` hook of the `apps.py` file.
@ -1589,17 +1629,17 @@ Consider a component with slot(s). This component may do some processing on the
```py
@register("my_comp")
class MyComp(Component):
template = """
<div>
{% slot "content" default %}
input: {{ input }}
{% endslot %}
</div>
"""
template = """
<div>
{% slot "content" default %}
input: {{ input }}
{% endslot %}
</div>
"""
def get_context_data(self, input):
processed_input = do_something(input)
return {"input": processed_input}
def get_context_data(self, input):
processed_input = do_something(input)
return {"input": processed_input}
```
You may want to design a component so that users of your component can still access the `input` variable, so they don't have to recompute it.
@ -1618,17 +1658,17 @@ To pass the data to the `slot` tag, simply pass them as keyword attributes (`key
```py
@register("my_comp")
class MyComp(Component):
template = """
<div>
{% slot "content" default input=input %}
input: {{ input }}
{% endslot %}
</div>
"""
template = """
<div>
{% slot "content" default input=input %}
input: {{ input }}
{% endslot %}
</div>
"""
def get_context_data(self, input):
processed_input = do_something(input)
return {
def get_context_data(self, input):
processed_input = do_something(input)
return {
"input": processed_input,
}
```
@ -1962,13 +2002,13 @@ Assuming that:
class_from_var = "from-var"
attrs = {
"class": "from-attrs",
"type": "submit",
"class": "from-attrs",
"type": "submit",
}
defaults = {
"class": "from-defaults",
"role": "button",
"class": "from-defaults",
"role": "button",
}
```
@ -2873,9 +2913,9 @@ class Calendar(Component):
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`.
Alternatively, you can specify the file paths relative to the directories set in `COMPONENTS.dirs` or `COMPONENTS.app_dirs`.
Assuming that `STATICFILES_DIRS` contains path `[project root]/components`, we can rewrite the example as:
Assuming that `COMPONENTS.dirs` contains path `[project root]/components`, we can rewrite the example as:
```py
# In a file [project root]/components/calendar/calendar.py
@ -2971,7 +3011,7 @@ In the example [above](#supported-types-for-file-paths), you could see that when
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`.
Because of that, the paths defined as "safe" strings are NEVER resolved, neither relative to component's directory, nor relative to `COMPONENTS.dirs`.
"Safe" strings can be used to lazily resolve a path, or to customize the `<script>` or `<link>` tag for individual paths:
@ -3091,10 +3131,27 @@ Here's overview of all available settings and their defaults:
COMPONENTS = {
"autodiscover": True,
"context_behavior": "django", # "django" | "isolated"
"dirs": [BASE_DIR / "components"], # Root-level "components" dirs, e.g. `/path/to/proj/components/`
"app_dirs": ["components"], # App-level "components" dirs, e.g. `[app]/components/`
"dynamic_component_name": "dynamic",
"libraries": [], # ["mysite.components.forms", ...]
"multiline_tags": True,
"reload_on_template_change": False,
"static_files_allowed": [
".css",
".js",
# Images
".apng", ".png", ".avif", ".gif", ".jpg",
".jpeg", ".jfif", ".pjpeg", ".pjp", ".svg",
".webp", ".bmp", ".ico", ".cur", ".tif", ".tiff",
# Fonts
".eot", ".ttf", ".woff", ".otf", ".svg",
],
"static_files_forbidden": [
".html", ".django", ".dj", ".tpl",
# Python files
".py", ".pyc",
],
"tag_formatter": "django_components.component_formatter",
"template_cache_size": 128,
}
@ -3152,6 +3209,46 @@ COMPONENTS = {
}
```
### `dirs`
Specify the directories that contain your components.
Directories must be full paths, same as with STATICFILES_DIRS.
These locations are searched during autodiscovery, or when you define HTML, JS, or CSS as
a separate file.
```py
COMPONENTS = {
"dirs": [BASE_DIR / "components"],
}
```
### `app_dirs`
Specify the app-level directories that contain your components.
Directories must be relative to app, e.g.:
```py
COMPONENTS = {
"app_dirs": ["my_comps"], # To search for [app]/my_comps
}
```
These locations are searched during autodiscovery, or when you define HTML, JS, or CSS as
a separate file.
Each app will be searched for these directories.
Set to empty list to disable app-level components:
```py
COMPONENTS = {
"app_dirs": [],
}
```
### `dynamic_component_name`
By default, the dynamic component is registered under the name `"dynamic"`. In case of a conflict, use this setting to change the name used for the dynamic components.
@ -3172,6 +3269,60 @@ COMPONENTS = {
}
```
### `static_files_allowed`
A list of regex patterns (as strings) that define which files within `COMPONENTS.dirs` and `COMPONENTS.app_dirs`
are treated as static files.
If a file is matched against any of the patterns, it's considered a static file. Such files are collected
when running `collectstatic`, and can be accessed under the static file endpoint.
You can also pass in compiled regexes (`re.Pattern`) for more advanced patterns.
By default, JS, CSS, and common image and font file formats are considered static files:
```python
COMPONENTS = {
"static_files_allowed": [
"css",
"js",
# Images
".apng", ".png",
".avif",
".gif",
".jpg", ".jpeg", ".jfif", ".pjpeg", ".pjp", # JPEG
".svg",
".webp", ".bmp",
".ico", ".cur", # ICO
".tif", ".tiff",
# Fonts
".eot", ".ttf", ".woff", ".otf", ".svg",
],
}
```
### `static_files_forbidden`
A list of suffixes that define which files within `COMPONENTS.dirs` and `COMPONENTS.app_dirs`
will NEVER be treated as static files.
If a file is matched against any of the patterns, it will never be considered a static file, even if the file matches
a pattern in [`COMPONENTS.static_files_allowed`](#static_files_allowed).
Use this setting together with `COMPONENTS.static_files_allowed` for a fine control over what files will be exposed.
You can also pass in compiled regexes (`re.Pattern`) for more advanced patterns.
By default, any HTML and Python are considered NOT static files:
```python
COMPONENTS = {
"static_files_forbidden": [
".html", ".django", ".dj", ".tpl", ".py", ".pyc",
],
}
```
### `template_cache_size` - Tune the template cache
Each time a template is rendered it is cached to a global in-memory cache (using Python's `lru_cache` decorator). This speeds up the next render of the component. As the same component is often used many times on the same page, these savings add up.
@ -3600,7 +3751,7 @@ You can publish and share your components for others to use. Here are the steps
4. Import the components in `apps.py`
Normally, users rely on [autodiscovery](#autodiscovery) and `STATICFILES_DIRS` to load the component files.
Normally, users rely on [autodiscovery](#autodiscovery) and `COMPONENTS.dirs` to load the component files.
Since you, as the library author, are not in control of the file system, it is recommended to load the components manually.

View file

@ -0,0 +1,98 @@
# Migrating from safer_staticfiles
This guide is for you if you're upgrating django_components to v0.100 or later
from older versions.
In version 0.100, we changed how components' static JS and CSS files are handled.
See more in the ["Static files" section](https://github.com/EmilStenstrom/django-components/tree/master/sampleproject).
Migration steps:
1. Remove `django_components.safer_staticfiles` from `INSTALLED_APPS` in your `settings.py`,
and replace it with `django.contrib.staticfiles`.
Before:
```py
INSTALLED_APPS = [
"django.contrib.admin",
...
# "django.contrib.staticfiles", # <-- ADD
"django_components",
"django_components.safer_staticfiles", # <-- REMOVE
]
```
After:
```py
INSTALLED_APPS = [
"django.contrib.admin",
...
"django.contrib.staticfiles",
"django_components",
]
```
2. Add `STATICFILES_FINDERS` to `settings.py`, and add `django_components.finders.ComponentsFileSystemFinder`:
```py
STATICFILES_FINDERS = [
# Default finders
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
# Django components
"django_components.finders.ComponentsFileSystemFinder", # <-- ADDED
]
```
3. Add `COMPONENTS.dirs` to `settings.py`.
If you previously defined `STATICFILES_DIRS`, move
only those directories from `STATICFILES_DIRS` that point to components directories, and keep the rest.
E.g. if you have `STATICFILES_DIRS` like this:
```py
STATICFILES_DIRS = [
BASE_DIR / "components", # <-- MOVE
BASE_DIR / "myapp" / "components", # <-- MOVE
BASE_DIR / "assets",
]
```
Then first two entries point to components dirs, whereas `/assets` points to non-component static files.
In this case move only the first two paths:
```py
COMPONENTS = {
"dirs": [
BASE_DIR / "components", # <-- MOVED
BASE_DIR / "myapp" / "components", # <-- MOVED
],
}
STATICFILES_DIRS = [
BASE_DIR / "assets",
]
```
Moreover, if you defined app-level component directories in `STATICFILES_DIRS` before,
you can now define as a RELATIVE path in `app_dirs`:
```py
COMPONENTS = {
"dirs": [
# Search top-level "/components/" dir
BASE_DIR / "components",
],
"app_dirs": [
# Search "/[app]/components/" dirs
"components",
],
}
STATICFILES_DIRS = [
BASE_DIR / "assets",
]
```

View file

@ -54,7 +54,7 @@ even for production environment.
Assuming that you're running the prod server with:
1. `DEBUG = False` setting
2. `"django_components.safer_staticfiles"` in the `INSTALLED_APPS`
2. `"django.contrib.staticfiles"` in the `INSTALLED_APPS`
Then Django will server only JS and CSS files under the `/static/` URL path.

View file

@ -6,7 +6,7 @@ class Calendar(Component):
# Templates inside `[your apps]/components` dir and `[project root]/components` dir
# will be automatically found.
#
# `template_name` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS
# `template_name` can be relative to dir where `calendar.py` is, or relative to COMPONENTS.dirs
template_name = "calendar/calendar.html"
# Or
# def get_template_name(context):
@ -19,10 +19,11 @@ class Calendar(Component):
}
def get(self, request, *args, **kwargs):
context = {
"date": request.GET.get("date", ""),
}
return self.render_to_response(context)
return self.render_to_response(
kwargs={
"date": request.GET.get("date", ""),
},
)
class Media:
css = "calendar/calendar.css"
@ -34,7 +35,7 @@ class CalendarRelative(Component):
# Templates inside `[your apps]/components` dir and `[project root]/components` dir
# will be automatically found.
#
# `template_name` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS
# `template_name` can be relative to dir where `calendar.py` is, or relative to COMPONENTS.dirs
template_name = "calendar.html"
# Or
# def get_template_name(context):

View file

@ -7,8 +7,12 @@ from django_components import Component, register, types
class Greeting(Component):
def get(self, request, *args, **kwargs):
slots = {"message": "Hello, world!"}
context = {"name": request.GET.get("name", "")}
return self.render_to_response(context=context, slots=slots)
return self.render_to_response(
slots=slots,
kwargs={
"name": request.GET.get("name", ""),
},
)
def get_context_data(self, name, *args, **kwargs) -> Dict[str, Any]:
return {"name": name}

View file

@ -6,7 +6,7 @@ class CalendarNested(Component):
# Templates inside `[your apps]/components` dir and `[project root]/components` dir
# will be automatically found.
#
# `template_name` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS
# `template_name` can be relative to dir where `calendar.py` is, or relative to COMPONENTS.dirs
template_name = "calendar.html"
# Or
# def get_template_name(context):
@ -19,10 +19,11 @@ class CalendarNested(Component):
}
def get(self, request, *args, **kwargs):
context = {
"date": request.GET.get("date", ""),
}
return self.render_to_response(context)
return self.render_to_response(
kwargs={
"date": request.GET.get("date", ""),
},
)
class Media:
css = "calendar.css"

View file

@ -28,10 +28,8 @@ INSTALLED_APPS = [
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
# Replaced by django_components.safer_staticfiles as of v0.27:
# "django.contrib.staticfiles",
"django.contrib.staticfiles",
"django_components",
"django_components.safer_staticfiles",
"calendarapp",
]
# Application definition
@ -79,14 +77,24 @@ TEMPLATES = [
},
]
STATICFILES_FINDERS = [
# Default finders
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
# Django components
"django_components.finders.ComponentsFileSystemFinder",
]
WSGI_APPLICATION = "sampleproject.wsgi.application"
# COMPONENTS = {
# "autodiscover": True,
# "libraries": [],
# "template_cache_size": 128,
# "context_behavior": "isolated", # "django" | "isolated"
# }
COMPONENTS = {
# "autodiscover": True,
"dirs": [BASE_DIR / "components"],
# "app_dirs": ["components"],
# "libraries": [],
# "template_cache_size": 128,
# "context_behavior": "isolated", # "django" | "isolated"
}
# Database
@ -135,7 +143,6 @@ USE_TZ = True
# https://docs.djangoproject.com/en/4.0/howto/static-files/
STATIC_URL = "static/"
STATICFILES_DIRS = [BASE_DIR / "components"]
# Default primary key field type
# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field

View file

@ -1,5 +1,6 @@
import re
from enum import Enum
from typing import TYPE_CHECKING, Dict, List, Union
from typing import TYPE_CHECKING, Dict, List, Tuple, Union
from django.conf import settings
@ -98,6 +99,14 @@ class AppSettings:
def AUTODISCOVER(self) -> bool:
return self.settings.get("autodiscover", True)
@property
def DIRS(self) -> List[Union[str, Tuple[str, str]]]:
return self.settings.get("dirs", [settings.BASE_DIR / "components"])
@property
def APP_DIRS(self) -> List[str]:
return self.settings.get("app_dirs", ["components"])
@property
def DYNAMIC_COMPONENT_NAME(self) -> str:
return self.settings.get("dynamic_component_name", "dynamic")
@ -118,6 +127,51 @@ class AppSettings:
def TEMPLATE_CACHE_SIZE(self) -> int:
return self.settings.get("template_cache_size", 128)
@property
def STATIC_FILES_ALLOWED(self) -> List[Union[str, re.Pattern]]:
default_static_files = [
".css",
".js",
# Images - See https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types#common_image_file_types # noqa: E501
".apng",
".png",
".avif",
".gif",
".jpg",
".jpeg",
".jfif",
".pjpeg",
".pjp",
".svg",
".webp",
".bmp",
".ico",
".cur",
".tif",
".tiff",
# Fonts - See https://stackoverflow.com/q/30572159/9788634
".eot",
".ttf",
".woff",
".otf",
".svg",
]
return self.settings.get("static_files_allowed", default_static_files)
@property
def STATIC_FILES_FORBIDDEN(self) -> List[Union[str, re.Pattern]]:
default_forbidden_static_files = [
".html",
# See https://marketplace.visualstudio.com/items?itemName=junstyle.vscode-django-support
".django",
".dj",
".tpl",
# Python files
".py",
".pyc",
]
return self.settings.get("forbidden_static_files", default_forbidden_static_files)
@property
def CONTEXT_BEHAVIOR(self) -> ContextBehavior:
raw_value = self.settings.get("context_behavior", ContextBehavior.DJANGO.value)

View file

@ -5,10 +5,9 @@ from pathlib import Path
from typing import Callable, List, Optional, Union
from django.conf import settings
from django.template.engine import Engine
from django_components.logger import logger
from django_components.template_loader import Loader
from django_components.template_loader import get_dirs
def autodiscover(
@ -27,7 +26,13 @@ def autodiscover(
component_filepaths = search_dirs(dirs, "**/*.py")
logger.debug(f"Autodiscover found {len(component_filepaths)} files in component directories.")
modules = [_filepath_to_python_module(filepath) for filepath in component_filepaths]
modules: List[str] = []
for filepath in component_filepaths:
module_path = _filepath_to_python_module(filepath)
# Ignore relative paths that are outside of the project root
if not module_path.startswith(".."):
modules.append(module_path)
return _import_modules(modules, map_module)
@ -89,19 +94,6 @@ def _filepath_to_python_module(file_path: Union[Path, str]) -> str:
return module_name
def get_dirs(engine: Optional[Engine] = None) -> List[Path]:
"""
Helper for using django_component's FilesystemLoader class to obtain a list
of directories where component python files may be defined.
"""
current_engine = engine
if current_engine is None:
current_engine = Engine.get_default()
loader = Loader(current_engine)
return loader.get_dirs()
def search_dirs(dirs: List[Path], search_glob: str) -> List[Path]:
"""
Search the directories for the given glob pattern. Glob search results are returned

View file

@ -284,7 +284,7 @@ def _resolve_component_relative_files(attrs: MutableMapping) -> None:
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."
" specified in the Django's 'COMPONENTS.dirs' settings."
)
return
@ -327,7 +327,7 @@ def _get_dir_path_from_component_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
# 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:
@ -341,7 +341,7 @@ def _get_dir_path_from_component_path(
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.
# 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)
# Return both absolute and relative paths:

View file

@ -0,0 +1,154 @@
import os
import re
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
from django.contrib.staticfiles.finders import BaseFinder
from django.contrib.staticfiles.utils import get_files
from django.core.checks import CheckMessage, Error, Warning
from django.core.files.storage import FileSystemStorage
from django.utils._os import safe_join
from django_components.app_settings import app_settings
from django_components.template_loader import get_dirs
from django_components.utils import any_regex_match, no_regex_match
# To keep track on which directories the finder has searched the static files.
searched_locations = []
# Custom Finder for staticfiles that searches for all files within the directories
# defined by `COMPONENTS.dirs`.
#
# This is what makes it possible to define JS and CSS files in the directories as
# defined by `COMPONENTS.dirs`, but still use the JS / CSS files with `static()` or
# `collectstatic` command.
class ComponentsFileSystemFinder(BaseFinder):
"""
A static files finder based on `FileSystemFinder`.
Differences:
- This finder uses `COMPONENTS.dirs` setting to locate files instead of `STATICFILES_DIRS`.
- Whether a file within `COMPONENTS.dirs` is considered a STATIC file is configured
by `COMPONENTS.static_files_allowed` and `COMPONENTS.forbidden_static_files`.
- If `COMPONENTS.dirs` is not set, defaults to `settings.BASE_DIR / "components"`
"""
def __init__(self, app_names: Any = None, *args: Any, **kwargs: Any) -> None:
component_dirs = [str(p) for p in get_dirs()]
# NOTE: The rest of the __init__ is the same as `django.contrib.staticfiles.finders.FileSystemFinder`,
# but using our locations instead of STATICFILES_DIRS.
# List of locations with static files
self.locations: List[Tuple[str, str]] = []
# Maps dir paths to an appropriate storage instance
self.storages: Dict[str, FileSystemStorage] = {}
for root in component_dirs:
if isinstance(root, (list, tuple)):
prefix, root = root
else:
prefix = ""
if (prefix, root) not in self.locations:
self.locations.append((prefix, root))
for prefix, root in self.locations:
filesystem_storage = FileSystemStorage(location=root)
filesystem_storage.prefix = prefix
self.storages[root] = filesystem_storage
super().__init__(*args, **kwargs)
# NOTE: Based on `FileSystemFinder.check`
def check(self, **kwargs: Any) -> List[CheckMessage]:
errors: List[CheckMessage] = []
if not isinstance(app_settings.DIRS, (list, tuple)):
errors.append(
Error(
"The COMPONENTS.dirs setting is not a tuple or list.",
hint="Perhaps you forgot a trailing comma?",
id="components.E001",
)
)
return errors
for root in app_settings.DIRS:
if isinstance(root, (list, tuple)):
prefix, root = root
if prefix.endswith("/"):
errors.append(
Error(
"The prefix %r in the COMPONENTS.dirs setting must not end with a slash." % prefix,
id="staticfiles.E003",
)
)
elif not os.path.isdir(root):
errors.append(
Warning(
f"The directory '{root}' in the COMPONENTS.dirs setting does not exist.",
id="components.W004",
)
)
return errors
# NOTE: Same as `FileSystemFinder.find`
def find(self, path: str, all: bool = False) -> Union[List[str], str]:
"""
Look for files in the extra locations as defined in COMPONENTS.dirs.
"""
matches: List[str] = []
for prefix, root in self.locations:
if root not in searched_locations:
searched_locations.append(root)
matched_path = self.find_location(root, path, prefix)
if matched_path:
if not all:
return matched_path
matches.append(matched_path)
return matches
# NOTE: Same as `FileSystemFinder.find_local`, but we exclude Python/HTML files
def find_location(self, root: str, path: str, prefix: Optional[str] = None) -> Optional[str]:
"""
Find a requested static file in a location and return the found
absolute path (or ``None`` if no match).
"""
if prefix:
prefix = "%s%s" % (prefix, os.sep)
if not path.startswith(prefix):
return None
path = path.removeprefix(prefix)
path = safe_join(root, path)
if os.path.exists(path) and self._is_path_valid(path):
return path
return None
# `Finder.list` is called from `collectstatic` command,
# see https://github.com/django/django/blob/bc9b6251e0b54c3b5520e3c66578041cc17e4a28/django/contrib/staticfiles/management/commands/collectstatic.py#L126C23-L126C30 # noqa E501
#
# NOTE: This is same as `FileSystemFinder.list`, but we exclude Python/HTML files
# NOTE 2: Yield can be annotated as Iterable, see https://stackoverflow.com/questions/38419654
def list(self, ignore_patterns: List[str]) -> Iterable[Tuple[str, FileSystemStorage]]:
"""
List all files in all locations.
"""
for prefix, root in self.locations:
# Skip nonexistent directories.
if os.path.isdir(root):
storage = self.storages[root]
for path in get_files(storage, ignore_patterns):
if self._is_path_valid(path):
yield path, storage
def _is_path_valid(self, path: str) -> bool:
# Normalize patterns to regexes
allowed_patterns = [
# Convert suffixes like `.html` to regex `\.html$`
re.compile(rf"\{p}$") if isinstance(p, str) else p
for p in app_settings.STATIC_FILES_ALLOWED
]
forbidden_patterns = [
# Convert suffixes like `.html` to regex `\.html$`
re.compile(rf"\{p}$") if isinstance(p, str) else p
for p in app_settings.STATIC_FILES_FORBIDDEN
]
return any_regex_match(path, allowed_patterns) and no_regex_match(path, forbidden_patterns)

View file

@ -1,22 +0,0 @@
from django.contrib.staticfiles.apps import StaticFilesConfig
class SaferStaticFilesConfig(StaticFilesConfig):
"""
Extend the `ignore_patterns` class attr of StaticFilesConfig to include Python
modules and HTML files.
When this class is registered as an installed app,
`$ ./manage.py collectstatic` will ignore .py and .html files,
preventing potentially sensitive backend logic from being leaked
by the static file server.
See https://docs.djangoproject.com/en/5.0/ref/contrib/staticfiles/#customizing-the-ignored-pattern-list
"""
default = True # Ensure that _this_ app is registered, as opposed to parent cls.
ignore_patterns = StaticFilesConfig.ignore_patterns + [
"*.py",
"*.html",
"*.pyc",
]

View file

@ -3,55 +3,92 @@ Template loader that loads templates from each Django app's "components" directo
"""
from pathlib import Path
from typing import List, Set
from typing import List, Optional, Set
from django.apps import apps
from django.conf import settings
from django.template.engine import Engine
from django.template.loaders.filesystem import Loader as FilesystemLoader
from django_components.app_settings import app_settings
from django_components.logger import logger
# Similar to `Path.is_relative_to`, which is missing in 3.8
def is_relative_to(path: Path, other: Path) -> bool:
try:
path.relative_to(other)
return True
except ValueError:
return False
# This is the heart of all features that deal with filesystem and file lookup.
# Autodiscovery, Django template resolution, static file resolution - They all
# depend on this loader.
class Loader(FilesystemLoader):
def get_dirs(self) -> List[Path]:
"""
Prepare directories that may contain component files:
Searches for dirs set in `STATICFILES_DIRS` settings. If none set, defaults to searching
for a "components" app. The dirs in `STATICFILES_DIRS` must be absolute paths.
Searches for dirs set in `COMPONENTS.dirs` settings. If none set, defaults to searching
for a "components" app. The dirs in `COMPONENTS.dirs` must be absolute paths.
In addition to that, also all apps are checked for `[app]/components` dirs.
Paths are accepted only if they resolve to a directory.
E.g. `/path/to/django_project/my_app/components/`.
If `STATICFILES_DIRS` is not set or empty, then `BASE_DIR` is required.
`BASE_DIR` setting is required.
"""
# Allow to configure from settings which dirs should be checked for components
if hasattr(settings, "STATICFILES_DIRS") and settings.STATICFILES_DIRS:
component_dirs = settings.STATICFILES_DIRS
else:
component_dirs = [settings.BASE_DIR / "components"]
component_dirs = app_settings.DIRS
# TODO_REMOVE_IN_V1
is_legacy_paths = (
# Use value of `STATICFILES_DIRS` ONLY if `COMPONENT.dirs` not set
not getattr(settings, "COMPONENTS", {}).get("dirs", None) is not None
and hasattr(settings, "STATICFILES_DIRS")
and settings.STATICFILES_DIRS
)
if is_legacy_paths:
# NOTE: For STATICFILES_DIRS, we use the defaults even for empty list.
# We don't do this for COMPONENTS.dirs, so user can explicitly specify "NO dirs".
component_dirs = settings.STATICFILES_DIRS or [settings.BASE_DIR / "components"]
source = "STATICFILES_DIRS" if is_legacy_paths else "COMPONENTS.dirs"
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()
# Add `[app]/[APP_DIR]` to the directories. This is, by default `[app]/components`
app_paths: List[Path] = []
for conf in apps.get_app_configs():
for app_dir in app_settings.APP_DIRS:
comps_path = Path(conf.path).joinpath(app_dir)
if comps_path.exists() and is_relative_to(comps_path, settings.BASE_DIR):
app_paths.append(comps_path)
directories: Set[Path] = set(app_paths)
# Validate and add other values from the config
for component_dir in component_dirs:
# Consider tuples for STATICFILES_DIRS (See #489)
# See https://docs.djangoproject.com/en/5.0/ref/settings/#prefixes-optional
if isinstance(component_dir, (tuple, list)) and len(component_dir) == 2:
if isinstance(component_dir, (tuple, list)):
component_dir = component_dir[1]
try:
Path(component_dir)
except TypeError:
logger.warning(
f"STATICFILES_DIRS expected str, bytes or os.PathLike object, or tuple/list of length 2. "
f"See Django documentation. Got {type(component_dir)} : {component_dir}"
f"{source} expected str, bytes or os.PathLike object, or tuple/list of length 2. "
f"See Django documentation for STATICFILES_DIRS. Got {type(component_dir)} : {component_dir}"
)
continue
if not Path(component_dir).is_absolute():
raise ValueError(f"STATICFILES_DIRS must contain absolute paths, got '{component_dir}'")
raise ValueError(f"{source} must contain absolute paths, got '{component_dir}'")
else:
directories.add(Path(component_dir).resolve())
@ -59,3 +96,16 @@ class Loader(FilesystemLoader):
"Template loader matched following template dirs:\n" + "\n".join([f" - {str(d)}" for d in directories])
)
return list(directories)
def get_dirs(engine: Optional[Engine] = None) -> List[Path]:
"""
Helper for using django_component's FilesystemLoader class to obtain a list
of directories where component python files may be defined.
"""
current_engine = engine
if current_engine is None:
current_engine = Engine.get_default()
loader = Loader(current_engine)
return loader.get_dirs()

View file

@ -1,4 +1,5 @@
import functools
import re
import sys
import typing
from pathlib import Path
@ -211,3 +212,11 @@ def lazy_cache(
return cast(TFunc, wrapper)
return decorator
def any_regex_match(string: str, patterns: List[re.Pattern]) -> bool:
return any(p.search(string) is not None for p in patterns)
def no_regex_match(string: str, patterns: List[re.Pattern]) -> bool:
return all(p.search(string) is None for p in patterns)

View file

@ -3,14 +3,14 @@ from typing import Any, Dict
from django_components import Component, register
# Used for testing the safer_staticfiles app in `test_safer_staticfiles.py`
@register("safer_staticfiles_component")
# Used for testing the staticfiles finder in `test_staticfiles.py`
@register("staticfiles_component")
class RelativeFileWithPathObjComponent(Component):
template_name = "safer_staticfiles.html"
template_name = "staticfiles.html"
class Media:
js = "safer_staticfiles.js"
css = "safer_staticfiles.css"
js = "staticfiles.js"
css = "staticfiles.css"
def get_context_data(self, variable, *args, **kwargs) -> Dict[str, Any]:
return {"variable": variable}

View file

@ -11,7 +11,7 @@ def setup_test_config(components: Optional[Dict] = None):
settings.configure(
BASE_DIR=Path(__file__).resolve().parent,
INSTALLED_APPS=("django_components",),
INSTALLED_APPS=("django_components", "tests.test_app"),
TEMPLATES=[
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
@ -32,6 +32,7 @@ def setup_test_config(components: Optional[Dict] = None):
"NAME": ":memory:",
}
},
SECRET_KEY="secret",
)
django.setup()

6
tests/test_app/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class TestAppConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "tests.test_app"

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,16 @@
from typing import Any, Dict
from django_components import Component, register
# Used for testing the template_loader
@register("app_lvl_comp")
class AppLvlCompComponent(Component):
template_name = "app_lvl_comp.html"
class Media:
js = "app_lvl_comp.js"
css = "app_lvl_comp.css"
def get_context_data(self, variable, *args, **kwargs) -> Dict[str, Any]:
return {"variable": variable}

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,16 @@
from typing import Any, Dict
from django_components import Component, register
# Used for testing the template_loader
@register("app_lvl_comp")
class AppLvlCompComponent(Component):
template_name = "app_lvl_comp.html"
class Media:
js = "app_lvl_comp.js"
css = "app_lvl_comp.css"
def get_context_data(self, variable, *args, **kwargs) -> Dict[str, Any]:
return {"variable": variable}

View file

@ -618,10 +618,9 @@ class MediaStaticfilesTests(BaseTestCase):
# 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.
# `django.contrib.staticfiles` MUST be installed for staticfiles resolution to work.
INSTALLED_APPS=[
"django_components.safer_staticfiles", # Or django.contrib.staticfiles
"django.contrib.staticfiles",
"django_components",
],
)
@ -675,10 +674,9 @@ class MediaStaticfilesTests(BaseTestCase):
"BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage",
},
},
# Either `django.contrib.staticfiles` or `django_components.safer_staticfiles` MUST
# be installed for staticfiles resolution to work.
# `django.contrib.staticfiles` MUST be installed for staticfiles resolution to work.
INSTALLED_APPS=[
"django_components.safer_staticfiles", # Or django.contrib.staticfiles
"django.contrib.staticfiles",
"django_components",
],
)

210
tests/test_finders.py Normal file
View file

@ -0,0 +1,210 @@
import re
from pathlib import Path
from django.contrib.staticfiles.management.commands.collectstatic import Command
from django.test import SimpleTestCase, override_settings
from .django_test_setup import setup_test_config
setup_test_config({"autodiscover": False})
# This subclass allows us to call the `collectstatic` command from within Python.
# We call the `collect` method, which returns info about what files were collected.
#
# The methods below are overriden to ensure we don't make any filesystem changes
# (copy/delete), as the original command copies files. Thus we can safely test that
# our app works as intended.
class MockCollectstaticCommand(Command):
# NOTE: We do not expect this to be called
def clear_dir(self, path):
raise NotImplementedError()
# NOTE: We do not expect this to be called
def link_file(self, path, prefixed_path, source_storage):
raise NotImplementedError()
def copy_file(self, path, prefixed_path, source_storage):
# Skip this file if it was already copied earlier
if prefixed_path in self.copied_files:
return self.log("Skipping '%s' (already copied earlier)" % path)
# Delete the target file if needed or break
if not self.delete_file(path, prefixed_path, source_storage):
return
# The full path of the source file
source_path = source_storage.path(path)
# Finally start copying
if self.dry_run:
self.log("Pretending to copy '%s'" % source_path, level=1)
else:
self.log("Copying '%s'" % source_path, level=2)
# ############# OUR CHANGE ##############
# with source_storage.open(path) as source_file:
# self.storage.save(prefixed_path, source_file)
# ############# OUR CHANGE ##############
self.copied_files.append(prefixed_path)
def do_collect():
cmd = MockCollectstaticCommand()
cmd.set_options(
interactive=False,
verbosity=1,
link=False,
clear=False,
dry_run=False,
ignore_patterns=[],
use_default_ignore_patterns=True,
post_process=True,
)
collected = cmd.collect()
return collected
common_settings = {
"STATIC_URL": "static/",
"STATIC_ROOT": "staticfiles",
"ROOT_URLCONF": __name__,
"INSTALLED_APPS": ("django_components", "django.contrib.staticfiles"),
}
COMPONENTS = {
"dirs": [Path(__file__).resolve().parent / "components"],
}
class StaticFilesFinderTests(SimpleTestCase):
@override_settings(
**common_settings,
COMPONENTS=COMPONENTS,
STATICFILES_FINDERS=[
# Default finders
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
],
)
def test_python_and_html_included(self):
collected = do_collect()
# Check that the component files are NOT loaded when our finder is NOT added
self.assertNotIn("staticfiles/staticfiles.css", collected["modified"])
self.assertNotIn("staticfiles/staticfiles.js", collected["modified"])
self.assertNotIn("staticfiles/staticfiles.html", collected["modified"])
self.assertNotIn("staticfiles/staticfiles.py", collected["modified"])
self.assertListEqual(collected["unmodified"], [])
self.assertListEqual(collected["post_processed"], [])
@override_settings(
**common_settings,
COMPONENTS=COMPONENTS,
STATICFILES_FINDERS=[
# Default finders
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
# Django components
"django_components.finders.ComponentsFileSystemFinder",
],
)
def test_python_and_html_omitted(self):
collected = do_collect()
# Check that our staticfiles_finder finds the files and OMITS .py and .html files
self.assertIn("staticfiles/staticfiles.css", collected["modified"])
self.assertIn("staticfiles/staticfiles.js", collected["modified"])
self.assertNotIn("staticfiles/staticfiles.html", collected["modified"])
self.assertNotIn("staticfiles/staticfiles.py", collected["modified"])
self.assertListEqual(collected["unmodified"], [])
self.assertListEqual(collected["post_processed"], [])
@override_settings(
**common_settings,
COMPONENTS={
**COMPONENTS,
"static_files_allowed": [
".js",
],
"forbidden_static_files": [],
},
STATICFILES_FINDERS=[
# Default finders
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
# Django components
"django_components.finders.ComponentsFileSystemFinder",
],
)
def test_set_static_files_allowed(self):
collected = do_collect()
# Check that our staticfiles_finder finds the files and OMITS .py and .html files
self.assertNotIn("staticfiles/staticfiles.css", collected["modified"])
self.assertIn("staticfiles/staticfiles.js", collected["modified"])
self.assertNotIn("staticfiles/staticfiles.html", collected["modified"])
self.assertNotIn("staticfiles/staticfiles.py", collected["modified"])
self.assertListEqual(collected["unmodified"], [])
self.assertListEqual(collected["post_processed"], [])
@override_settings(
**common_settings,
COMPONENTS={
**COMPONENTS,
"static_files_allowed": [
re.compile(r".*"),
],
"forbidden_static_files": [
re.compile(r"\.(?:js)$"),
],
},
STATICFILES_FINDERS=[
# Default finders
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
# Django components
"django_components.finders.ComponentsFileSystemFinder",
],
)
def test_set_forbidden_files(self):
collected = do_collect()
# Check that our staticfiles_finder finds the files and OMITS .py and .html files
self.assertIn("staticfiles/staticfiles.css", collected["modified"])
self.assertNotIn("staticfiles/staticfiles.js", collected["modified"])
self.assertIn("staticfiles/staticfiles.html", collected["modified"])
self.assertIn("staticfiles/staticfiles.py", collected["modified"])
self.assertListEqual(collected["unmodified"], [])
self.assertListEqual(collected["post_processed"], [])
@override_settings(
**common_settings,
COMPONENTS={
**COMPONENTS,
"static_files_allowed": [
".js",
".css",
],
"forbidden_static_files": [
".js",
],
},
STATICFILES_FINDERS=[
# Default finders
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
# Django components
"django_components.finders.ComponentsFileSystemFinder",
],
)
def test_set_both_allowed_and_forbidden_files(self):
collected = do_collect()
# Check that our staticfiles_finder finds the files and OMITS .py and .html files
self.assertIn("staticfiles/staticfiles.css", collected["modified"])
self.assertNotIn("staticfiles/staticfiles.js", collected["modified"])
self.assertNotIn("staticfiles/staticfiles.html", collected["modified"])
self.assertNotIn("staticfiles/staticfiles.py", collected["modified"])
self.assertListEqual(collected["unmodified"], [])
self.assertListEqual(collected["post_processed"], [])

View file

@ -56,15 +56,19 @@ class ComponentRegistryTest(unittest.TestCase):
my_lib = Library()
my_reg = ComponentRegistry(library=my_lib)
default_registry_comps_before = len(registry.all())
self.assertDictEqual(my_reg.all(), {})
self.assertDictEqual(registry.all(), {})
@register("decorated_component", registry=my_reg)
class TestComponent(Component):
pass
self.assertDictEqual(my_reg.all(), {"decorated_component": TestComponent})
self.assertDictEqual(registry.all(), {})
# Check that the component was NOT added to the default registry
default_registry_comps_after = len(registry.all())
self.assertEqual(default_registry_comps_before, default_registry_comps_after)
def test_simple_register(self):
self.registry.register(name="testcomponent", component=MockComponent)

View file

@ -1,103 +0,0 @@
from pathlib import Path
from django.contrib.staticfiles.management.commands.collectstatic import Command
from django.test import SimpleTestCase, override_settings
from .django_test_setup import * # NOQA
# This subclass allows us to call the `collectstatic` command from within Python.
# We call the `collect` method, which returns info about what files were collected.
#
# The methods below are overriden to ensure we don't make any filesystem changes
# (copy/delete), as the original command copies files. Thus we can safely test that
# our `safer_staticfiles` app works as intended.
class MockCollectstaticCommand(Command):
# NOTE: We do not expect this to be called
def clear_dir(self, path):
raise NotImplementedError()
# NOTE: We do not expect this to be called
def link_file(self, path, prefixed_path, source_storage):
raise NotImplementedError()
def copy_file(self, path, prefixed_path, source_storage):
# Skip this file if it was already copied earlier
if prefixed_path in self.copied_files:
return self.log("Skipping '%s' (already copied earlier)" % path)
# Delete the target file if needed or break
if not self.delete_file(path, prefixed_path, source_storage):
return
# The full path of the source file
source_path = source_storage.path(path)
# Finally start copying
if self.dry_run:
self.log("Pretending to copy '%s'" % source_path, level=1)
else:
self.log("Copying '%s'" % source_path, level=2)
# ############# OUR CHANGE ##############
# with source_storage.open(path) as source_file:
# self.storage.save(prefixed_path, source_file)
# ############# OUR CHANGE ##############
self.copied_files.append(prefixed_path)
def do_collect():
cmd = MockCollectstaticCommand()
cmd.set_options(
interactive=False,
verbosity=1,
link=False,
clear=False,
dry_run=False,
ignore_patterns=[],
use_default_ignore_patterns=True,
post_process=True,
)
collected = cmd.collect()
return collected
common_settings = {
"STATIC_URL": "static/",
"STATICFILES_DIRS": [Path(__file__).resolve().parent / "components"],
"STATIC_ROOT": "staticfiles",
"ROOT_URLCONF": __name__,
"SECRET_KEY": "secret",
}
# Check that .py and .html files are INCLUDED with the original staticfiles app
@override_settings(
**common_settings,
INSTALLED_APPS=("django_components", "django.contrib.staticfiles"),
)
class OrigStaticFileTests(SimpleTestCase):
def test_python_and_html_included(self):
collected = do_collect()
self.assertIn("safer_staticfiles/safer_staticfiles.css", collected["modified"])
self.assertIn("safer_staticfiles/safer_staticfiles.js", collected["modified"])
self.assertIn("safer_staticfiles/safer_staticfiles.html", collected["modified"])
self.assertIn("safer_staticfiles/safer_staticfiles.py", collected["modified"])
self.assertListEqual(collected["unmodified"], [])
self.assertListEqual(collected["post_processed"], [])
# Check that .py and .html files are OMITTED from our version of staticfiles app
@override_settings(
**common_settings,
INSTALLED_APPS=("django_components", "django_components.safer_staticfiles"),
)
class SaferStaticFileTests(SimpleTestCase):
def test_python_and_html_omitted(self):
collected = do_collect()
self.assertIn("safer_staticfiles/safer_staticfiles.css", collected["modified"])
self.assertIn("safer_staticfiles/safer_staticfiles.js", collected["modified"])
self.assertNotIn("safer_staticfiles/safer_staticfiles.html", collected["modified"])
self.assertNotIn("safer_staticfiles/safer_staticfiles.py", collected["modified"])
self.assertListEqual(collected["unmodified"], [])
self.assertListEqual(collected["post_processed"], [])

View file

@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch
from django.template.engine import Engine
from django.test import override_settings
from django_components.template_loader import Loader
from django_components.template_loader import Loader, get_dirs
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase
@ -12,10 +12,10 @@ from .testutils import BaseTestCase
setup_test_config({"autodiscover": False})
@override_settings(
BASE_DIR=Path(__file__).parent.resolve(),
)
class TemplateLoaderTest(BaseTestCase):
@override_settings(
BASE_DIR=Path(__file__).parent.resolve(),
)
def test_get_dirs__base_dir(self):
current_engine = Engine.get_default()
loader = Loader(current_engine)
@ -24,7 +24,10 @@ class TemplateLoaderTest(BaseTestCase):
sorted(dirs),
sorted(
[
# Top-level /components dir
Path(__file__).parent.resolve() / "components",
# App-level /components dir
Path(__file__).parent.resolve() / "test_app" / "components",
]
),
)
@ -42,35 +45,137 @@ class TemplateLoaderTest(BaseTestCase):
self.assertEqual(sorted(dirs), sorted(expected))
@override_settings(
BASE_DIR=Path(__file__).parent.resolve(),
STATICFILES_DIRS=[
Path(__file__).parent.resolve() / "components",
("with_alias", Path(__file__).parent.resolve() / "components"),
("too_many", "items", Path(__file__).parent.resolve() / "components"),
("too_many", Path(__file__).parent.resolve() / "components", Path(__file__).parent.resolve()),
("with_not_str_alias", 3),
] # noqa
], # noqa
)
@patch("django_components.template_loader.logger.warning")
def test_get_dirs__staticfiles_dirs(self, mock_warning: MagicMock):
def test_get_dirs__components_dirs(self, mock_warning: MagicMock):
mock_warning.reset_mock()
current_engine = Engine.get_default()
Loader(current_engine).get_dirs()
comps_path = Path(__file__).parent.resolve() / "components"
dirs = get_dirs()
self.assertEqual(
sorted(dirs),
sorted(
[
# Top-level /components dir
Path(__file__).parent.resolve() / "components",
# App-level /components dir
Path(__file__).parent.resolve() / "test_app" / "components",
]
),
)
warn_inputs = [warn.args[0] for warn in mock_warning.call_args_list]
assert f"Got <class 'tuple'> : ('too_many', 'items', {repr(comps_path)})" in warn_inputs[0]
assert "Got <class 'int'> : 3" in warn_inputs[1]
assert "Got <class 'int'> : 3" in warn_inputs[0]
@override_settings(STATICFILES_DIRS=["components"])
def test_get_dirs__staticfiles_dirs__raises_on_relative_path_1(self):
@override_settings(
BASE_DIR=Path(__file__).parent.resolve(),
COMPONENTS={
"dirs": [],
},
)
def test_get_dirs__components_dirs__empty(self):
dirs = get_dirs()
self.assertEqual(
sorted(dirs),
sorted(
[
# App-level /components dir
Path(__file__).parent.resolve()
/ "test_app"
/ "components",
]
),
)
@override_settings(
BASE_DIR=Path(__file__).parent.resolve(),
COMPONENTS={
"dirs": ["components"],
},
)
def test_get_dirs__componenents_dirs__raises_on_relative_path_1(self):
current_engine = Engine.get_default()
loader = Loader(current_engine)
with self.assertRaisesMessage(ValueError, "STATICFILES_DIRS must contain absolute paths"):
with self.assertRaisesMessage(ValueError, "COMPONENTS.dirs must contain absolute paths"):
loader.get_dirs()
@override_settings(STATICFILES_DIRS=[("with_alias", "components")])
def test_get_dirs__staticfiles_dirs__raises_on_relative_path_2(self):
@override_settings(
BASE_DIR=Path(__file__).parent.resolve(),
COMPONENTS={
"dirs": [("with_alias", "components")],
},
)
def test_get_dirs__component_dirs__raises_on_relative_path_2(self):
current_engine = Engine.get_default()
loader = Loader(current_engine)
with self.assertRaisesMessage(ValueError, "STATICFILES_DIRS must contain absolute paths"):
with self.assertRaisesMessage(ValueError, "COMPONENTS.dirs must contain absolute paths"):
loader.get_dirs()
@override_settings(
BASE_DIR=Path(__file__).parent.resolve(),
COMPONENTS={
"app_dirs": ["custom_comps_dir"],
},
)
def test_get_dirs__app_dirs(self):
current_engine = Engine.get_default()
loader = Loader(current_engine)
dirs = loader.get_dirs()
self.assertEqual(
sorted(dirs),
sorted(
[
# Top-level /components dir
Path(__file__).parent.resolve() / "components",
# App-level /components dir
Path(__file__).parent.resolve() / "test_app" / "custom_comps_dir",
]
),
)
@override_settings(
BASE_DIR=Path(__file__).parent.resolve(),
COMPONENTS={
"app_dirs": [],
},
)
def test_get_dirs__app_dirs_empty(self):
current_engine = Engine.get_default()
loader = Loader(current_engine)
dirs = loader.get_dirs()
self.assertEqual(
sorted(dirs),
sorted(
[
# Top-level /components dir
Path(__file__).parent.resolve()
/ "components",
]
),
)
@override_settings(
BASE_DIR=Path(__file__).parent.resolve(),
COMPONENTS={
"app_dirs": ["this_dir_does_not_exist"],
},
)
def test_get_dirs__app_dirs_not_found(self):
current_engine = Engine.get_default()
loader = Loader(current_engine)
dirs = loader.get_dirs()
self.assertEqual(
sorted(dirs),
sorted(
[
# Top-level /components dir
Path(__file__).parent.resolve()
/ "components",
]
),
)