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

118 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-26 12:41 +0100

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 copy import deepcopy 

9from importlib import resources 

10from pathlib import Path 

11from typing import Any 

12 

13from jinja2 import Environment 

14from sphinx.application import Sphinx 

15from sphinx.config import Config 

16from sphinx.errors import ConfigError, ExtensionError 

17 

18from structured_tutorials import templates 

19from structured_tutorials.errors import DestinationIsADirectoryError 

20from structured_tutorials.models import ( 

21 AlternativeModel, 

22 CommandsPartModel, 

23 FilePartModel, 

24 PromptModel, 

25 TutorialModel, 

26) 

27from structured_tutorials.textwrap import wrap_command_filter 

28 

29TEMPLATE_DIR = resources.files(templates) 

30 

31 

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

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

34 root = config.structured_tutorials_root 

35 if not isinstance(root, Path): 

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

37 if not root.is_absolute(): 

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

39 

40 

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

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

43 tutorial_path = Path(arg) 

44 if tutorial_path.is_absolute(): 

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

46 

47 absolute_path = tutorial_root / tutorial_path 

48 if not absolute_path.exists(): 

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

50 return absolute_path 

51 

52 

53class TutorialWrapper: 

54 """Wrapper class for rendering a tutorial. 

55 

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

57 """ 

58 

59 def __init__( 

60 self, tutorial: TutorialModel, context: dict[str, Any] | None = None, command_text_width: int = 75 

61 ) -> None: 

62 self.tutorial = tutorial 

63 self.next_part = 0 

64 self.env = Environment(keep_trailing_newline=True) 

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

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

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

68 if context: 

69 self.context.update(context) 

70 

71 # settings from sphinx: 

72 self.command_text_width = command_text_width 

73 

74 @classmethod 

75 def from_file( 

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

77 ) -> "TutorialWrapper": 

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

79 tutorial = TutorialModel.from_file(path) 

80 return cls(tutorial, context=context, command_text_width=command_text_width) 

81 

82 def render(self, template: str) -> str: 

83 return self.env.from_string(template).render(self.context) 

84 

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

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

87 commands = [] 

88 for command_config in part.commands: 

89 # Skip individual commands if marked as skipped for documentation 

90 if command_config.doc.skip: 

91 continue 

92 

93 # Render the prompt 

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

95 

96 # Render the command 

97 if isinstance(command_config.command, str): 

98 command = self.render(command_config.command) 

99 else: 

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

101 

102 # Render output 

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

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

105 

106 # Finally, render the command 

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

108{{ output }}{% endif %}""" 

109 command_context = { 

110 "prompt": prompt, 

111 "command": command, 

112 "output": output, 

113 "text_width": self.command_text_width, 

114 } 

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

116 commands.append(rendered_command) 

117 

118 # Update the context from update_context 

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

120 

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

122 template = self.env.from_string(template_str) 

123 return template.render( 

124 { 

125 "commands": commands, 

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

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

128 } 

129 ) 

130 

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

132 content = part.contents 

133 if content is None: 

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

135 with open(self.tutorial.tutorial_root / part.source) as stream: 

136 content = stream.read() 

137 

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

139 if part.template: 

140 content = self.render(content) 

141 

142 # Render the caption (default is the filename) 

143 if part.doc.caption: 

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

145 elif part.doc.caption is not False: 

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

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

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

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

150 if not part.source: 

151 raise DestinationIsADirectoryError( 

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

153 ) 

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

155 else: 

156 caption = "" 

157 

158 if part.doc.ignore_spelling: 

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

160 

161 # Read template from resources 

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

163 

164 # Render template 

165 template = self.env.from_string(template_str) 

166 value = template.render( 

167 { 

168 "part": part, 

169 "content": content, 

170 "caption": caption, 

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

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

173 } 

174 ) 

175 return value 

176 

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

178 tabs = [] 

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

180 key = self.tutorial.configuration.doc.alternative_names.get(key, key) 

181 

182 if isinstance(alternate_part, CommandsPartModel): 

183 tabs.append((key, self.render_code_block(alternate_part).strip())) 

184 elif isinstance(alternate_part, FilePartModel): 

185 tabs.append((key, self.render_file(alternate_part).strip())) 

186 else: # pragma: no cover 

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

188 

189 # Read template from resources 

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

191 

192 # Render template 

193 template = self.env.from_string(template_str) 

194 value = template.render( 

195 { 

196 "part": part, 

197 "tabs": tabs, 

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

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

200 } 

201 ) 

202 return value.strip() 

203 

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

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

206 # Find the next part that is not skipped 

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

208 self.next_part += 1 

209 

210 # Ignore prompt models when rendering tutorials. 

211 if isinstance(part, PromptModel): 

212 continue 

213 

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

215 if not part.doc.skip: 

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

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

218 break 

219 else: 

220 raise ExtensionError("No more parts left in tutorial.") 

221 

222 if isinstance(part, CommandsPartModel): 

223 text = self.render_code_block(part) 

224 elif isinstance(part, FilePartModel): 

225 text = self.render_file(part) 

226 elif isinstance(part, AlternativeModel): 

227 text = self.render_alternatives(part) 

228 else: # pragma: no cover 

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

230 

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

232 return text