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
« 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.
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 # Render the prompt
90 prompt = self.env.from_string(self.context["prompt_template"]).render(self.context)
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)
98 # Render output
99 output_template = command_config.doc.output.rstrip("\n")
100 output = self.env.from_string(output_template).render(self.context)
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)
114 # Update the context from update_context
115 self.context.update(command_config.doc.update_context)
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 )
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()
134 # Only render template if it is configured to be a template.
135 if part.template:
136 content = self.render(content)
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 = ""
154 if part.doc.ignore_spelling:
155 caption = f":spelling:ignore:`{caption}`"
157 # Read template from resources
158 template_str = TEMPLATE_DIR.joinpath("file_part.rst.template").read_text("utf-8")
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
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)
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.")
185 # Read template from resources
186 template_str = TEMPLATE_DIR.joinpath("alternative_part.rst.template").read_text("utf-8")
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()
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
206 # Ignore prompt models when rendering tutorials.
207 if isinstance(part, PromptModel):
208 continue
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.")
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.")
227 self.context.update(part.doc.update_context)
228 return text