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

61 statements  

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

1"""Command line interface for `coursebook`. 

2 

3The commands are: 

4 

5* `coursebook build [folder]` (or just `coursebook [folder]`): creates the 

6 files of a project. 

7* `coursebook new folder`: creates a new project. 

8* `coursebook template style destination`: writes the Word or PowerPoint 

9 template of a style so that it can be changed. 

10 

11Contents: 

12 main: runs a command from the command line. 

13 _COMMANDS: the names of the commands. 

14 

15""" 

16 

17from __future__ import annotations 

18 

19import argparse 

20import logging 

21import pathlib 

22import sys 

23from typing import TYPE_CHECKING 

24 

25from . import __version__, project, scaffold, settings 

26 

27if TYPE_CHECKING: 

28 from collections.abc import Sequence 

29 

30_COMMANDS: tuple[str, ...] = ('build', 'new', 'template') 

31 

32 

33""" Public Functions """ 

34 

35 

36def main(arguments: Sequence[str] | None = None) -> int: 

37 """Runs a command from the command line. 

38 

39 If the first argument is not a command, the command is 'build'. 

40 

41 Args: 

42 arguments: the command line arguments, without the program name. 

43 Defaults to `None`, which uses `sys.argv`. 

44 

45 Returns: 

46 The exit status: 0 if the command succeeded and 1 otherwise. 

47 

48 """ 

49 values = list(sys.argv[1:] if arguments is None else arguments) 

50 if not values or values[0] not in {*_COMMANDS, '-h', '--help', '--version'}: 

51 values.insert(0, 'build') 

52 options = _create_parser().parse_args(values) 

53 logging.basicConfig( 

54 format = '%(levelname)s: %(message)s', 

55 level = ( 

56 logging.INFO if getattr(options, 'verbose', False) 

57 else logging.WARNING)) 

58 try: 

59 if options.command == 'new': 

60 paths = _new(options) 

61 elif options.command == 'template': 

62 paths = [scaffold.write_template( 

63 options.style, options.destination)] 

64 else: 

65 paths = _build(options) 

66 except (ImportError, OSError, RuntimeError, ValueError) as error: 

67 sys.stderr.write(f'coursebook: error: {error}\n') 

68 return 1 

69 for path in paths: 

70 sys.stdout.write(f'Created {path}\n') 

71 return 0 

72 

73 

74""" Private Functions """ 

75 

76 

77def _build(options: argparse.Namespace) -> list[pathlib.Path]: 

78 """Builds a project from the parsed command line options. 

79 

80 Args: 

81 options: the parsed options of the 'build' command. 

82 

83 Returns: 

84 The paths to the created files. 

85 

86 """ 

87 project_ = project.Project.from_folder(options.folder) 

88 if options.verbose: 

89 for source in project_.unused: 

90 logging.info('Source file "%s" is not in the outline', ( 

91 source.path.name if source.path else source.name)) 

92 return project_.build( 

93 include = options.include, 

94 output_folder = options.output_folder) 

95 

96 

97def _create_parser() -> argparse.ArgumentParser: 

98 """Returns the parser for the command line arguments. 

99 

100 Returns: 

101 The argument parser. 

102 

103 """ 

104 parser = argparse.ArgumentParser( 

105 prog = 'coursebook', 

106 description = ( 

107 'Creates Word, PowerPoint, PDF, and HTML files from a project ' 

108 'file and markdown source files.')) 

109 parser.add_argument( 

110 '--version', 

111 action = 'version', 

112 version = f'%(prog)s {__version__}') 

113 commands = parser.add_subparsers(dest = 'command', required = True) 

114 build = commands.add_parser( 

115 'build', 

116 help = 'create the files of a project (the default command)', 

117 description = 'Creates the files of a project.') 

118 build.add_argument( 

119 'folder', 

120 nargs = '?', 

121 default = '.', 

122 help = 'the project folder (defaults to the current folder)') 

123 build.add_argument( 

124 '-i', '--include', 

125 nargs = '+', 

126 metavar = 'SECTION', 

127 help = ( 

128 'the sections to create files for, overriding the project file ' 

129 f'(choices: {", ".join(settings.SECTIONS)})')) 

130 build.add_argument( 

131 '-o', '--output-folder', 

132 help = 'folder for the created files, overriding the project file') 

133 build.add_argument( 

134 '-v', '--verbose', 

135 action = 'store_true', 

136 help = 'report source files that are not in the outline') 

137 new = commands.add_parser( 

138 'new', 

139 help = 'create a new project', 

140 description = ( 

141 'Creates a project folder with a project file, a source file for ' 

142 'each outline entry, and a visuals folder.')) 

143 new.add_argument('folder', help = 'the folder for the new project') 

144 new.add_argument( 

145 '-n', '--name', 

146 help = 'the title of the project (defaults to the folder name)') 

147 new.add_argument( 

148 '-s', '--style', 

149 default = 'coursebook', 

150 help = 'the style of the project (defaults to coursebook)') 

151 new.add_argument( 

152 '-f', '--format', 

153 choices = scaffold.PROJECT_FILE_FORMATS, 

154 default = 'ini', 

155 help = 'the format of the project file (defaults to ini)') 

156 new.add_argument( 

157 '--outline', 

158 metavar = 'FILE', 

159 help = 'a text file with one outline line (such as "I: Introduction") ' 

160 'per line') 

161 new.add_argument( 

162 '-e', '--entry', 

163 action = 'append', 

164 metavar = 'LINE', 

165 help = 'an outline line (repeat for each entry)') 

166 template = commands.add_parser( 

167 'template', 

168 help = "write a style's Word or PowerPoint template", 

169 description = ( 

170 'Writes the Word (.docx) or PowerPoint (.pptx) template of a ' 

171 'style so that it can be changed and named in a project file.')) 

172 template.add_argument('style', help = 'the name of the style') 

173 template.add_argument( 

174 'destination', 

175 help = 'the path for the template, ending in .docx or .pptx') 

176 return parser 

177 

178 

179def _new(options: argparse.Namespace) -> list[pathlib.Path]: 

180 """Creates a project from the parsed command line options. 

181 

182 Args: 

183 options: the parsed options of the 'new' command. 

184 

185 Returns: 

186 The paths to the created files and folders. 

187 

188 """ 

189 lines: list[str] = [] 

190 if options.outline: 

191 text = pathlib.Path(options.outline).read_text(encoding = 'utf-8') 

192 lines.extend( 

193 line for line in text.splitlines() 

194 if line.strip() and not line.lstrip().startswith('#')) 

195 lines.extend(options.entry or []) 

196 return scaffold.create_project( 

197 options.folder, 

198 name = options.name, 

199 style = options.style, 

200 outline_lines = lines or None, 

201 file_format = options.format)