from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING import django_watchfiles from django.utils import autoreload if TYPE_CHECKING: from collections.abc import Iterable from collections.abc import Sequence from watchfiles import Change IGNORED_PROJECT_ROOT_NAMES: frozenset[str] = frozenset({ ".git", ".venv", "__pycache__", "firecracker", }) def _resolve_path(path: Path) -> Path: try: return path.resolve() except OSError: return path.absolute() def _is_relative_to(path: Path, parent: Path) -> bool: try: path.relative_to(parent) except ValueError: return False return True def build_project_watch_roots( project_root: Path, *, ignored_root_names: Sequence[str] = tuple(IGNORED_PROJECT_ROOT_NAMES), ) -> tuple[Path, ...]: """Return top-level project directories worth watching during development.""" ignored_names = set(ignored_root_names) resolved_project_root = _resolve_path(project_root) return tuple( sorted( _resolve_path(child) for child in resolved_project_root.iterdir() if child.is_dir() and not child.name.startswith(".") and child.name not in ignored_names ), ) class TussilagoWatchfilesReloader(django_watchfiles.WatchfilesReloader): """Use child directories instead of repo root for dev autoreload watches.""" def __init__( self, *, project_root: Path, ignored_paths: Sequence[Path] | None = None, ) -> None: """Store project-specific watch settings before watcher startup.""" self.project_root = _resolve_path(project_root) self.ignored_paths = tuple( _resolve_path(path) for path in (ignored_paths or (self.project_root / "firecracker",)) ) self.project_watch_roots = build_project_watch_roots(self.project_root) super().__init__() def file_filter(self, change: Change, filename: str) -> bool: """Drop file events from ignored paths such as the firecracker tree. Returns: True when the file event should still be watched. """ resolved_path = _resolve_path(Path(filename)) if any(_is_relative_to(resolved_path, ignored_path) for ignored_path in self.ignored_paths): return False return super().file_filter(change, filename) def watched_roots(self, watched_files: Iterable[Path]) -> frozenset[Path]: """Replace repo-root watches with top-level child directories. Returns: Watch roots with the project root expanded into child directories. """ roots = {_resolve_path(root) for root in super().watched_roots(watched_files)} filtered_roots = { root for root in roots if not any(_is_relative_to(root, ignored_path) for ignored_path in self.ignored_paths) } if self.project_root in filtered_roots: filtered_roots.remove(self.project_root) filtered_roots.update(self.project_watch_roots) return frozenset(filtered_roots) def install_watchfiles_reloader_patch(*, project_root: Path) -> None: """Install project-specific watchfiles reloader for dev and test runtimes.""" resolved_project_root = _resolve_path(project_root) class ProjectWatchfilesReloader(TussilagoWatchfilesReloader): def __init__(self) -> None: super().__init__(project_root=resolved_project_root) def replaced_get_reloader() -> autoreload.BaseReloader: return ProjectWatchfilesReloader() autoreload.get_reloader = replaced_get_reloader