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

116 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-22 22:55 +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 # Render the prompt 

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

91 

92 # Render the command 

93 if isinstance(command_config.command, str): 

94 command = self.render(command_config.command) 

95 else: 

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

97 

98 # Render output 

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

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

101 

102 # Finally, render the command 

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

104{{ output }}{% endif %}""" 

105 command_context = { 

106 "prompt": prompt, 

107 "command": command, 

108 "output": output, 

109 "text_width": self.command_text_width, 

110 } 

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

112 commands.append(rendered_command) 

113 

114 # Update the context from update_context 

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

116 

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

118 template = self.env.from_string(template_str) 

119 return template.render( 

120 { 

121 "commands": commands, 

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

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

124 } 

125 ) 

126 

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

128 content = part.contents 

129 if content is None: 

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

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

132 content = stream.read() 

133 

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

135 if part.template: 

136 content = self.render(content) 

137 

138 # Render the caption (default is the filename) 

139 if part.doc.caption: 

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

141 elif part.doc.caption is not False: 

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

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

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

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

146 if not part.source: 

147 raise DestinationIsADirectoryError( 

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

149 ) 

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

151 else: 

152 caption = "" 

153 

154 if part.doc.ignore_spelling: 

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

156 

157 # Read template from resources 

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

159 

160 # Render template 

161 template = self.env.from_string(template_str) 

162 value = template.render( 

163 { 

164 "part": part, 

165 "content": content, 

166 "caption": caption, 

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

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

169 } 

170 ) 

171 return value 

172 

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

174 tabs = [] 

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

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

177 

178 if isinstance(alternate_part, CommandsPartModel): 

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

180 elif isinstance(alternate_part, FilePartModel): 

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

182 else: # pragma: no cover 

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

184 

185 # Read template from resources 

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

187 

188 # Render template 

189 template = self.env.from_string(template_str) 

190 value = template.render( 

191 { 

192 "part": part, 

193 "tabs": tabs, 

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

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

196 } 

197 ) 

198 return value.strip() 

199 

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

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

202 # Find the next part that is not skipped 

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

204 self.next_part += 1 

205 

206 # Ignore prompt models when rendering tutorials. 

207 if isinstance(part, PromptModel): 

208 continue 

209 

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

211 if not part.doc.skip: 

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

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

214 break 

215 else: 

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

217 

218 if isinstance(part, CommandsPartModel): 

219 text = self.render_code_block(part) 

220 elif isinstance(part, FilePartModel): 

221 text = self.render_file(part) 

222 elif isinstance(part, AlternativeModel): 

223 text = self.render_alternatives(part) 

224 else: # pragma: no cover 

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

226 

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

228 return text