Coverage for src/coursebook/assembly.py: 99%

54 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-31 22:06 +0000

1"""Combines the source files of a project into a single markdown document. 

2 

3The outline sets the order of the source files and adds a heading for each 

4entry. The headings inside each source file are moved beneath the heading of 

5its entry, and its footnote labels are made unique so that footnotes from 

6different files do not collide. 

7 

8A source file's front matter may set: 

9 

10* `title`: the title used in place of the outline title in headings. 

11* `visuals_in_notes`: whether to copy the images in its text into its notes 

12 (overriding the project setting). 

13 

14Contents: 

15 assemble_document: combines one section of every source into a document. 

16 assemble_slides: combines the slides of every source into a slide deck. 

17 _MAXIMUM_HEADING_LEVEL: the deepest heading level that markdown supports. 

18 

19""" 

20 

21from __future__ import annotations 

22 

23import dataclasses 

24from typing import TYPE_CHECKING 

25 

26from . import markdown 

27 

28if TYPE_CHECKING: 

29 from collections.abc import Mapping 

30 

31 from . import outline, sources, styles 

32 

33_MAXIMUM_HEADING_LEVEL: int = 6 

34 

35 

36""" Public Functions """ 

37 

38 

39def assemble_document( 

40 project_outline: outline.Outline, 

41 project_sources: Mapping[str, sources.Source], 

42 *, 

43 section: str, 

44 style: styles.Style, 

45 include_empty: bool = True, 

46 visuals_in_notes: bool = False) -> str: 

47 """Combines one section of every source into a markdown document. 

48 

49 Each outline entry becomes a heading at the level of the entry (so a top 

50 level entry is a level one heading). The headings in each source are 

51 moved beneath the heading of its entry. 

52 

53 Args: 

54 project_outline: the outline of the project. 

55 project_sources: the sources of the project, with keys that are the 

56 names of the outline entries. 

57 section: the section to combine ('text' or 'notes'). 

58 style: the style of the project. 

59 include_empty: whether to add headings for entries that have no 

60 content in `section` (and no entries beneath them with content). 

61 Defaults to `True`. 

62 visuals_in_notes: whether to copy the images in the text of each 

63 source into its notes when `section` is 'notes'. A source's front 

64 matter may override this. Defaults to `False`. 

65 

66 Returns: 

67 The combined markdown. 

68 

69 """ 

70 parts: list[str] = [] 

71 for entry in project_outline.walk(): 

72 if not include_empty and not _has_content( 

73 entry, project_sources, section, visuals_in_notes): 

74 continue 

75 source = project_sources.get(entry.name) 

76 level = min(entry.level + 1, _MAXIMUM_HEADING_LEVEL) 

77 heading = style.format_heading(_titled(entry, source)) 

78 parts.append(f'{"#" * level} {heading}') 

79 body = _get_body(source, section, visuals_in_notes) 

80 if body: 

81 body = markdown.namespace_footnotes(body, prefix = entry.name) 

82 parts.append(markdown.shift_headings(body, levels = level)) 

83 return _join(parts) 

84 

85 

86def assemble_slides( 

87 project_outline: outline.Outline, 

88 project_sources: Mapping[str, sources.Source], 

89 *, 

90 style: styles.Style, 

91 section_slides: bool = True) -> str: 

92 """Combines the slides of every source into a markdown slide deck. 

93 

94 The deck uses pandoc's slide level 2: each level two heading (or 

95 horizontal rule) starts a new slide and each level one heading is a 

96 section title slide. In a source file, each slide starts with a level one 

97 heading (which becomes level two) or a horizontal rule ('---'). If the 

98 slides of a source do not start with a heading, the first slide is given 

99 the heading of its entry. 

100 

101 Args: 

102 project_outline: the outline of the project. 

103 project_sources: the sources of the project, with keys that are the 

104 names of the outline entries. 

105 style: the style of the project. 

106 section_slides: whether to add a section title slide for each entry 

107 that has slides (or entries beneath it with slides). Defaults to 

108 `True`. 

109 

110 Returns: 

111 The combined markdown. 

112 

113 """ 

114 parts: list[str] = [] 

115 for entry in project_outline.walk(): 

116 if not _has_content(entry, project_sources, 'slides'): 

117 continue 

118 source = project_sources.get(entry.name) 

119 heading = style.format_heading(_titled(entry, source)) 

120 if section_slides: 120 ↛ 122line 120 didn't jump to line 122 because the condition on line 120 was always true

121 parts.append(f'# {heading}') 

122 body = _get_body(source, 'slides') 

123 if body: 

124 if not body.lstrip().startswith('#'): 

125 parts.append(f'## {heading}') 

126 body = markdown.namespace_footnotes(body, prefix = entry.name) 

127 parts.append(markdown.shift_headings(body, levels = 1)) 

128 return _join(parts) 

129 

130 

131""" Private Functions """ 

132 

133 

134def _get_body( 

135 source: sources.Source | None, 

136 section: str, 

137 visuals_in_notes: bool = False) -> str: 

138 """Returns the markdown of `section` in `source`. 

139 

140 Args: 

141 source: the source file, or `None` if the entry has no source file. 

142 section: the section to return. 

143 visuals_in_notes: whether to add the images in the text of `source` 

144 to its notes when `section` is 'notes'. The front matter of 

145 `source` may override this. Defaults to `False`. 

146 

147 Returns: 

148 The markdown of the section, or an empty `str` if there is none. 

149 

150 """ 

151 if source is None: 

152 return '' 

153 body = source.get(section) 

154 copy_visuals = bool(source.metadata.get( 

155 'visuals_in_notes', visuals_in_notes)) 

156 if section == 'notes' and copy_visuals: 

157 images = markdown.extract_images(source.get('text')) 

158 body = '\n\n'.join(part for part in (body, *images) if part) 

159 return body 

160 

161 

162def _has_content( 

163 entry: outline.OutlineEntry, 

164 project_sources: Mapping[str, sources.Source], 

165 section: str, 

166 visuals_in_notes: bool = False) -> bool: 

167 """Returns whether `entry` or an entry beneath it has `section` content. 

168 

169 Args: 

170 entry: the outline entry. 

171 project_sources: the sources of the project. 

172 section: the section to check. 

173 visuals_in_notes: whether images in the text count as notes. Defaults 

174 to `False`. 

175 

176 Returns: 

177 Whether there is any content in `section` for `entry` or the entries 

178 beneath it. 

179 

180 """ 

181 return any( 

182 _get_body(project_sources.get(item.name), section, visuals_in_notes) 

183 for item in entry.walk()) 

184 

185 

186def _join(parts: list[str]) -> str: 

187 """Joins the parts of a markdown document. 

188 

189 Args: 

190 parts: the headings and bodies of the document, in order. 

191 

192 Returns: 

193 The parts separated by blank lines, with a final newline. 

194 

195 """ 

196 return '\n\n'.join(parts) + '\n' if parts else '' 

197 

198 

199def _titled( 

200 entry: outline.OutlineEntry, 

201 source: sources.Source | None) -> outline.OutlineEntry: 

202 """Returns `entry` with the title from the front matter of `source`. 

203 

204 Args: 

205 entry: the outline entry. 

206 source: the source file of `entry`, if any. 

207 

208 Returns: 

209 `entry`, or a copy with its title replaced if the front matter of 

210 `source` sets `title`. 

211 

212 """ 

213 if source is not None and source.metadata.get('title'): 

214 return dataclasses.replace( 

215 entry, title = str(source.metadata['title'])) 

216 return entry