fix: autoimport with nested apps (#672)

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-14 22:47:30 +02:00 committed by GitHub
parent 6b3c112968
commit ee9b92975a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 218 additions and 112 deletions

View file

@ -25,7 +25,7 @@ class ComponentsConfig(AppConfig):
# See https://github.com/EmilStenstrom/django-components/discussions/567#discussioncomment-10273632
# And https://stackoverflow.com/questions/42907285/66673186#66673186
if app_settings.RELOAD_ON_TEMPLATE_CHANGE:
dirs = get_dirs()
dirs = get_dirs(include_apps=False)
component_filepaths = search_dirs(dirs, "**/*")
watch_files_for_autoreload(component_filepaths)

View file

@ -4,8 +4,10 @@ import os
from pathlib import Path
from typing import Callable, List, Optional, Union
from django.apps import apps
from django.conf import settings
from django_components.app_settings import app_settings
from django_components.logger import logger
from django_components.template_loader import get_dirs
@ -22,16 +24,53 @@ def autodiscover(
You can map the module paths with `map_module` function. This serves
as an escape hatch for when you need to use this function in tests.
"""
dirs = get_dirs()
dirs = get_dirs(include_apps=False)
component_filepaths = search_dirs(dirs, "**/*.py")
logger.debug(f"Autodiscover found {len(component_filepaths)} files in component directories.")
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__))
modules: List[str] = []
# We handle dirs from `COMPONENTS.dirs` and from individual apps separately.
#
# 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)
# Ignore relative paths that are outside of the project root
if not module_path.startswith(".."):
modules.append(module_path)
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
modules.append(module_path)
# 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], "**/*.py")
for filepath in app_component_filepaths:
app_component_module = _filepath_to_python_module(filepath, conf.path, conf.name)
modules.append(app_component_module)
return _import_modules(modules, map_module)
@ -67,7 +106,11 @@ def _import_modules(
return imported_modules
def _filepath_to_python_module(file_path: Union[Path, str]) -> str:
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.
@ -77,13 +120,7 @@ def _filepath_to_python_module(file_path: Union[Path, str]) -> str:
- Then the path relative to project root is `app/components/mycomp.py`
- Which we then turn into python import path `app.components.mycomp`
"""
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__))
rel_path = os.path.relpath(file_path, start=project_root)
rel_path = os.path.relpath(file_path, start=root_fs_path)
rel_path_without_suffix = str(Path(rel_path).with_suffix(""))
# NOTE: `Path` normalizes paths to use `/` as separator, while `os.path`
@ -91,7 +128,12 @@ def _filepath_to_python_module(file_path: Union[Path, str]) -> str:
sep = os.path.sep if os.path.sep in rel_path_without_suffix else "/"
module_name = rel_path_without_suffix.replace(sep, ".")
return module_name
# 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]:

View file

@ -19,7 +19,7 @@ class Command(BaseCommand):
def handle(self, *args: Any, **options: Any) -> None:
current_engine = Engine.get_default()
loader = Loader(current_engine)
dirs = loader.get_dirs()
dirs = loader.get_dirs(include_apps=False)
if settings.BASE_DIR:
dirs.append(Path(settings.BASE_DIR) / "templates")

View file

@ -14,20 +14,11 @@ 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]:
def get_dirs(self, include_apps: bool = True) -> List[Path]:
"""
Prepare directories that may contain component files:
@ -64,11 +55,12 @@ class Loader(FilesystemLoader):
# 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)
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)
@ -98,7 +90,7 @@ class Loader(FilesystemLoader):
return list(directories)
def get_dirs(engine: Optional[Engine] = None) -> List[Path]:
def get_dirs(include_apps: bool = True, 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.
@ -108,4 +100,4 @@ def get_dirs(engine: Optional[Engine] = None) -> List[Path]:
current_engine = Engine.get_default()
loader = Loader(current_engine)
return loader.get_dirs()
return loader.get_dirs(include_apps)