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

112 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-21 19:08 +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 

11 

12from jinja2 import Environment 

13from sphinx.application import Sphinx 

14from sphinx.config import Config 

15from sphinx.errors import ConfigError, ExtensionError 

16 

17from structured_tutorials import templates 

18from structured_tutorials.errors import DestinationIsADirectoryError 

19from structured_tutorials.models import ( 

20 AlternativeModel, 

21 CommandsPartModel, 

22 FilePartModel, 

23 PromptModel, 

24 TutorialModel, 

25) 

26from structured_tutorials.textwrap import wrap_command_filter 

27 

28TEMPLATE_DIR = resources.files(templates) 

29 

30 

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

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

33 root = config.tutorial_root 

34 if not isinstance(root, Path): 

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

36 if not root.is_absolute(): 

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

38 

39 

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

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

42 tutorial_path = Path(arg) 

43 if tutorial_path.is_absolute(): 

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

45 

46 absolute_path = tutorial_root / tutorial_path 

47 if not absolute_path.exists(): 

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

49 return absolute_path 

50 

51 

52class TutorialWrapper: 

53 """Wrapper class for rendering a tutorial. 

54 

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

56 """ 

57 

58 def __init__(self, tutorial: TutorialModel, command_text_width: int = 75) -> None: 

59 self.tutorial = tutorial 

60 self.next_part = 0 

61 self.env = Environment(keep_trailing_newline=True) 

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

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

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

65 

66 # settings from sphinx: 

67 self.command_text_width = command_text_width 

68 

69 @classmethod 

70 def from_file(cls, path: Path, command_text_width: int = 75) -> "TutorialWrapper": 

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

72 tutorial = TutorialModel.from_file(path) 

73 return cls(tutorial) 

74 

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

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

77 

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

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

80 commands = [] 

81 for command_config in part.commands: 

82 # Render the prompt 

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

84 

85 # Render the command 

86 if isinstance(command_config.command, str): 

87 command = self.render(command_config.command) 

88 else: 

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

90 

91 # Render output 

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

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

94 

95 # Finally, render the command 

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

97{{ output }}{% endif %}""" 

98 command_context = { 

99 "prompt": prompt, 

100 "command": command, 

101 "output": output, 

102 "text_width": self.command_text_width, 

103 } 

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

105 commands.append(rendered_command) 

106 

107 # Update the context from update_context 

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

109 

110 template = """.. code-block:: console 

111 

112{% for cmd in commands %}{{ cmd|indent(4, first=True) }} 

113{% endfor %}""" 

114 return self.env.from_string(template).render({"commands": commands}) 

115 

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

117 content = part.contents 

118 if content is None: 

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

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

121 content = stream.read() 

122 

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

124 if part.template: 

125 content = self.render(content) 

126 

127 # Render the caption (default is the filename) 

128 if part.doc.caption: 

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

130 elif part.doc.caption is not False: 

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

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

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

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

135 if not part.source: 

136 raise DestinationIsADirectoryError( 

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

138 ) 

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

140 else: 

141 caption = "" 

142 

143 if part.doc.ignore_spelling: 

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

145 

146 # Read template from resources 

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

148 

149 # Render template 

150 template = self.env.from_string(template_str) 

151 value = template.render({"part": part, "content": content, "caption": caption}) 

152 return value 

153 

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

155 tabs = [] 

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

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

158 

159 if isinstance(alternate_part, CommandsPartModel): 

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

161 elif isinstance(alternate_part, FilePartModel): 

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

163 else: # pragma: no cover 

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

165 

166 # Read template from resources 

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

168 

169 # Render template 

170 template = self.env.from_string(template_str) 

171 value = template.render({"part": part, "tabs": tabs}) 

172 return value.strip() 

173 

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

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

176 # Find the next part that is not skipped 

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

178 self.next_part += 1 

179 

180 # Ignore prompt models when rendering tutorials. 

181 if isinstance(part, PromptModel): 

182 continue 

183 

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

185 if not part.doc.skip: 

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

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

188 break 

189 else: 

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

191 

192 if isinstance(part, CommandsPartModel): 

193 text = self.render_code_block(part) 

194 elif isinstance(part, FilePartModel): 

195 text = self.render_file(part) 

196 elif isinstance(part, AlternativeModel): 

197 text = self.render_alternatives(part) 

198 else: # pragma: no cover 

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

200 

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

202 return text