Coverage for src/shellman/cli.py: 62.23%
134 statements
« prev ^ index » next coverage.py v7.7.1, created at 2025-03-27 14:35 +0100
« prev ^ index » next coverage.py v7.7.1, created at 2025-03-27 14:35 +0100
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
23from shellman import __version__, debug, templates
24from shellman.context import DEFAULT_JSON_FILE, _get_context, _update
25from shellman.reader import DocFile, DocStream, _merge
27if TYPE_CHECKING:
28 from collections.abc import Sequence
30 from shellman.templates import Template
33class _DebugInfo(argparse.Action):
34 def __init__(self, nargs: int | str | None = 0, **kwargs: Any) -> None:
35 super().__init__(nargs=nargs, **kwargs)
37 def __call__(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002
38 debug.print_debug_info()
39 sys.exit(0)
42def _valid_file(value: str) -> str:
43 if value == "-": 43 ↛ 44line 43 didn't jump to line 44 because the condition on line 43 was never true
44 return value
45 if not value: 45 ↛ 46line 45 didn't jump to line 46 because the condition on line 45 was never true
46 raise argparse.ArgumentTypeError("'' is not a valid file path")
47 if not os.path.exists(value): 47 ↛ 48line 47 didn't jump to line 48 because the condition on line 47 was never true
48 raise argparse.ArgumentTypeError(f"{value} is not a valid file path")
49 if os.path.isdir(value): 49 ↛ 50line 49 didn't jump to line 50 because the condition on line 49 was never true
50 raise argparse.ArgumentTypeError(f"{value} is a directory, not a regular file")
51 return value
54def get_parser() -> argparse.ArgumentParser:
55 """Return the CLI argument parser.
57 Returns:
58 An argparse parser.
59 """
60 parser = argparse.ArgumentParser(prog="shellman")
62 parser.add_argument(
63 "-c",
64 "--context",
65 dest="context",
66 nargs="+",
67 help="context to inject when rendering the template. "
68 "You can pass JSON strings or key=value pairs. "
69 "Example: `--context project=hello '{\"version\": [0, 3, 1]}'`.",
70 )
72 parser.add_argument(
73 "--context-file",
74 dest="context_file",
75 help="JSON file to read context from. "
76 f"By default shellman will try to read the file '{DEFAULT_JSON_FILE}' "
77 "in the current directory.",
78 )
80 parser.add_argument(
81 "-t",
82 "--template",
83 metavar="TEMPLATE",
84 choices=templates._parser_choices(),
85 default="helptext",
86 dest="template",
87 help="the Jinja2 template to use. "
88 'Prefix with "path:" to specify the path '
89 "to a custom template. "
90 f"Available templates: {', '.join(templates._names())}",
91 )
93 parser.add_argument(
94 "-m",
95 "--merge",
96 dest="merge",
97 action="store_true",
98 help="with multiple input files, merge their contents in the output "
99 "instead of appending (default: %(default)s). ",
100 )
102 parser.add_argument(
103 "-o",
104 "--output",
105 action="store",
106 dest="output",
107 default=None,
108 help="file to write to (default: stdout). "
109 "You can use the following variables in the output name: "
110 "{basename}, {ext}, {filename} (equal to {basename}.{ext}), "
111 "{filepath}, {dirname}, {dirpath}, and {vcsroot} "
112 "(git and mercurial supported). "
113 "They will be populated from each input file.",
114 )
115 parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {debug.get_version()}")
116 parser.add_argument("--debug-info", action=_DebugInfo, help="Print debug information.")
118 parser.add_argument(
119 "FILE",
120 type=_valid_file,
121 nargs="*",
122 help="path to the file(s) to read. Use - to read on standard input.",
123 )
124 return parser
127def _render(template: Template, doc: DocFile | DocStream | None = None, **context: dict) -> str:
128 shellman: dict[str, Any] = {"doc": {}}
129 if doc is not None:
130 shellman["doc"] = doc.sections
131 shellman["filename"] = doc.filename
132 shellman["filepath"] = doc.filepath
133 shellman["today"] = datetime.now(tz=timezone.utc).date()
134 shellman["version"] = __version__
136 if "shellman" in context: 136 ↛ 137line 136 didn't jump to line 137 because the condition on line 136 was never true
137 _update(shellman, context.pop("shellman"))
139 return template.render(shellman=shellman, **context)
142def _write(contents: str, filepath: str) -> None:
143 with open(filepath, "w", encoding="utf-8") as write_stream:
144 print(contents, file=write_stream)
147def _common_ancestor(docs: Sequence[DocFile | DocStream]) -> str:
148 splits: list[tuple[str, str]] = [os.path.split(doc.filepath) for doc in docs if doc.filepath]
149 vertical = []
150 depth = 1
151 while True:
152 if not all(len(s) >= depth for s in splits):
153 break
154 vertical.append([s[depth - 1] for s in splits])
155 depth += 1
156 common = ""
157 for vert in vertical:
158 if vert.count(vert[0]) != len(vert):
159 break
160 common = vert[0]
161 return common or "<VARIOUS_INPUTS>"
164def _is_format_string(string: str) -> bool:
165 return bool(re.search(r"{[a-zA-Z_][\w]*}", string))
168def _guess_filename(output: str, docs: Sequence[DocFile | DocStream] | None = None) -> str:
169 if output and not _is_format_string(output): 169 ↛ anywhereline 169 didn't jump anywhere: it always raised an exception.
170 return os.path.basename(output)
171 if docs:
172 return _common_ancestor(docs)
173 return ""
176def _output_name_variables(doc: DocFile | DocStream | None = None) -> dict:
177 if doc: 177 ↛ anywhereline 177 didn't jump anywhere: it always raised an exception.
178 basename, ext = os.path.splitext(doc.filename)
179 abspath = os.path.abspath(doc.filepath or doc.filename)
180 dirpath = os.path.split(abspath)[0] or "."
181 dirname = os.path.basename(dirpath)
182 return {
183 "filename": doc.filename,
184 "filepath": abspath,
185 "basename": basename,
186 "ext": ext,
187 "dirpath": dirpath,
188 "dirname": dirname,
189 "vcsroot": _get_vcs_root(dirpath),
190 }
191 return {}
194_vcs_root_cache: dict[str, str] = {}
197def _get_vcs_root(path: str) -> str:
198 if path in _vcs_root_cache: 198 ↛ anywhereline 198 didn't jump anywhere: it always raised an exception.
199 return _vcs_root_cache[path]
200 original_path = path
201 while not any(os.path.exists(os.path.join(path, vcs)) for vcs in (".git", ".hg", ".svn")):
202 path = os.path.dirname(path)
203 if path == "/":
204 path = ""
205 break
206 _vcs_root_cache[original_path] = path
207 return path
210def main(args: list[str] | None = None) -> int:
211 """Run the main program.
213 This function is executed when you type `shellman` or `python -m shellman`.
215 Get the file to parse, construct a Doc object, get file's doc,
216 get the according formatter class, instantiate it
217 with acquired doc and write on specified file (stdout by default).
219 Parameters:
220 args: Arguments passed from the command line.
222 Returns:
223 An exit code.
224 """
225 templates._load_plugin_templates()
227 parser = get_parser()
228 opts = parser.parse_args(args)
230 # Catch errors as early as possible
231 if opts.merge and len(opts.FILE) < 2: # noqa: PLR2004 231 ↛ 232line 231 didn't jump to line 232 because the condition on line 231 was never true
232 print(
233 "shellman: warning: --merge option is ignored with less than 2 inputs",
234 file=sys.stderr,
235 )
237 if not opts.FILE and opts.output and _is_format_string(opts.output): 237 ↛ 238line 237 didn't jump to line 238 because the condition on line 237 was never true
238 parser.print_usage(file=sys.stderr)
239 print(
240 "shellman: error: cannot format output name without file inputs. "
241 "Please remove variables from output name, or provide file inputs",
242 file=sys.stderr,
243 )
244 return 2
246 # Immediately get the template to throw error if not found
247 if opts.template.startswith("path:"): 247 ↛ 248line 247 didn't jump to line 248 because the condition on line 247 was never true
248 template = templates._get_custom_template(opts.template[5:])
249 else:
250 template = templates.templates[opts.template]
252 context = _get_context(opts)
254 # Render template with context only
255 if not opts.FILE:
256 if not context:
257 parser.print_usage(file=sys.stderr)
258 print("shellman: error: please specify input file(s) or context", file=sys.stderr)
259 return 1
260 contents = _render(template, None, **context)
261 if opts.output:
262 _write(contents, opts.output)
263 else:
264 print(contents)
265 return 0
267 # Parse input files
268 docs: list[DocFile | DocStream] = []
269 for file in opts.FILE:
270 if file == "-":
271 docs.append(DocStream(sys.stdin, filename=_guess_filename(opts.output)))
272 else:
273 docs.append(DocFile(file))
275 # Optionally merge the parsed contents
276 if opts.merge: 276 ↛ 277line 276 didn't jump to line 277 because the condition on line 276 was never true
277 new_filename = _guess_filename(opts.output, docs)
278 docs = [_merge(docs, new_filename)]
280 # If opts.output contains variables, each input has its own output
281 if opts.output and _is_format_string(opts.output): 281 ↛ 282line 281 didn't jump to line 282 because the condition on line 281 was never true
282 for doc in docs: 282 ↛ anywhereline 282 didn't jump anywhere: it always raised an exception.
283 _write(
284 _render(template, doc, **context),
285 opts.output.format(**_output_name_variables(doc)),
286 )
287 # Else, concatenate contents (no effect if already merged), then output to file or stdout
288 else:
289 contents = "\n\n\n".join(_render(template, doc, **context) for doc in docs)
290 if opts.output:
291 _write(contents, opts.output)
292 else:
293 print(contents)
295 return 0