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

1# SPDX-License-Identifier: GPL-3.0-or-later 

2"""Sphinx directive for embedding collapsible cikit log files. 

3 

4The directive renders log files as collapsed HTML details elements while 

5providing a regular literal block as fallback for non-HTML builders. 

6 

7Author: 

8 Sven Schlender 

9Date: 

10 2026-09-13 

11""" 

12 

13from html import escape 

14from pathlib import Path 

15 

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 

21 

22 

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} 

31 

32.cikit-log > summary { 

33 padding: 0.6em 0.8em; 

34 cursor: pointer; 

35 font-weight: 600; 

36} 

37 

38.cikit-log > p { 

39 margin: 0; 

40 padding: 0 0.8em 0.5em; 

41} 

42 

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() 

54 

55 

56def _add_log_style(_app, _pagename, _templatename, context, _doctree): 

57 """Add cikit log styling to HTML pages. 

58 

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. 

61 

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}' 

70 

71 

72class CikitLogDirective(SphinxDirective): 

73 """Embed a log file as a collapsible documentation block. 

74 

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 """ 

79 

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} 

86 

87 def run(self): 

88 """Read the referenced log file and create builder-specific nodes. 

89 

90 Returns: 

91 Nodes containing a collapsible HTML log and a non-HTML fallback. 

92 

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) 

98 

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 

103 

104 self.env.note_dependency(str(log_file)) 

105 

106 title = self.options.get("title", Path(uri).name) 

107 classes = ["cikit-log", *self.options.get("class", [])] 

108 

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") 

113 

114 fallback_node = addnodes.only(expr="not html") 

115 fallback_node += self._build_fallback(uri, title, content, classes) 

116 

117 return [html_node, fallback_node] 

118 

119 def _resolve_log_file(self, uri: str) -> Path: 

120 """Resolve a log URI relative to the current RST source file. 

121 

122 Args: 

123 uri: Relative log file URI from the directive argument. 

124 

125 Returns: 

126 Resolved filesystem path. 

127 """ 

128 source_file = Path(self.env.doc2path(self.env.docname, base=True)) 

129 

130 return (source_file.parent / uri).resolve() 

131 

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. 

136 

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. 

143 

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 "" 

152 

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 ) 

160 

161 @staticmethod 

162 def _build_fallback(uri: str, title: str, content: str, classes: list): 

163 """Build the non-HTML fallback for a log file. 

164 

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. 

170 

171 Returns: 

172 Docutils container containing the log. 

173 """ 

174 container = nodes.container(classes=classes) 

175 container += nodes.rubric(text=title) 

176 

177 link = nodes.paragraph() 

178 link += nodes.reference("", "Download log", refuri=uri) 

179 container += link 

180 container += nodes.literal_block(content, content) 

181 

182 return container 

183 

184 

185def setup(app): 

186 """Register the cikit log directive with Sphinx. 

187 

188 Args: 

189 app: Active Sphinx application. 

190 

191 Returns: 

192 Sphinx extension metadata. 

193 """ 

194 app.add_directive("cikit-log", CikitLogDirective) 

195 app.connect("html-page-context", _add_log_style) 

196 

197 return {"version": "1.0", "parallel_read_safe": True, "parallel_write_safe": True}