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
« 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.
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.
10"""
12from __future__ import annotations
14import json
15import pathlib
16import shutil
17import string
18import tempfile
19from typing import TYPE_CHECKING, Any
21import yaml
23from . import outline, settings, styles, templates
25if TYPE_CHECKING:
26 from collections.abc import Iterable
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')
36""" Public Functions """
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.
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.
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'.
63 Returns:
64 The paths to the files and folders that were created.
66 Raises:
67 FileExistsError: if `folder` already has a project file.
68 ValueError: if the style, outline, or file format is not valid.
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
113def write_template(
114 style: str,
115 destination: str | pathlib.Path) -> pathlib.Path:
116 """Writes the Word or PowerPoint template of a style.
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.
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.
126 Returns:
127 The path to the template.
129 Raises:
130 FileExistsError: if `destination` already exists.
131 ValueError: if the style or extension is not valid.
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
164""" Private Functions """
167def _format_entry(entry: outline.OutlineEntry) -> str:
168 """Returns the outline line for `entry`.
170 Args:
171 entry: an outline entry.
173 Returns:
174 The line, with dashes for an unnumbered entry and the file after a '|'
175 if the entry names one.
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
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.
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'.
196 Returns:
197 The text of the project file.
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')
222def _give_entries_own_files(project_outline: outline.Outline) -> None:
223 """Gives each outline entry that would share a source file its own file.
225 Args:
226 project_outline: the outline, whose entries are changed in place.
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)