Coverage for src/shellman/cli.py: 47.94%

128 statements  

« prev     ^ index     » next       coverage.py v7.3.0, created at 2023-09-03 19:58 +0200

1"""Module that contains the command line application.""" 

2 

3# Why does this file exist, and why not put this in `__main__`? 

4# 

5# You might be tempted to import things from `__main__` later, 

6# but that will cause problems: the code will get executed twice: 

7# 

8# - When you run `python -m shellman` python will execute 

9# `__main__.py` as a script. That means there won't be any 

10# `shellman.__main__` in `sys.modules`. 

11# - When you import `__main__` it will get executed again (as a module) because 

12# there's no `shellman.__main__` in `sys.modules`. 

13 

14from __future__ import annotations 

15 

16import argparse 

17import os 

18import re 

19import sys 

20from datetime import datetime, timezone 

21from typing import TYPE_CHECKING, Any, Sequence 

22 

23from shellman import __version__, templates 

24from shellman.context import DEFAULT_JSON_FILE, _get_context, _update 

25from shellman.reader import DocFile, DocStream, _merge 

26 

27if TYPE_CHECKING: 

28 from shellman.templates import Template 

29 

30 

31def _valid_file(value: str) -> str: 

32 if value == "-": 32 ↛ 33line 32 didn't jump to line 33, because the condition on line 32 was never true

33 return value 

34 if not value: 34 ↛ 35line 34 didn't jump to line 35, because the condition on line 34 was never true

35 raise argparse.ArgumentTypeError("'' is not a valid file path") 

36 if not os.path.exists(value): 36 ↛ 37line 36 didn't jump to line 37, because the condition on line 36 was never true

37 raise argparse.ArgumentTypeError("%s is not a valid file path" % value) 

38 if os.path.isdir(value): 38 ↛ 39line 38 didn't jump to line 39, because the condition on line 38 was never true

39 raise argparse.ArgumentTypeError("%s is a directory, not a regular file" % value) 

40 return value 

41 

42 

43def get_parser() -> argparse.ArgumentParser: 

44 """Return the CLI argument parser. 

45 

46 Returns: 

47 An argparse parser. 

48 """ 

49 parser = argparse.ArgumentParser(prog="shellman") 

50 

51 parser.add_argument( 

52 "-c", 

53 "--context", 

54 dest="context", 

55 nargs="+", 

56 help="context to inject when rendering the template. " 

57 "You can pass JSON strings or key=value pairs. " 

58 "Example: `--context project=hello '{\"version\": [0, 3, 1]}'`.", 

59 ) 

60 

61 parser.add_argument( 

62 "--context-file", 

63 dest="context_file", 

64 help="JSON file to read context from. " 

65 "By default shellman will try to read the file '%s' " 

66 "in the current directory." % DEFAULT_JSON_FILE, 

67 ) 

68 

69 parser.add_argument( 

70 "-t", 

71 "--template", 

72 metavar="TEMPLATE", 

73 choices=templates._parser_choices(), 

74 default="helptext", 

75 dest="template", 

76 help="the Jinja2 template to use. " 

77 'Prefix with "path:" to specify the path ' 

78 "to a custom template. " 

79 "Available templates: %s" % ", ".join(templates._names()), 

80 ) 

81 

82 parser.add_argument( 

83 "-m", 

84 "--merge", 

85 dest="merge", 

86 action="store_true", 

87 help="with multiple input files, merge their contents in the output " 

88 "instead of appending (default: %(default)s). ", 

89 ) 

90 

91 parser.add_argument( 

92 "-o", 

93 "--output", 

94 action="store", 

95 dest="output", 

96 default=None, 

97 help="file to write to (default: stdout). " 

98 "You can use the following variables in the output name: " 

99 "{basename}, {ext}, {filename} (equal to {basename}.{ext}), " 

100 "{filepath}, {dirname}, {dirpath}, and {vcsroot} " 

101 "(git and mercurial supported). " 

102 "They will be populated from each input file.", 

103 ) 

104 

105 parser.add_argument( 

106 "FILE", 

107 type=_valid_file, 

108 nargs="*", 

109 help="path to the file(s) to read. Use - to read on standard input.", 

110 ) 

111 return parser 

112 

113 

114def _render(template: Template, doc: DocFile | DocStream | None = None, **context: dict) -> str: 

115 shellman: dict[str, Any] = {"doc": {}} 

116 if doc is not None: 

117 shellman["doc"] = doc.sections 

118 shellman["filename"] = doc.filename 

119 shellman["filepath"] = doc.filepath 

120 shellman["today"] = datetime.now(tz=timezone.utc).date() 

121 shellman["version"] = __version__ 

122 

123 if "shellman" in context: 123 ↛ 124line 123 didn't jump to line 124, because the condition on line 123 was never true

124 _update(shellman, context.pop("shellman")) 

125 

126 return template.render(shellman=shellman, **context) 

127 

128 

129def _write(contents: str, filepath: str) -> None: 

130 with open(filepath, "w", encoding="utf-8") as write_stream: 

131 print(contents, file=write_stream) 

132 

133 

134def _common_ancestor(docs: Sequence[DocFile | DocStream]) -> str: 

135 splits: list[tuple[str, str]] = [os.path.split(doc.filepath) for doc in docs if doc.filepath] 

136 vertical = [] 

137 depth = 1 

138 while True: 

139 if not all(len(s) >= depth for s in splits): 

140 break 

141 vertical.append([s[depth - 1] for s in splits]) 

142 depth += 1 

143 common = "" 

144 for vert in vertical: 

145 if vert.count(vert[0]) != len(vert): 

146 break 

147 common = vert[0] 

148 return common or "<VARIOUS_INPUTS>" 

149 

150 

151def _is_format_string(string: str) -> bool: 

152 if re.search(r"{[a-zA-Z_][\w]*}", string): 

153 return True 

154 return False 

155 

156 

157def _guess_filename(output: str, docs: Sequence[DocFile | DocStream] | None = None) -> str: 

158 if output and not _is_format_string(output): 

159 return os.path.basename(output) 

160 if docs: 

161 return _common_ancestor(docs) 

162 return "" 

163 

164 

165def _output_name_variables(doc: DocFile | DocStream | None = None) -> dict: 

166 if doc: 

167 basename, ext = os.path.splitext(doc.filename) 

168 abspath = os.path.abspath(doc.filepath or doc.filename) 

169 dirpath = os.path.split(abspath)[0] or "." 

170 dirname = os.path.basename(dirpath) 

171 return { 

172 "filename": doc.filename, 

173 "filepath": abspath, 

174 "basename": basename, 

175 "ext": ext, 

176 "dirpath": dirpath, 

177 "dirname": dirname, 

178 "vcsroot": _get_vcs_root(dirpath), 

179 } 

180 return {} 

181 

182 

183_vcs_root_cache: dict[str, str] = {} 

184 

185 

186def _get_vcs_root(path: str) -> str: 

187 if path in _vcs_root_cache: 

188 return _vcs_root_cache[path] 

189 original_path = path 

190 while not any(os.path.exists(os.path.join(path, vcs)) for vcs in (".git", ".hg", ".svn")): 

191 path = os.path.dirname(path) 

192 if path == "/": 

193 path = "" 

194 break 

195 _vcs_root_cache[original_path] = path 

196 return path 

197 

198 

199def main(args: list[str] | None = None) -> int: 

200 """Run the main program. 

201 

202 This function is executed when you type `shellman` or `python -m shellman`. 

203 

204 Get the file to parse, construct a Doc object, get file's doc, 

205 get the according formatter class, instantiate it 

206 with acquired doc and write on specified file (stdout by default). 

207 

208 Parameters: 

209 args: Arguments passed from the command line. 

210 

211 Returns: 

212 An exit code. 

213 """ 

214 templates._load_plugin_templates() 

215 

216 parser = get_parser() 

217 opts = parser.parse_args(args) 

218 

219 # Catch errors as early as possible 

220 if opts.merge and len(opts.FILE) < 2: # noqa: PLR2004 220 ↛ 221line 220 didn't jump to line 221, because the condition on line 220 was never true

221 print( 

222 "shellman: warning: --merge option is ignored with less than 2 inputs", 

223 file=sys.stderr, 

224 ) 

225 

226 if not opts.FILE and opts.output and _is_format_string(opts.output): 226 ↛ 227line 226 didn't jump to line 227, because the condition on line 226 was never true

227 parser.print_usage(file=sys.stderr) 

228 print( 

229 "shellman: error: cannot format output name without file inputs. " 

230 "Please remove variables from output name, or provide file inputs", 

231 file=sys.stderr, 

232 ) 

233 return 2 

234 

235 # Immediately get the template to throw error if not found 

236 if opts.template.startswith("path:"): 236 ↛ 237line 236 didn't jump to line 237, because the condition on line 236 was never true

237 template = templates._get_custom_template(opts.template[5:]) 

238 else: 

239 template = templates.templates[opts.template] 

240 

241 context = _get_context(opts) 

242 

243 # Render template with context only 

244 if not opts.FILE: 

245 if not context: 

246 parser.print_usage(file=sys.stderr) 

247 print("shellman: error: please specify input file(s) or context", file=sys.stderr) 

248 return 1 

249 contents = _render(template, None, **context) 

250 if opts.output: 250 ↛ 251line 250 didn't jump to line 251, because the condition on line 250 was never true

251 _write(contents, opts.output) 

252 else: 

253 print(contents) 

254 return 0 

255 

256 # Parse input files 

257 docs: list[DocFile | DocStream] = [] 

258 for file in opts.FILE: 

259 if file == "-": 259 ↛ 260line 259 didn't jump to line 260, because the condition on line 259 was never true

260 docs.append(DocStream(sys.stdin, filename=_guess_filename(opts.output))) 

261 else: 

262 docs.append(DocFile(file)) 

263 

264 # Optionally merge the parsed contents 

265 if opts.merge: 265 ↛ 266line 265 didn't jump to line 266, because the condition on line 265 was never true

266 new_filename = _guess_filename(opts.output, docs) 

267 docs = [_merge(docs, new_filename)] 

268 

269 # If opts.output contains variables, each input has its own output 

270 if opts.output and _is_format_string(opts.output): 270 ↛ 271line 270 didn't jump to line 271, because the condition on line 270 was never true

271 for doc in docs: 

272 _write( 

273 _render(template, doc, **context), 

274 opts.output.format(**_output_name_variables(doc)), 

275 ) 

276 # Else, concatenate contents (no effect if already merged), then output to file or stdout 

277 else: 

278 contents = "\n\n\n".join(_render(template, doc, **context) for doc in docs) 

279 if opts.output: 279 ↛ 280line 279 didn't jump to line 280, because the condition on line 279 was never true

280 _write(contents, opts.output) 

281 else: 

282 print(contents) 

283 

284 return 0