Coverage for src/coursebook/sources.py: 100%

41 statements  

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

1"""The markdown source files of a `coursebook` project. 

2 

3Each source file holds the content for one entry in the outline. It may start 

4with YAML front matter and is divided into sections by lines such as '<text>', 

5'<slides>', and '<notes>'. Content before the first divider belongs to the 

6text. 

7 

8Contents: 

9 Source: the front matter and sections of a source file. 

10 load_sources: loads every source file in a folder. 

11 

12""" 

13 

14from __future__ import annotations 

15 

16import dataclasses 

17import pathlib 

18from typing import Any 

19 

20from . import markdown, settings 

21 

22 

23@dataclasses.dataclass 

24class Source: 

25 """The front matter and sections of a source file. 

26 

27 Args: 

28 name: the name of the source file in lower case with words joined by 

29 underscores (without the '.md' extension). 

30 sections: `dict` with keys that are section names ('text', 'slides', 

31 or 'notes') and values that are the markdown of those sections. 

32 Defaults to an empty `dict`. 

33 metadata: the front matter of the source file. Defaults to an empty 

34 `dict`. 

35 path: the path to the source file, if it was loaded from a file. 

36 Defaults to `None`. 

37 

38 """ 

39 

40 name: str 

41 sections: dict[str, str] = dataclasses.field(default_factory = dict) 

42 metadata: dict[str, Any] = dataclasses.field(default_factory = dict) 

43 path: pathlib.Path | None = None 

44 

45 """ Class Methods """ 

46 

47 @classmethod 

48 def from_file( 

49 cls, 

50 path: str | pathlib.Path, 

51 name: str | None = None) -> Source: 

52 """Creates a source from a markdown file. 

53 

54 Args: 

55 path: path to the markdown file. 

56 name: the name of the source. Defaults to `None`, which uses the 

57 normalized name of the file (see `markdown.normalize_name`). 

58 

59 Returns: 

60 A source with the front matter and sections of the file. 

61 

62 Raises: 

63 ValueError: if the front matter or a divider is not valid. 

64 

65 """ 

66 path = pathlib.Path(path) 

67 text = path.read_text(encoding = 'utf-8') 

68 try: 

69 return cls.from_text( 

70 text, 

71 name = name or markdown.normalize_name(path.stem), 

72 path = path) 

73 except ValueError as error: 

74 message = f'{path.name}: {error}' 

75 raise ValueError(message) from error 

76 

77 @classmethod 

78 def from_text( 

79 cls, 

80 text: str, 

81 name: str, 

82 path: pathlib.Path | None = None) -> Source: 

83 """Creates a source from markdown text. 

84 

85 Args: 

86 text: the contents of a source file. 

87 name: the name of the source. 

88 path: the path to the source file, if any. Defaults to `None`. 

89 

90 Returns: 

91 A source with the front matter and sections of `text`. 

92 

93 """ 

94 metadata, body = markdown.split_front_matter(text) 

95 sections = markdown.split_sections( 

96 body, 

97 names = settings.SECTION_ALIASES, 

98 default = 'text') 

99 return cls( 

100 name = name, 

101 sections = sections, 

102 metadata = metadata, 

103 path = path) 

104 

105 """ Public Methods """ 

106 

107 def get(self, section: str) -> str: 

108 """Returns the markdown of `section`. 

109 

110 Args: 

111 section: 'text', 'slides', or 'notes'. 

112 

113 Returns: 

114 The markdown of the section, or an empty `str` if the source does 

115 not have that section. 

116 

117 """ 

118 return self.sections.get(section, '') 

119 

120 

121""" Public Functions """ 

122 

123 

124def load_sources(folder: str | pathlib.Path) -> dict[str, Source]: 

125 """Loads every markdown file in `folder`. 

126 

127 Subfolders are not searched. 

128 

129 Args: 

130 folder: the project folder. 

131 

132 Returns: 

133 A `dict` with keys that are the names of the sources (see 

134 `markdown.normalize_name`) and values that are the sources. 

135 

136 Raises: 

137 ValueError: if two files have the same normalized name (such as 

138 'Introduction.md' and 'introduction.md') or a file is not valid. 

139 

140 """ 

141 sources: dict[str, Source] = {} 

142 for path in sorted(pathlib.Path(folder).glob('*.md')): 

143 source = Source.from_file(path) 

144 if source.name in sources: 

145 other = sources[source.name].path 

146 name = other.name if other else source.name 

147 message = f'{path.name} and {name} are for the same outline entry' 

148 raise ValueError(message) 

149 sources[source.name] = source 

150 return sources