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
« prev ^ index » next coverage.py v7.3.0, created at 2023-09-03 19:58 +0200
1"""Module that contains the command line application."""
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`.
14from __future__ import annotations
16import argparse
17import os
18import re
19import sys
20from datetime import datetime, timezone
21from typing import TYPE_CHECKING, Any, Sequence
23from shellman import __version__, templates
24from shellman.context import DEFAULT_JSON_FILE, _get_context, _update
25from shellman.reader import DocFile, DocStream, _merge
27if TYPE_CHECKING:
28 from shellman.templates import Template
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
43def get_parser() -> argparse.ArgumentParser:
44 """Return the CLI argument parser.
46 Returns:
47 An argparse parser.
48 """
49 parser = argparse.ArgumentParser(prog="shellman")
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 )
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 )
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 )
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 )
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 )
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
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__
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"))
126 return template.render(shellman=shellman, **context)
129def _write(contents: str, filepath: str) -> None:
130 with open(filepath, "w", encoding="utf-8") as write_stream:
131 print(contents, file=write_stream)
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>"
151def _is_format_string(string: str) -> bool:
152 if re.search(r"{[a-zA-Z_][\w]*}", string):
153 return True
154 return False
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 ""
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 {}
183_vcs_root_cache: dict[str, str] = {}
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
199def main(args: list[str] | None = None) -> int:
200 """Run the main program.
202 This function is executed when you type `shellman` or `python -m shellman`.
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).
208 Parameters:
209 args: Arguments passed from the command line.
211 Returns:
212 An exit code.
213 """
214 templates._load_plugin_templates()
216 parser = get_parser()
217 opts = parser.parse_args(args)
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 )
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
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]
241 context = _get_context(opts)
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
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))
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)]
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)
284 return 0