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
« 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.
4"""Utility functions for the sphinx extension."""
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
15from jinja2 import Environment
16from sphinx.application import Sphinx
17from sphinx.config import Config
18from sphinx.errors import ConfigError, ExtensionError
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
32TEMPLATE_DIR = resources.files(templates)
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.")
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.")
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
56class TutorialWrapper:
57 """Wrapper class for rendering a tutorial.
59 This class exists mainly to wrap the main logic into a separate class that is more easily testable.
60 """
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)
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"]
86 # settings from sphinx:
87 self.command_text_width = command_text_width
88 self.orig_cwd = self.context["cwd"]
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
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)
109 def render(self, template: str, **context: Any) -> str:
110 return self.env.from_string(template).render({**self.context, **context})
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
121 # Render the prompt
122 prompt = self.env.from_string(self.context["prompt_template"]).render(self.context)
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)
130 # Render output
131 output_template = command_config.doc.output.rstrip("\n")
132 output = self.env.from_string(output_template).render(self.context)
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)
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
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 )
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()
171 # Only render template if it is configured to be a template.
172 if part.template:
173 content = self.render(content)
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 = ""
191 if part.doc.ignore_spelling:
192 caption = f":spelling:ignore:`{caption}`"
194 # Read template from resources
195 template_str = TEMPLATE_DIR.joinpath("file_part.rst.template").read_text("utf-8")
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
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
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
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.")
234 # Read template from resources
235 template_str = TEMPLATE_DIR.joinpath("alternative_part.rst.template").read_text("utf-8")
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 )
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
253 return value.strip()
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
261 # Ignore prompt models when rendering tutorials.
262 if isinstance(part, PromptModel):
263 continue
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.")
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.")
282 self.context.update(part.doc.update_context)
283 return text