Coverage for jinja2_async_environment/loaders/filesystem.py: 63%
83 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-03 14:09 -0700
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-03 14:09 -0700
1"""Async filesystem template loader implementation."""
3import typing as t
5from anyio import Path as AsyncPath
6from jinja2.utils import internalcode
8from .base import AsyncBaseLoader, SourceType
10if t.TYPE_CHECKING:
11 from ..environment import AsyncEnvironment
14class AsyncFileSystemLoader(AsyncBaseLoader):
15 """Async filesystem template loader with memory optimization.
17 This loader searches for templates in the filesystem asynchronously,
18 supporting multiple search paths and proper file watching for
19 template updates.
20 """
22 __slots__ = ("encoding", "followlinks", "_template_cache")
24 def __init__(
25 self,
26 searchpath: AsyncPath | str | t.Sequence[AsyncPath | str],
27 encoding: str = "utf-8",
28 followlinks: bool = False,
29 ) -> None:
30 """Initialize the filesystem loader.
32 Args:
33 searchpath: Path or sequence of paths to search for templates
34 encoding: File encoding to use when reading templates
35 followlinks: Whether to follow symbolic links
36 """
37 super().__init__(searchpath)
38 self.encoding = encoding
39 self.followlinks = followlinks
40 self._template_cache: dict[str, tuple[float, str]] = {}
42 @internalcode
43 async def get_source_async(
44 self, environment: "AsyncEnvironment", name: str
45 ) -> SourceType:
46 """Get template source from filesystem asynchronously.
48 Args:
49 environment: The async environment instance
50 name: Template name to load
52 Returns:
53 Tuple of (source, filename, uptodate_func)
55 Raises:
56 TemplateNotFound: If template file cannot be found
57 """
58 self._ensure_initialized()
60 for searchpath in self.searchpath:
61 template_path = searchpath / name
63 try:
64 if await template_path.exists():
65 if not await self._is_safe_path(template_path):
66 continue
68 # Read the template file
69 source = await template_path.read_text(encoding=self.encoding)
70 filename = str(template_path.resolve())
72 # Create uptodate function
73 def uptodate() -> bool:
74 try:
75 return (
76 template_path.stat().st_mtime
77 == template_path.stat().st_mtime
78 )
79 except OSError:
80 return False
82 return source, filename, uptodate
84 except OSError:
85 # Continue to next search path
86 continue
88 # Template not found in any search path
89 self._handle_template_not_found(name)
91 async def _is_safe_path(self, template_path: AsyncPath) -> bool:
92 """Check if the template path is safe to access.
94 Args:
95 template_path: Path to check
97 Returns:
98 True if path is safe, False otherwise
99 """
100 try:
101 # Check if it's a file
102 if not await template_path.is_file():
103 return False
105 # If not following links, check for symlinks
106 if not self.followlinks and await template_path.is_symlink():
107 return False
109 # Check that the resolved path is within search paths
110 resolved_path = await template_path.resolve()
112 for searchpath in self.searchpath:
113 resolved_searchpath = await searchpath.resolve()
114 try:
115 resolved_path.relative_to(resolved_searchpath)
116 return True
117 except ValueError:
118 continue
120 return False
122 except OSError:
123 return False
125 @internalcode
126 async def list_templates_async(self) -> list[str]:
127 """List all templates in the search paths asynchronously.
129 Returns:
130 Sorted list of template names
131 """
132 self._ensure_initialized()
134 found_templates = set()
136 for searchpath in self.searchpath:
137 if not await searchpath.exists():
138 continue
140 try:
141 # Use rglob to find all files recursively
142 async for template_path in searchpath.rglob("*"):
143 if await template_path.is_file():
144 # Get relative path from search path
145 try:
146 relative_path = template_path.relative_to(searchpath)
147 template_name = str(relative_path).replace("\\", "/")
149 # Only include if it's a safe path
150 if await self._is_safe_path(template_path):
151 found_templates.add(template_name)
153 except ValueError:
154 # Path is not relative to searchpath
155 continue
157 except OSError:
158 # Skip this search path if it can't be accessed
159 continue
161 return sorted(found_templates)
163 async def _walk_directory(self, directory: AsyncPath):
164 """Async generator to walk directory tree.
166 Args:
167 directory: Directory to walk
169 Yields:
170 AsyncPath objects for each file/directory found
171 """
172 if not await directory.exists():
173 return
175 try:
176 async for item in directory.iterdir():
177 yield item
179 if await item.is_dir() and (
180 self.followlinks or not await item.is_symlink()
181 ):
182 async for subitem in self._walk_directory(item):
183 yield subitem
185 except (OSError, PermissionError):
186 # Skip directories we can't access
187 return
189 def _get_cache_key(self, name: str) -> str:
190 """Generate cache key for template.
192 Args:
193 name: Template name
195 Returns:
196 Cache key string
197 """
198 return f"fs:{name}"