Coverage for /builds/svesch/cikit/src/cikit/sphinx_ext/log.py: 100%
55 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-10-09 14:17 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-10-09 14:17 +0000
1# SPDX-License-Identifier: GPL-3.0-or-later
2"""Sphinx directive for embedding collapsible cikit log files.
4The directive renders log files as collapsed HTML details elements while
5providing a regular literal block as fallback for non-HTML builders.
7Author:
8 Sven Schlender
9Date:
10 2026-09-13
11"""
13from html import escape
14from pathlib import Path
16from docutils import nodes
17from docutils.parsers.rst import directives
18from sphinx import addnodes
19from sphinx.errors import SphinxError
20from sphinx.util.docutils import SphinxDirective
23LOG_STYLE = """
24<style>
25.cikit-log {
26 margin: 1.5em 0;
27 border: 1px solid #d9d9d9;
28 border-radius: 4px;
29 background: #f8f8f8;
30}
32.cikit-log > summary {
33 padding: 0.6em 0.8em;
34 cursor: pointer;
35 font-weight: 600;
36}
38.cikit-log > p {
39 margin: 0;
40 padding: 0 0.8em 0.5em;
41}
43.cikit-log-content {
44 box-sizing: border-box;
45 max-width: 100%;
46 margin: 0;
47 padding: 0.8em;
48 font-size: 0.85em;
49 white-space: pre-wrap;
50 overflow-wrap: anywhere;
51}
52</style>
53""".strip()
56def _add_log_style(_app, _pagename, _templatename, context, _doctree):
57 """Add cikit log styling to HTML pages.
59 The style is injected through the Sphinx HTML page context so projects
60 using the extension do not need to provide additional static CSS files.
62 Args:
63 _app: Active Sphinx application.
64 _pagename: Name of the rendered page.
65 _templatename: HTML template used for the page.
66 context: HTML template context.
67 _doctree: Document tree of the rendered page.
68 """
69 context["metatags"] = f'{context.get("metatags", "")}\n{LOG_STYLE}'
72class CikitLogDirective(SphinxDirective):
73 """Embed a log file as a collapsible documentation block.
75 HTML output uses a native ``details`` element that is collapsed by
76 default. Other builders receive a normal literal block containing the
77 same log output.
78 """
80 required_arguments = 1
81 optional_arguments = 0
82 final_argument_whitespace = False
83 has_content = False
84 option_spec = {"title": directives.unchanged, "open": directives.flag,
85 "class": directives.class_option}
87 def run(self):
88 """Read the referenced log file and create builder-specific nodes.
90 Returns:
91 Nodes containing a collapsible HTML log and a non-HTML fallback.
93 Raises:
94 SphinxError: If the referenced log file cannot be read.
95 """
96 uri = directives.uri(self.arguments[0])
97 log_file = self._resolve_log_file(uri)
99 try:
100 content = log_file.read_text(encoding="utf-8", errors="replace")
101 except OSError as exp:
102 raise SphinxError(f"Failed to read cikit log '{log_file}': {exp}") from exp
104 self.env.note_dependency(str(log_file))
106 title = self.options.get("title", Path(uri).name)
107 classes = ["cikit-log", *self.options.get("class", [])]
109 html_node = addnodes.only(expr="html")
110 html_node += nodes.raw("",
111 self._build_html(uri, title, content, classes,
112 "open" in self.options), format="html")
114 fallback_node = addnodes.only(expr="not html")
115 fallback_node += self._build_fallback(uri, title, content, classes)
117 return [html_node, fallback_node]
119 def _resolve_log_file(self, uri: str) -> Path:
120 """Resolve a log URI relative to the current RST source file.
122 Args:
123 uri: Relative log file URI from the directive argument.
125 Returns:
126 Resolved filesystem path.
127 """
128 source_file = Path(self.env.doc2path(self.env.docname, base=True))
130 return (source_file.parent / uri).resolve()
132 @staticmethod
133 def _build_html(uri: str, title: str, content: str, classes: list,
134 opened: bool) -> str:
135 """Build collapsible HTML markup for a log file.
137 Args:
138 uri: Relative log file URI.
139 title: Display title.
140 content: Log file contents.
141 classes: CSS classes assigned to the details element.
142 opened: Whether the details element is initially expanded.
144 Returns:
145 Escaped HTML markup.
146 """
147 safe_uri = escape(uri, quote=True)
148 safe_title = escape(title)
149 safe_content = escape(content)
150 safe_classes = escape(" ".join(classes), quote=True)
151 open_attr = " open" if opened else ""
153 return (
154 f'<details class="{safe_classes}"{open_attr}>'
155 f"<summary>{safe_title}</summary>"
156 f'<p><a href="{safe_uri}" download>Download log</a></p>'
157 f'<pre class="cikit-log-content">{safe_content}</pre>'
158 "</details>"
159 )
161 @staticmethod
162 def _build_fallback(uri: str, title: str, content: str, classes: list):
163 """Build the non-HTML fallback for a log file.
165 Args:
166 uri: Relative log file URI.
167 title: Display title.
168 content: Log file contents.
169 classes: CSS classes assigned to the container.
171 Returns:
172 Docutils container containing the log.
173 """
174 container = nodes.container(classes=classes)
175 container += nodes.rubric(text=title)
177 link = nodes.paragraph()
178 link += nodes.reference("", "Download log", refuri=uri)
179 container += link
180 container += nodes.literal_block(content, content)
182 return container
185def setup(app):
186 """Register the cikit log directive with Sphinx.
188 Args:
189 app: Active Sphinx application.
191 Returns:
192 Sphinx extension metadata.
193 """
194 app.add_directive("cikit-log", CikitLogDirective)
195 app.connect("html-page-context", _add_log_style)
197 return {"version": "1.0", "parallel_read_safe": True, "parallel_write_safe": True}