Coverage for jinja2_async_environment/loaders/base.py: 76%

1"""Base classes and protocols for async template loaders."""

2

3 import typing as t

4 from abc import abstractmethod

5

6 from anyio import Path as AsyncPath

7 from jinja2.loaders import BaseLoader

8 from jinja2.utils import internalcode

9

10 if t.TYPE_CHECKING:

11 from jinja2 import Template

12

13 from ..environment import AsyncEnvironment

14

15# Type alias for source data

16 SourceType = tuple[str, str | None, t.Callable[[], bool] | None]

17

18

19 class AsyncLoaderProtocol(t.Protocol):

20 """Protocol defining the interface for async template loaders.

21

22 This protocol ensures all async loaders implement the required methods

23 for loading templates asynchronously while maintaining compatibility

24 with Jinja2's loader interface.

25 """

26

27 async def get_source_async(

28 self, environment: "AsyncEnvironment", name: str

29 ) -> SourceType | None:

30 """Get template source asynchronously.

31

32 Args:

33 environment: The async environment instance

34 name: Template name to load

35

36 Returns:

37 Tuple of (source, filename, uptodate_func) or None if not found

38

39 Raises:

40 TemplateNotFound: If template cannot be found

41 """

42 ...

43

44 async def list_templates_async(self) -> list[str]:

45 """List all available templates asynchronously.

46

47 Returns:

48 List of template names

49

50 Raises:

51 TypeError: If listing is not supported by this loader

52 """

53 ...

54

55 async def load_async(

56 self,

57 environment: "AsyncEnvironment",

58 name: str,

59 env_globals: dict[str, t.Any] | None = None,

60 ) -> "Template":

61 """Load template asynchronously.

62

63 Args:

64 environment: The async environment instance

65 name: Template name to load

66 env_globals: Global variables for the template

67

68 Returns:

69 Compiled Template object

70

71 Raises:

72 TemplateNotFound: If template cannot be found

73 """

74 ...

75

76

77 class AsyncBaseLoader(BaseLoader):

78 """Base class for async template loaders with memory optimization.

79

80 This class provides the foundation for all async loaders, implementing

81 common functionality while using __slots__ for memory efficiency.

82

83 Features:

84 - Thread-safe lazy initialization

85 - Memory-optimized with __slots__

86 - Robust error handling and resource management

87 - Compatible with Jinja2's loader interface

88 - Support for async template operations

89

90 Thread Safety:

91 The loader implements thread-safe initialization using RLock to ensure

92 that multiple concurrent calls to _ensure_initialized() are handled safely.

93 """

94

95 __slots__ = ("searchpath", "_initialized", "_init_lock")

96

97 def __init__(self, searchpath: AsyncPath | str | t.Sequence[AsyncPath | str]):

98 """Initialize the async loader.

99

100 Args:

101 searchpath: Path or sequence of paths to search for templates

102

103 Raises:

104 TypeError: If searchpath is not a valid type

105 ValueError: If searchpath is empty or contains invalid paths

106 """

107 # Validate and normalize searchpath

108 searchpath_list: list[t.Any] = []

109 if isinstance(searchpath, str) or hasattr(

110 searchpath, "parts"

111 ): # AsyncPath check

112 searchpath_list = [searchpath]

113 else:

114 # Try to treat as sequence

115 try:

116 searchpath_list = list(searchpath) # type: ignore

117 if not searchpath_list:

118 raise ValueError("searchpath cannot be empty")

119

120 # Validate each path in the sequence

121 for i, path in enumerate(searchpath_list):

122 if not (isinstance(path, str) or hasattr(path, "parts")):

123 raise TypeError(

124 f"searchpath item {i} must be a string or AsyncPath, got {type(path)}"

125 )

126 except (TypeError, ValueError) as e:

127 if isinstance(e, ValueError):

128 raise

129 raise TypeError(

130 "searchpath must be a string, AsyncPath, or sequence of strings/AsyncPaths"

131 ) from e

132

133 # Convert to AsyncPath objects for consistency and validate

134 self.searchpath: list[AsyncPath] = []

135 for path in searchpath_list:

136 if isinstance(path, str):

137 if not path.strip():

138 raise ValueError("Empty string paths are not allowed")

139 self.searchpath.append(AsyncPath(path))

140 elif hasattr(path, "parts"):

141 # Already an AsyncPath-like object, convert to ensure type safety

142 self.searchpath.append(AsyncPath(str(path)))

143 else:

144 # Fallback: convert to string then AsyncPath

145 self.searchpath.append(AsyncPath(str(path)))

146

147 self._initialized = False

148 self._init_lock = None # Will be created on first use

149

150 def _ensure_initialized(self) -> None:

151 """Ensure the loader is properly initialized.

152

153 This method provides thread-safe lazy initialization. Subclasses

154 should override _perform_initialization() instead of this method.

155

156 Thread Safety:

157 Uses a reentrant lock (RLock) to ensure thread-safe initialization

158 even when called from multiple threads concurrently.

159

160 Raises:

161 RuntimeError: If initialization fails

162 """

163 if not self._initialized:

164 # Thread-safe initialization check

165 import threading

166

167 if not hasattr(self, "_init_lock") or self._init_lock is None:

168 self._init_lock = threading.RLock()

169

170 with self._init_lock:

171 if not self._initialized:

172 try:

173 self._perform_initialization()

174 self._initialized = True

175 except Exception:

176 # Ensure we don't leave loader in partially initialized state

177 self._initialized = False

178 # Re-raise the original exception to maintain test compatibility

179 raise

180

181 def _perform_initialization(self) -> None:

182 """Perform the actual initialization work.

183

184 This method should be overridden by subclasses that need

185 custom initialization logic. It is called within a thread-safe

186 context by _ensure_initialized().

187

188 Subclasses should implement their initialization logic here

189 rather than overriding _ensure_initialized() directly.

190

191 Raises:

192 Exception: Any initialization-specific exceptions

193 """

194 pass

195

196 @abstractmethod

197 async def get_source_async(

198 self, environment: "AsyncEnvironment", name: str

199 ) -> SourceType:

200 """Get template source asynchronously.

201

202 Must be implemented by subclasses.

203

204 Args:

205 environment: The async environment instance

206 name: Template name to load

207

208 Returns:

209 Tuple of (source, filename, uptodate_func)

210

211 Raises:

212 TemplateNotFound: If template cannot be found

213 """

214 raise NotImplementedError("Subclasses must implement get_source_async")

215

216 async def list_templates_async(self) -> list[str]:

217 """List all available templates asynchronously.

218

219 Default implementation raises TypeError. Override in subclasses

220 that support template listing.

221

222 Returns:

223 List of template names

224

225 Raises:

226 TypeError: If listing is not supported by this loader

227 """

228 raise TypeError("this loader cannot iterate over all templates")

229

230 @internalcode

231 async def load_async(

232 self,

233 environment: "AsyncEnvironment",

234 name: str,

235 env_globals: dict[str, t.Any] | None = None,

236 ) -> "Template":

237 """Load template asynchronously using get_source_async.

238

239 This method orchestrates the complete template loading process:

240 1. Get template source via get_source_async()

241 2. Handle bytecode caching if available

242 3. Compile template if not cached

243 4. Create and return Template instance

244

245 Args:

246 environment: The async environment instance

247 name: Template name to load

248 env_globals: Global variables for the template (optional)

249

250 Returns:

251 Compiled Template object ready for rendering

252

253 Raises:

254 TemplateNotFound: If template cannot be found

255 RuntimeError: If compilation or loading fails

256 """

257 if env_globals is None:

258 env_globals = {}

259

260 # Import TemplateNotFound here to avoid circular imports

261 from jinja2.exceptions import TemplateNotFound

262

263 # Validate inputs

264 if not name or not name.strip():

265 raise TemplateNotFound("Template name cannot be empty")

266

267 # Get template source

268 try:

269 source, path, uptodate = await self.get_source_async(environment, name)

270 except Exception as e:

271 if isinstance(e, TemplateNotFound):

272 raise

273 from jinja2.exceptions import TemplateNotFound as TNF

274

275 raise TNF(f"Failed to get template source: {e}") from e

276

277 # Normalize source to string

278 try:

279 source_str = source.decode("utf-8") if isinstance(source, bytes) else source

280 except UnicodeDecodeError as e:

281 from jinja2.exceptions import TemplateNotFound as TNF

282

283 raise TNF(f"Template {name} contains invalid UTF-8 encoding: {e}") from e

284

285 # Handle bytecode cache if available

286 bcc = environment.bytecode_cache

287 if bcc is not None:

288 try:

289 bucket = bcc.get_bucket(environment, name, path) # type: ignore

290

291 # Create checksum for bytecode caching

292 import hashlib

293

294 checksum = hashlib.sha256(source_str.encode("utf-8")).hexdigest()

295

296 # Try to get existing bytecode

297 code = bucket.get_bytecode(checksum)

298

299 if code is None:

300 # Compile template

301 code = environment.compile(source_str, name, path)

302 bucket.set_bytecode(checksum, code)

303 except Exception:

304 # Fallback to direct compilation if cache fails

305 code = environment.compile(source_str, name, path)

306 else:

307 # No cache, compile directly

308 code = environment.compile(source_str, name, path)

309

310 # Create template instance

311 template = environment.template_class.from_code(

312 environment,

313 code,

314 env_globals,

315 uptodate,

316 )

317

318 return template

319

320 def _get_cache_manager(self, environment: "AsyncEnvironment") -> t.Any:

321 """Get the cache manager from the environment.

322

323 Args:

324 environment: The async environment instance

325

326 Returns:

327 The cache manager instance

328 """

329 return getattr(environment, "cache_manager", None)

330

331 @internalcode

332 def _handle_template_not_found(self, name: str) -> None:

333 """Helper method to raise TemplateNotFound with consistent messaging.

334

335 Args:

336 name: Template name that was not found

337

338 Raises:

339 TemplateNotFound: Always raised with appropriate message

340 """

341 from jinja2.exceptions import TemplateNotFound

342

343 raise TemplateNotFound(name)