Source code for next.components.checks

"""System checks for the components subsystem."""

from __future__ import annotations

import ast
from typing import TYPE_CHECKING

from django.conf import settings
from django.core.checks import CheckMessage, Error, Tags, register

from next.checks.common import errors_for_unknown_keys
from next.conf import next_framework_settings

from .backends import FileComponentsBackend
from .manager import ComponentsManager


if TYPE_CHECKING:
    from pathlib import Path


_COMPONENT_BACKEND_SETTINGS_KEY = "DEFAULT_COMPONENT_BACKENDS"

_FILE_COMPONENT_BACKEND_CONFIG_KEYS = frozenset(
    {
        "BACKEND",
        "COMPONENTS_DIR",
        "DIRS",
    },
)


def _validate_single_component_backend(
    config: dict[str, object],
    index: int,
) -> list[CheckMessage]:
    """Validate required keys and types for one merged component backend dict."""
    prefix = f"NEXT_FRAMEWORK['{_COMPONENT_BACKEND_SETTINGS_KEY}'][{index}]"
    errors: list[CheckMessage] = [
        Error(
            f"{prefix} must specify {key}.",
            obj=settings,
            id="next.E031",
        )
        for key in ("BACKEND", "DIRS", "COMPONENTS_DIR")
        if key not in config
    ]
    if errors:
        return errors
    if not isinstance(config["BACKEND"], str):
        errors.append(
            Error(
                f"{prefix}.BACKEND must be a string.",
                obj=settings,
                id="next.E032",
            ),
        )
    if not isinstance(config["DIRS"], list):
        errors.append(
            Error(
                f"{prefix}.DIRS must be a list.",
                obj=settings,
                id="next.E032",
            ),
        )
    if not isinstance(config["COMPONENTS_DIR"], str):
        errors.append(
            Error(
                f"{prefix}.COMPONENTS_DIR must be a string.",
                obj=settings,
                id="next.E027",
            ),
        )
    errors.extend(
        errors_for_unknown_keys(
            config,
            allowed=_FILE_COMPONENT_BACKEND_CONFIG_KEYS,
            prefix=prefix,
        ),
    )
    return errors


[docs] @register(Tags.compatibility) def check_next_components_configuration( *_args: object, **_kwargs: object, ) -> list[CheckMessage]: """Validate `DEFAULT_COMPONENT_BACKENDS` shape in merged `NEXT_FRAMEWORK`.""" raw = getattr(settings, "NEXT_FRAMEWORK", None) if raw is not None and not isinstance(raw, dict): return [] backends = next_framework_settings.DEFAULT_COMPONENT_BACKENDS if not isinstance(backends, list): return [ Error( "NEXT_FRAMEWORK['DEFAULT_COMPONENT_BACKENDS'] must be a list of " "backend configuration dictionaries.", obj=settings, id="next.E023", ), ] if len(backends) == 0: return [ Error( "NEXT_FRAMEWORK['DEFAULT_COMPONENT_BACKENDS'] must contain at least " "one component backend entry.", obj=settings, id="next.E033", ), ] errors: list[CheckMessage] = [] for i, config in enumerate(backends): if not isinstance(config, dict): errors.append( Error( f"NEXT_FRAMEWORK['{_COMPONENT_BACKEND_SETTINGS_KEY}'][{i}] " "must be a dictionary.", obj=settings, id="next.E002", ), ) continue errors.extend(_validate_single_component_backend(config, i)) return errors
[docs] @register(Tags.compatibility) def check_duplicate_component_names( *_args: object, **_kwargs: object, ) -> list[CheckMessage]: """Check that no two components share the same name within the same scope.""" errors: list[CheckMessage] = [] configs = next_framework_settings.DEFAULT_COMPONENT_BACKENDS if not isinstance(configs, list) or not configs: return errors manager = ComponentsManager() manager._reload_config() for backend in manager._backends: if not isinstance(backend, FileComponentsBackend): continue backend._ensure_loaded() seen: dict[tuple[Path, str], list[tuple[str, str]]] = {} for info in backend._registry: key = (info.scope_root, info.name) if key not in seen: seen[key] = [] path_str = str(info.template_path or info.module_path or "") seen[key].append((info.scope_relative, path_str)) for (_scope_root, name), entries in seen.items(): if len(entries) > 1: paths_str = ", ".join(p for _sr, p in entries if p) errors.append( Error( f'Component name "{name}" is registered more than once ' f"within the same scope: {paths_str}", obj=settings, id="next.E020", ), ) return errors
[docs] @register(Tags.compatibility) def check_cross_root_component_name_conflicts( *_args: object, **_kwargs: object, ) -> list[CheckMessage]: """Reject one component name in the root route scope on more than one page tree.""" errors: list[CheckMessage] = [] configs = next_framework_settings.DEFAULT_COMPONENT_BACKENDS if not isinstance(configs, list) or not configs: return errors manager = ComponentsManager() manager._reload_config() for backend in manager._backends: if not isinstance(backend, FileComponentsBackend): continue backend._ensure_loaded() by_name: dict[str, dict[Path, str]] = {} for info in backend._registry: if (info.scope_relative or "").strip(): continue root = info.resolved_scope_root path_str = str(info.template_path or info.module_path or "") roots_for_name = by_name.setdefault(info.name, {}) roots_for_name.setdefault(root, path_str) for name, roots_map in sorted(by_name.items()): if len(roots_map) <= 1: continue details = ". ".join( f"{root}: {path_str or '?'}" for root, path_str in sorted( roots_map.items(), key=lambda item: str(item[0]), ) ) errors.append( Error( f'Component name "{name}" uses the shared root namespace on more ' f"than one page tree. Each distinct directory root in " f"NEXT_FRAMEWORK DEFAULT_PAGE_BACKENDS DIRS must expose unique " f"names at the root route scope. Locations: {details}.", obj=settings, id="next.E034", ), ) return errors
def _component_py_uses_pages_context(file_path: Path) -> bool: """Return True if `component.py` imports `context` from `next.pages`.""" try: source = file_path.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): return False try: tree = ast.parse(source) except SyntaxError: return False for node in ast.walk(tree): if ( isinstance(node, ast.ImportFrom) and getattr(node, "module", None) == "next.pages" ): for alias in node.names: if getattr(alias, "name", None) == "context": return True if ( isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) and node.value.id == "page" and node.attr == "context" ): return True return False
[docs] @register(Tags.compatibility) def check_component_py_no_pages_context( *_args: object, **_kwargs: object, ) -> list[CheckMessage]: """Check that `component.py` files do not use `context` from `next.pages`.""" errors: list[CheckMessage] = [] configs = next_framework_settings.DEFAULT_COMPONENT_BACKENDS if not isinstance(configs, list) or not configs: return errors manager = ComponentsManager() manager._reload_config() for backend in manager._backends: if not isinstance(backend, FileComponentsBackend): continue backend._ensure_loaded() for info in backend._registry: if info.module_path is None: continue if not info.module_path.exists(): continue if _component_py_uses_pages_context(info.module_path): errors.append( Error( "component.py must not use context from next.pages. " "Use component context from next.components instead.", obj=str(info.module_path), id="next.E021", ), ) return errors
__all__ = [ "check_component_py_no_pages_context", "check_cross_root_component_name_conflicts", "check_duplicate_component_names", "check_next_components_configuration", ]