Coverage for src/coursebook/scaffold.py: 96%

97 statements  

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

1"""Creates the files for new projects and editable templates. 

2 

3Contents: 

4 PROJECT_FILE_FORMATS: the formats that a new project file may use. 

5 create_project: creates a project folder with a project file and source 

6 files. 

7 write_template: writes the Word or PowerPoint template of a style. 

8 _DEFAULT_OUTLINE: the outline used when none is given. 

9 

10""" 

11 

12from __future__ import annotations 

13 

14import json 

15import pathlib 

16import shutil 

17import string 

18import tempfile 

19from typing import TYPE_CHECKING, Any 

20 

21import yaml 

22 

23from . import outline, settings, styles, templates 

24 

25if TYPE_CHECKING: 

26 from collections.abc import Iterable 

27 

28PROJECT_FILE_FORMATS: tuple[str, ...] = ('ini', 'toml', 'yaml') 

29_DEFAULT_OUTLINE: tuple[str, ...] = ( 

30 'I: Introduction', 

31 'II: Background', 

32 'III: Analysis', 

33 'IV: Conclusion') 

34 

35 

36""" Public Functions """ 

37 

38 

39def create_project( 

40 folder: str | pathlib.Path, 

41 name: str | None = None, 

42 style: str = 'coursebook', 

43 outline_lines: Iterable[outline.OutlineLine] | None = None, 

44 file_format: str = 'ini') -> list[pathlib.Path]: 

45 """Creates a project folder with a project file and source files. 

46 

47 Each outline entry gets a source file with a divider for each section of 

48 the style. Source files that already exist are left alone. If two entries 

49 would share a source file (because they have the same title), the later 

50 one is given its own file in the outline. 

51 

52 Args: 

53 folder: the project folder, which is created if it does not exist. 

54 name: the title of the project. Defaults to `None`, which uses the 

55 name of `folder` in title case, with spaces for underscores and 

56 dashes. 

57 style: the name of the style. Defaults to 'coursebook'. 

58 outline_lines: the outline of the project. Defaults to `None`, which 

59 uses a short sample outline. 

60 file_format: the format of the project file ('ini', 'toml', or 

61 'yaml'). Defaults to 'ini'. 

62 

63 Returns: 

64 The paths to the files and folders that were created. 

65 

66 Raises: 

67 FileExistsError: if `folder` already has a project file. 

68 ValueError: if the style, outline, or file format is not valid. 

69 

70 """ 

71 folder = pathlib.Path(folder) 

72 if file_format not in PROJECT_FILE_FORMATS: 

73 message = ( 

74 f'unknown project file format "{file_format}" (use ' 

75 f'{", ".join(PROJECT_FILE_FORMATS)})') 

76 raise ValueError(message) 

77 try: 

78 existing = settings.find_project_file(folder) 

79 except FileNotFoundError: 

80 pass 

81 else: 

82 message = f'{folder} already has a project file ({existing.name})' 

83 raise FileExistsError(message) 

84 project_style = styles.get_style(style) 

85 project_outline = outline.Outline.from_lines( 

86 list(outline_lines) if outline_lines else _DEFAULT_OUTLINE) 

87 _give_entries_own_files(project_outline) 

88 if not name: 

89 words = folder.resolve().name.replace('_', ' ').replace('-', ' ') 

90 name = string.capwords(words) or 'Untitled' 

91 folder.mkdir(parents = True, exist_ok = True) 

92 project_file = folder / f'project.{file_format}' 

93 project_file.write_text( 

94 _format_project_file(name, style, project_outline, file_format), 

95 encoding = 'utf-8') 

96 created = [project_file] 

97 skeleton = '\n\n\n'.join( 

98 f'<{section}>' for section in project_style.sections) + '\n' 

99 for entry in project_outline: 

100 path = folder / entry.file_name 

101 if path.exists(): 

102 continue 

103 path.parent.mkdir(parents = True, exist_ok = True) 

104 path.write_text(skeleton, encoding = 'utf-8') 

105 created.append(path) 

106 visuals = folder / 'visuals' 

107 if not visuals.exists(): 107 ↛ 110line 107 didn't jump to line 110 because the condition on line 107 was always true

108 visuals.mkdir() 

109 created.append(visuals) 

110 return created 

111 

112 

113def write_template( 

114 style: str, 

115 destination: str | pathlib.Path) -> pathlib.Path: 

116 """Writes the Word or PowerPoint template of a style. 

117 

118 The template can be changed in Word or PowerPoint and then named in the 

119 "Text Template" or "Slides Template" setting of a project. 

120 

121 Args: 

122 style: the name of the style. 

123 destination: path for the template. Its extension ('.docx' or 

124 '.pptx') chooses which template is written. 

125 

126 Returns: 

127 The path to the template. 

128 

129 Raises: 

130 FileExistsError: if `destination` already exists. 

131 ValueError: if the style or extension is not valid. 

132 

133 """ 

134 destination = pathlib.Path(destination) 

135 suffix = destination.suffix.lower() 

136 if suffix not in {'.docx', '.pptx'}: 

137 message = f'{destination.name} must end with .docx or .pptx' 

138 raise ValueError(message) 

139 if destination.exists(): 

140 message = f'{destination} already exists' 

141 raise FileExistsError(message) 

142 project_style = styles.get_style(style) 

143 destination.parent.mkdir(parents = True, exist_ok = True) 

144 if suffix == '.docx': 

145 name = project_style.text_template 

146 if name is None: 146 ↛ 149line 146 didn't jump to line 149 because the condition on line 146 was always true

147 return templates.create_word_template( 

148 project_style.formatting, destination) 

149 application = 'word' 

150 else: 

151 name = project_style.slides_template 

152 if name is None: 

153 destination.write_bytes(templates.get_pandoc_template('pptx')) 

154 return destination 

155 application = 'powerpoint' 

156 found = templates.find_template( 

157 name, application = application, folder = pathlib.Path.cwd()) 

158 with tempfile.TemporaryDirectory() as temporary: 

159 prepared = templates.prepare_template(found, temporary) 

160 shutil.copyfile(prepared, destination) 

161 return destination 

162 

163 

164""" Private Functions """ 

165 

166 

167def _format_entry(entry: outline.OutlineEntry) -> str: 

168 """Returns the outline line for `entry`. 

169 

170 Args: 

171 entry: an outline entry. 

172 

173 Returns: 

174 The line, with dashes for an unnumbered entry and the file after a '|' 

175 if the entry names one. 

176 

177 """ 

178 label = entry.label or '-' * (entry.level + 1) 

179 line = f'{label}: {entry.title}' 

180 return f'{line} | {entry.file}' if entry.file else line 

181 

182 

183def _format_project_file( 

184 name: str, 

185 style: str, 

186 project_outline: outline.Outline, 

187 file_format: str) -> str: 

188 """Returns the contents of a new project file. 

189 

190 Args: 

191 name: the title of the project. 

192 style: the name of the style. 

193 project_outline: the outline of the project. 

194 file_format: 'ini', 'toml', or 'yaml'. 

195 

196 Returns: 

197 The text of the project file. 

198 

199 """ 

200 lines = [_format_entry(entry) for entry in project_outline] 

201 options: dict[str, Any] = { 

202 'name': name, 'style': style, 'output_folder': 'output'} 

203 if file_format == 'toml': 

204 items = ',\n'.join(f' {json.dumps(line)}' for line in lines) 

205 values = '\n'.join( 

206 f'{key} = {json.dumps(value)}' for key, value in options.items()) 

207 return f'outline = [\n{items}]\n\n[project]\n{values}\n' 

208 if file_format == 'yaml': 

209 return yaml.safe_dump( 

210 {'project': options, 'outline': lines}, 

211 allow_unicode = True, 

212 sort_keys = False) 

213 outline_text = '\n'.join(lines) 

214 return ( 

215 f'[Project]\nName: {name}\nStyle: {style}\n' 

216 '# Author: First Author; Second Author\n' 

217 '# Bibliography: references.bib\n' 

218 '# Text Format: word, pdf\n' 

219 f'Output Folder: output\n\n[Outline]\n{outline_text}\n') 

220 

221 

222def _give_entries_own_files(project_outline: outline.Outline) -> None: 

223 """Gives each outline entry that would share a source file its own file. 

224 

225 Args: 

226 project_outline: the outline, whose entries are changed in place. 

227 

228 """ 

229 used: set[str] = set() 

230 for entry in project_outline: 

231 if entry.name in used: 

232 number = 2 

233 while f'{entry.name}_{number}' in used: 233 ↛ 234line 233 didn't jump to line 234 because the condition on line 233 was never true

234 number += 1 

235 entry.file = f'{entry.name}_{number}.md' 

236 used.add(entry.name)