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
« 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.
4"""Utility functions for the sphinx extension."""
6import os
7import shlex
8from copy import deepcopy
9from importlib import resources
10from pathlib import Path
12from jinja2 import Environment
13from sphinx.application import Sphinx
14from sphinx.config import Config
15from sphinx.errors import ConfigError, ExtensionError
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
28TEMPLATE_DIR = resources.files(templates)
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.")
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.")
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
52class TutorialWrapper:
53 """Wrapper class for rendering a tutorial.
55 This class exists mainly to wrap the main logic into a separate class that is more easily testable.
56 """
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))
66 # settings from sphinx:
67 self.command_text_width = command_text_width
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)
75 def render(self, template: str) -> str:
76 return self.env.from_string(template).render(self.context)
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)
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)
91 # Render output
92 output_template = command_config.doc.output.rstrip("\n")
93 output = self.env.from_string(output_template).render(self.context)
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)
107 # Update the context from update_context
108 self.context.update(command_config.doc.update_context)
110 template = """.. code-block:: console
112{% for cmd in commands %}{{ cmd|indent(4, first=True) }}
113{% endfor %}"""
114 return self.env.from_string(template).render({"commands": commands})
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()
123 # Only render template if it is configured to be a template.
124 if part.template:
125 content = self.render(content)
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 = ""
143 if part.doc.ignore_spelling:
144 caption = f":spelling:ignore:`{caption}`"
146 # Read template from resources
147 template_str = TEMPLATE_DIR.joinpath("file_part.rst.template").read_text("utf-8")
149 # Render template
150 template = self.env.from_string(template_str)
151 value = template.render({"part": part, "content": content, "caption": caption})
152 return value
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)
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.")
166 # Read template from resources
167 template_str = TEMPLATE_DIR.joinpath("alternative_part.rst.template").read_text("utf-8")
169 # Render template
170 template = self.env.from_string(template_str)
171 value = template.render({"part": part, "tabs": tabs})
172 return value.strip()
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
180 # Ignore prompt models when rendering tutorials.
181 if isinstance(part, PromptModel):
182 continue
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.")
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.")
201 self.context.update(part.doc.update_context)
202 return text