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
« 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.
4"""Utility functions for the sphinx extension."""
6import os
7import shlex
8from copy import deepcopy
9from importlib import resources
10from pathlib import Path
11from typing import Any
13from jinja2 import Environment
14from sphinx.application import Sphinx
15from sphinx.config import Config
16from sphinx.errors import ConfigError, ExtensionError
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
29TEMPLATE_DIR = resources.files(templates)
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.")
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.")
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
53class TutorialWrapper:
54 """Wrapper class for rendering a tutorial.
56 This class exists mainly to wrap the main logic into a separate class that is more easily testable.
57 """
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)
71 # settings from sphinx:
72 self.command_text_width = command_text_width
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)
82 def render(self, template: str) -> str:
83 return self.env.from_string(template).render(self.context)
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
93 # Render the prompt
94 prompt = self.env.from_string(self.context["prompt_template"]).render(self.context)
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)
102 # Render output
103 output_template = command_config.doc.output.rstrip("\n")
104 output = self.env.from_string(output_template).render(self.context)
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)
118 # Update the context from update_context
119 self.context.update(command_config.doc.update_context)
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 )
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()
138 # Only render template if it is configured to be a template.
139 if part.template:
140 content = self.render(content)
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 = ""
158 if part.doc.ignore_spelling:
159 caption = f":spelling:ignore:`{caption}`"
161 # Read template from resources
162 template_str = TEMPLATE_DIR.joinpath("file_part.rst.template").read_text("utf-8")
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
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)
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.")
189 # Read template from resources
190 template_str = TEMPLATE_DIR.joinpath("alternative_part.rst.template").read_text("utf-8")
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()
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
210 # Ignore prompt models when rendering tutorials.
211 if isinstance(part, PromptModel):
212 continue
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.")
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.")
231 self.context.update(part.doc.update_context)
232 return text