Coverage for structured_tutorials / sphinx / utils.py: 100%

153 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-04 13:17 +0200

1# Copyright (c) 2025 Mathias Ertl 

2# Licensed under the MIT License. See LICENSE file for details. 

3 

4"""Utility functions for the sphinx extension.""" 

5 

6import os 

7import shlex 

8from collections.abc import Iterator 

9from contextlib import contextmanager 

10from copy import deepcopy 

11from importlib import resources 

12from pathlib import Path 

13from typing import Any 

14 

15from jinja2 import Environment 

16from sphinx.application import Sphinx 

17from sphinx.config import Config 

18from sphinx.errors import ConfigError, ExtensionError 

19 

20from structured_tutorials import templates 

21from structured_tutorials.errors import DestinationIsADirectoryError 

22from structured_tutorials.models import ( 

23 AlternativeModel, 

24 CommandsPartModel, 

25 FilePartModel, 

26 PromptModel, 

27 TutorialModel, 

28) 

29from structured_tutorials.models.tutorial import DocumentationAlternativeConfigurationModel 

30from structured_tutorials.textwrap import wrap_command_filter 

31 

32TEMPLATE_DIR = resources.files(templates) 

33 

34 

35def validate_configuration(app: Sphinx, config: Config) -> None: 

36 """Validate configuration directives, so that we can rely on values later.""" 

37 root = config.structured_tutorials_root 

38 if not isinstance(root, Path): 

39 raise ConfigError(f"{root}: Must be of type Path.") 

40 if not root.is_absolute(): 

41 raise ConfigError(f"{root}: Path must be absolute.") 

42 

43 

44def get_tutorial_path(tutorial_root: Path, arg: str) -> Path: 

45 """Get the full tutorial path and verify existence.""" 

46 tutorial_path = Path(arg) 

47 if tutorial_path.is_absolute(): 

48 raise ExtensionError(f"{tutorial_path}: Path must not be absolute.") 

49 

50 absolute_path = tutorial_root / tutorial_path 

51 if not absolute_path.exists(): 

52 raise ExtensionError(f"{absolute_path}: File not found.") 

53 return absolute_path 

54 

55 

56class TutorialWrapper: 

57 """Wrapper class for rendering a tutorial. 

58 

59 This class exists mainly to wrap the main logic into a separate class that is more easily testable. 

60 """ 

61 

62 def __init__( 

63 self, 

64 tutorial: TutorialModel, 

65 path: Path, 

66 context: dict[str, Any] | None = None, 

67 command_text_width: int = 75, 

68 ) -> None: 

69 self.path = path 

70 self.tutorial = tutorial 

71 self.next_part = 0 

72 self.env = Environment(keep_trailing_newline=True) 

73 self.env.filters["wrap_command"] = wrap_command_filter 

74 self.context = deepcopy(tutorial.configuration.context) 

75 self.context.update(deepcopy(tutorial.configuration.doc.context)) 

76 if context: 

77 self.context.update(context) 

78 

79 # Render context variables incrementally 

80 for key, value in [(k, v) for k, v in self.context.items() if isinstance(v, str)]: 

81 if key.endswith("_template"): 

82 continue 

83 self.context[key] = self.render(value, _key=key) 

84 self.orig_cwd = self.context["cwd"] 

85 

86 # settings from sphinx: 

87 self.command_text_width = command_text_width 

88 self.orig_cwd = self.context["cwd"] 

89 

90 @contextmanager 

91 def update_context(self, context: dict[str, Any]) -> Iterator[None]: 

92 orig_context = self.context 

93 new_context = deepcopy(orig_context) 

94 new_context.update(context) 

95 try: 

96 self.context = new_context 

97 yield 

98 finally: 

99 self.context = orig_context 

100 

101 @classmethod 

102 def from_file( 

103 cls, path: Path, context: dict[str, Any] | None = None, command_text_width: int = 75 

104 ) -> "TutorialWrapper": 

105 """Factory method for creating a TutorialWrapper from a file.""" 

106 tutorial = TutorialModel.from_file(path) 

107 return cls(tutorial, path, context=context, command_text_width=command_text_width) 

108 

109 def render(self, template: str, **context: Any) -> str: 

110 return self.env.from_string(template).render({**self.context, **context}) 

111 

112 def render_code_block(self, part: CommandsPartModel) -> str: 

113 """Render a CommandsPartModel as a code-block.""" 

114 assert part.doc is not False # Already checked by the caller 

115 commands = [] 

116 for command_config in part.commands: 

117 # Skip individual commands if marked as skipped for documentation 

118 if command_config.doc is False: 

119 continue 

120 

121 # Render the prompt 

122 prompt = self.env.from_string(self.context["prompt_template"]).render(self.context) 

123 

124 # Render the command 

125 if isinstance(command_config.command, str): 

126 command = self.render(command_config.command) 

127 else: 

128 command = shlex.join(self.render(token) for token in command_config.command) 

129 

130 # Render output 

131 output_template = command_config.doc.output.rstrip("\n") 

132 output = self.env.from_string(output_template).render(self.context) 

133 

134 # Finally, render the command 

135 command_template = """{{ command|wrap_command(prompt, text_width) }}{% if output %} 

136{{ output }}{% endif %}""" 

137 command_context = { 

138 "prompt": prompt, 

139 "command": command, 

140 "output": output, 

141 "text_width": self.command_text_width, 

142 } 

143 rendered_command = self.env.from_string(command_template).render(command_context) 

144 commands.append(rendered_command) 

145 

146 # Update the context from update_context 

147 self.context.update(command_config.doc.update_context) 

148 if command_config.chdir: 

149 self.context["cwd"] = self.render(str(command_config.chdir)) 

150 elif command_config.chdir is False: 

151 self.context["cwd"] = self.orig_cwd 

152 

153 template_str = TEMPLATE_DIR.joinpath("commands_part.rst.template").read_text("utf-8") 

154 template = self.env.from_string(template_str) 

155 return template.render( 

156 { 

157 "commands": commands, 

158 "text_after": self.render(part.doc.text_after), 

159 "text_before": self.render(part.doc.text_before), 

160 } 

161 ) 

162 

163 def render_file(self, part: FilePartModel) -> str: 

164 assert part.doc is not False # Already checked by the caller 

165 content = part.contents 

166 if content is None: 

167 assert part.source is not None # assured by model validation 

168 with open(self.tutorial.root / part.source) as stream: 

169 content = stream.read() 

170 

171 # Only render template if it is configured to be a template. 

172 if part.template: 

173 content = self.render(content) 

174 

175 # Render the caption (default is the filename) 

176 if part.doc.caption: 

177 caption = self.render(part.doc.caption) 

178 elif part.doc.caption is not False: 

179 caption = self.render(str(part.destination)) 

180 if caption.endswith(os.path.sep): 

181 # Model validation already validates that the destination does not look like a directory, if 

182 # no source is set, but this could be tricked if the destination is a template. 

183 if not part.source: 

184 raise DestinationIsADirectoryError( 

185 f"{caption}: Destination is directory, but no source given to derive filename." 

186 ) 

187 caption = os.path.join(caption, part.source.name) 

188 else: 

189 caption = "" 

190 

191 if part.doc.ignore_spelling: 

192 caption = f":spelling:ignore:`{caption}`" 

193 

194 # Read template from resources 

195 template_str = TEMPLATE_DIR.joinpath("file_part.rst.template").read_text("utf-8") 

196 

197 # Render template 

198 template = self.env.from_string(template_str) 

199 value = template.render( 

200 { 

201 "part": part, 

202 "content": content, 

203 "caption": caption, 

204 "text_after": self.render(part.doc.text_after), 

205 "text_before": self.render(part.doc.text_before), 

206 } 

207 ) 

208 return value 

209 

210 def render_alternatives(self, part: AlternativeModel) -> str: 

211 assert part.doc is not False # Already checked by the caller 

212 tabs = [] 

213 for key, alternate_part in part.alternatives.items(): 

214 config = self.tutorial.configuration.doc.alternatives.get( 

215 key, DocumentationAlternativeConfigurationModel() 

216 ) 

217 name = config.name or key 

218 

219 # When rendering documentation, any changes to the context are discarded after the alternative, 

220 # as different alternatives could set different values and this would be pretty unpredictable 

221 # for the user. 

222 additional_context = {"alternative": key, "alternative_name": name, **config.context} 

223 with self.update_context(additional_context): 

224 if alternate_part.doc is False: 

225 continue 

226 

227 if isinstance(alternate_part, CommandsPartModel): 

228 tabs.append((name, self.render_code_block(alternate_part).strip())) 

229 elif isinstance(alternate_part, FilePartModel): 

230 tabs.append((name, self.render_file(alternate_part).strip())) 

231 else: # pragma: no cover 

232 raise ExtensionError("Alternative found unknown part type.") 

233 

234 # Read template from resources 

235 template_str = TEMPLATE_DIR.joinpath("alternative_part.rst.template").read_text("utf-8") 

236 

237 # Render template 

238 template = self.env.from_string(template_str) 

239 value = template.render( 

240 { 

241 "part": part, 

242 "tabs": tabs, 

243 "text_after": self.render(part.doc.text_after), 

244 "text_before": self.render(part.doc.text_before), 

245 } 

246 ) 

247 

248 if part.chdir: 

249 self.context["cwd"] = self.render(str(part.chdir)) 

250 elif part.chdir is False: 

251 self.context["cwd"] = self.orig_cwd 

252 

253 return value.strip() 

254 

255 def render_part(self, part_id: str | None = None) -> str: 

256 """Render the given part of the tutorial.""" 

257 # Find the next part that is not skipped 

258 for part in self.tutorial.parts[self.next_part :]: 

259 self.next_part += 1 

260 

261 # Ignore prompt models when rendering tutorials. 

262 if isinstance(part, PromptModel): 

263 continue 

264 

265 # If the part is not configured to be skipped for docs, use it. 

266 if part.doc is not False: 

267 if part_id is not None and part.id != part_id: 

268 raise ExtensionError(f"{part_id}: Part is not the next part (next one is {part.id}).") 

269 break 

270 else: 

271 raise ExtensionError(f"{self.path}: No more parts left in tutorial.") 

272 

273 if isinstance(part, CommandsPartModel): 

274 text = self.render_code_block(part) 

275 elif isinstance(part, FilePartModel): 

276 text = self.render_file(part) 

277 elif isinstance(part, AlternativeModel): 

278 text = self.render_alternatives(part) 

279 else: # pragma: no cover 

280 raise ExtensionError(f"{part}: Unsupported part type.") 

281 

282 self.context.update(part.doc.update_context) 

283 return text