django-components/src/django_components/util/loader.py

258 lines
10 KiB
Python

import glob
import os
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import List, NamedTuple, Optional, Set, Union
from django.apps import apps
from django.conf import settings
from django_components.app_settings import ComponentsSettings, app_settings
from django_components.util.logger import logger
def get_component_dirs(include_apps: bool = True) -> List[Path]:
"""
Get directories that may contain component files.
This is the heart of all features that deal with filesystem and file lookup.
Autodiscovery, Django template resolution, static file resolution - They all use this.
Args:
include_apps (bool, optional): Include directories from installed Django apps.\
Defaults to `True`.
Returns:
List[Path]: A list of directories that may contain component files.
`get_component_dirs()` searches for dirs set in
[`COMPONENTS.dirs`](../settings#django_components.app_settings.ComponentsSettings.dirs)
settings. If none set, defaults to searching for a `"components"` app.
In addition to that, also all installed Django apps are checked whether they contain
directories as set in
[`COMPONENTS.app_dirs`](../settings#django_components.app_settings.ComponentsSettings.app_dirs)
(e.g. `[app]/components`).
**Notes:**
- Paths that do not point to directories are ignored.
- `BASE_DIR` setting is required.
- The paths in [`COMPONENTS.dirs`](../settings#django_components.app_settings.ComponentsSettings.dirs)
must be absolute paths.
"""
# Allow to configure from settings which dirs should be checked for components
component_dirs = app_settings.DIRS
# TODO_REMOVE_IN_V1
raw_component_settings = getattr(settings, "COMPONENTS", {})
if isinstance(raw_component_settings, dict):
raw_dirs_value = raw_component_settings.get("dirs", None)
elif isinstance(raw_component_settings, ComponentsSettings):
raw_dirs_value = raw_component_settings.dirs
else:
raw_dirs_value = None
is_component_dirs_set = raw_dirs_value is not None
is_legacy_paths = (
# Use value of `STATICFILES_DIRS` ONLY if `COMPONENT.dirs` not set
not is_component_dirs_set
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"]
# END TODO_REMOVE_IN_V1
source = "STATICFILES_DIRS" if is_legacy_paths else "COMPONENTS.dirs"
logger.debug(
"get_component_dirs will search for valid dirs from following options:\n"
+ "\n".join([f" - {str(d)}" for d in component_dirs])
)
# Add `[app]/[APP_DIR]` to the directories. This is, by default `[app]/components`
app_paths: List[Path] = []
if include_apps:
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():
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)):
component_dir = component_dir[1]
try:
Path(component_dir)
except TypeError:
logger.warning(
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"{source} must contain absolute paths, got '{component_dir}'")
else:
directories.add(Path(component_dir).resolve())
logger.debug(
"get_component_dirs matched following template dirs:\n" + "\n".join([f" - {str(d)}" for d in directories])
)
return list(directories)
class ComponentFileEntry(NamedTuple):
"""Result returned by [`get_component_files()`](../api#django_components.get_component_files)."""
dot_path: str
"""The python import path for the module. E.g. `app.components.mycomp`"""
filepath: Path
"""The filesystem path to the module. E.g. `/path/to/project/app/components/mycomp.py`"""
def get_component_files(suffix: Optional[str] = None) -> List[ComponentFileEntry]:
"""
Search for files within the component directories (as defined in
[`get_component_dirs()`](../api#django_components.get_component_dirs)).
Requires `BASE_DIR` setting to be set.
Subdirectories and files starting with an underscore `_` (except `__init__.py`) are ignored.
Args:
suffix (Optional[str], optional): The suffix to search for. E.g. `.py`, `.js`, `.css`.\
Defaults to `None`, which will search for all files.
Returns:
List[ComponentFileEntry] A list of entries that contain both the filesystem path and \
the python import path (dot path).
**Example:**
```python
from django_components import get_component_files
modules = get_component_files(".py")
```
"""
search_glob = f"**/*{suffix}" if suffix else "**/*"
dirs = get_component_dirs(include_apps=False)
component_filepaths = _search_dirs(dirs, search_glob)
if hasattr(settings, "BASE_DIR") and settings.BASE_DIR:
project_root = str(settings.BASE_DIR)
else:
# Fallback for getting the root dir, see https://stackoverflow.com/a/16413955/9788634
project_root = os.path.abspath(os.path.dirname(__name__))
# NOTE: We handle dirs from `COMPONENTS.dirs` and from individual apps separately.
modules: List[ComponentFileEntry] = []
# First let's handle the dirs from `COMPONENTS.dirs`
#
# Because for dirs in `COMPONENTS.dirs`, we assume they will be nested under `BASE_DIR`,
# and that `BASE_DIR` is the current working dir (CWD). So the path relatively to `BASE_DIR`
# is ALSO the python import path.
for filepath in component_filepaths:
module_path = _filepath_to_python_module(filepath, project_root, None)
# Ignore files starting with dot `.` or files in dirs that start with dot.
#
# If any of the parts of the path start with a dot, e.g. the filesystem path
# is `./abc/.def`, then this gets converted to python module as `abc..def`
#
# NOTE: This approach also ignores files:
# - with two dots in the middle (ab..cd.py)
# - an extra dot at the end (abcd..py)
# - files outside of the parent component (../abcd.py).
# But all these are NOT valid python modules so that's fine.
if ".." in module_path:
continue
entry = ComponentFileEntry(dot_path=module_path, filepath=filepath)
modules.append(entry)
# For for apps, the directories may be outside of the project, e.g. in case of third party
# apps. So we have to resolve the python import path relative to the package name / the root
# import path for the app.
# See https://github.com/EmilStenstrom/django-components/issues/669
for conf in apps.get_app_configs():
for app_dir in app_settings.APP_DIRS:
comps_path = Path(conf.path).joinpath(app_dir)
if not comps_path.exists():
continue
app_component_filepaths = _search_dirs([comps_path], search_glob)
for filepath in app_component_filepaths:
app_component_module = _filepath_to_python_module(filepath, conf.path, conf.name)
entry = ComponentFileEntry(dot_path=app_component_module, filepath=filepath)
modules.append(entry)
return modules
def _filepath_to_python_module(
file_path: Union[Path, str],
root_fs_path: Union[str, Path],
root_module_path: Optional[str],
) -> str:
"""
Derive python import path from the filesystem path.
Example:
- If project root is `/path/to/project`
- And file_path is `/path/to/project/app/components/mycomp.py`
- Then the path relative to project root is `app/components/mycomp.py`
- Which we then turn into python import path `app.components.mycomp`
"""
path_cls = PureWindowsPath if os.name == "nt" else PurePosixPath
rel_path = path_cls(file_path).relative_to(path_cls(root_fs_path))
rel_path_parts = rel_path.with_suffix("").parts
module_name = ".".join(rel_path_parts)
# Combine with the base module path
full_module_name = f"{root_module_path}.{module_name}" if root_module_path else module_name
if full_module_name.endswith(".__init__"):
full_module_name = full_module_name[:-9] # Remove the trailing `.__init__`
return full_module_name
def _search_dirs(dirs: List[Path], search_glob: str) -> List[Path]:
"""
Search the directories for the given glob pattern. Glob search results are returned
as a flattened list.
"""
matched_files: List[Path] = []
for directory in dirs:
for path_str in glob.iglob(str(Path(directory) / search_glob), recursive=True):
path = Path(path_str)
# Skip any subdirectory or file (under the top-level directory) that starts with an underscore
rel_dir_parts = list(path.relative_to(directory).parts)
name_part = rel_dir_parts.pop()
if any(part.startswith("_") for part in rel_dir_parts):
continue
if name_part.startswith("_") and name_part != "__init__.py":
continue
matched_files.append(path)
return matched_files
def resolve_file(filepath: str, dirs: Optional[List[Path]] = None) -> Optional[Path]:
dirs = dirs if dirs is not None else get_component_dirs()
for directory in dirs:
full_path = Path(directory) / filepath
if full_path.exists():
return full_path
return None