Coverage for src/pytkdocs/cli.py: 95.24%
72 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-09 18:24 +0100
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-09 18:24 +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 pytkdocs` python will execute
9# `__main__.py` as a script. That means there won't be any
10# `pytkdocs.__main__` in `sys.modules`.
11# - When you import `__main__` it will get executed again (as a module) because
12# there's no `pytkdocs.__main__` in `sys.modules`.
14from __future__ import annotations
16import argparse
17import json
18import sys
19import traceback
20from contextlib import contextmanager
21from io import StringIO
22from typing import TYPE_CHECKING, Any
24from pytkdocs import debug
25from pytkdocs.loader import Loader
26from pytkdocs.serializer import serialize_object
28if TYPE_CHECKING:
29 from collections.abc import Iterator
31 from pytkdocs.objects import Object
34class _DebugInfo(argparse.Action):
35 def __init__(self, nargs: int | str | None = 0, **kwargs: Any) -> None:
36 super().__init__(nargs=nargs, **kwargs)
38 def __call__(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002
39 debug.print_debug_info()
40 sys.exit(0)
43def process_config(config: dict) -> dict:
44 """Process a loading configuration.
46 The `config` argument is a dictionary looking like this:
48 ```python
49 {
50 "objects": [
51 {"path": "python.dotted.path.to.the.object1"},
52 {"path": "python.dotted.path.to.the.object2"},
53 ]
54 }
55 ```
57 The result is a dictionary looking like this:
59 ```python
60 {
61 "loading_errors": [
62 "message1",
63 "message2",
64 ],
65 "parsing_errors": {
66 "path.to.object1": [
67 "message1",
68 "message2",
69 ],
70 "path.to.object2": [
71 "message1",
72 "message2",
73 ],
74 },
75 "objects": [
76 {
77 "path": "path.to.object1",
78 # other attributes, see the documentation for `pytkdocs.objects` or `pytkdocs.serializer`
79 },
80 {
81 "path": "path.to.object2",
82 # other attributes, see the documentation for `pytkdocs.objects` or `pytkdocs.serializer`
83 },
84 ],
85 }
86 ```
88 Arguments:
89 config: The configuration.
91 Returns:
92 The collected documentation along with the errors that occurred.
93 """
94 collected = []
95 loading_errors = []
96 parsing_errors = {}
98 for obj_config in config["objects"]:
99 path = obj_config.pop("path")
100 members = obj_config.pop("members", set())
102 if isinstance(members, list): 102 ↛ 103line 102 didn't jump to line 103 because the condition on line 102 was never true
103 members = set(members)
104 loader = Loader(**obj_config)
106 obj = loader.get_object_documentation(path, members)
108 loading_errors.extend(loader.errors)
109 parsing_errors.update(extract_errors(obj))
111 serialized_obj = serialize_object(obj)
112 collected.append(serialized_obj)
114 return {"loading_errors": loading_errors, "parsing_errors": parsing_errors, "objects": collected}
117def process_json(json_input: str) -> dict:
118 """Process JSON input.
120 Simply load the JSON as a Python dictionary, then pass it to [`process_config`][pytkdocs.cli.process_config].
122 Arguments:
123 json_input: The JSON to load.
125 Returns:
126 The result of the call to [`process_config`][pytkdocs.cli.process_config].
127 """
128 return process_config(json.loads(json_input))
131def extract_docstring_parsing_errors(errors: dict, obj: Object) -> None:
132 """Recursion helper.
134 Update the `errors` dictionary by side-effect. Recurse on the object's children.
136 Arguments:
137 errors: The dictionary to update.
138 obj: The object.
139 """
140 if hasattr(obj, "docstring_errors") and obj.docstring_errors: 140 ↛ 141line 140 didn't jump to line 141 because the condition on line 140 was never true
141 errors[obj.path] = obj.docstring_errors
142 for child in obj.children:
143 extract_docstring_parsing_errors(errors, child)
146def extract_errors(obj: Object) -> dict:
147 """Extract the docstring parsing errors of each object, recursively, into a flat dictionary.
149 Arguments:
150 obj: An object from `pytkdocs.objects`.
152 Returns:
153 A flat dictionary. Keys are the objects' names.
154 """
155 parsing_errors: dict[str, list[str]] = {}
156 extract_docstring_parsing_errors(parsing_errors, obj)
157 return parsing_errors
160def get_parser() -> argparse.ArgumentParser:
161 """Return the program argument parser.
163 Returns:
164 The argument parser for the program.
165 """
166 parser = argparse.ArgumentParser(prog="pytkdocs")
167 parser.add_argument(
168 "-1",
169 "--line-by-line",
170 action="store_true",
171 dest="line_by_line",
172 help="Process each line read on stdin, one by one.",
173 )
174 parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {debug.get_version()}")
175 parser.add_argument("--debug-info", action=_DebugInfo, help="Print debug information.")
176 return parser
179@contextmanager
180def discarded_stdout() -> Iterator[None]:
181 """Discard standard output.
183 Yields:
184 Nothing: We only yield to act as a context manager.
185 """
186 # Discard things printed at import time to avoid corrupting our JSON output
187 # See https://github.com/pawamoy/pytkdocs/issues/24
188 old_stdout = sys.stdout
189 sys.stdout = StringIO()
191 yield
193 # Flush imported modules' output, and restore true sys.stdout
194 sys.stdout.flush()
195 sys.stdout = old_stdout
198def main(args: list[str] | None = None) -> int:
199 """Run the main program.
201 This function is executed when you type `pytkdocs` or `python -m pytkdocs`.
203 Parameters:
204 args: Arguments passed from the command line.
206 Returns:
207 An exit code.
208 """
209 parser = get_parser()
210 parsed_args: argparse.Namespace = parser.parse_args(args)
212 if parsed_args.line_by_line:
213 for line in sys.stdin:
214 with discarded_stdout():
215 try:
216 output = json.dumps(process_json(line))
217 except Exception as error: # noqa: BLE001
218 # Don't fail on error. We must handle the next inputs.
219 # Instead, print error as JSON.
220 output = json.dumps({"error": str(error), "traceback": traceback.format_exc()})
221 print(output)
222 else:
223 with discarded_stdout():
224 output = json.dumps(process_json(sys.stdin.read()))
225 print(output)
227 return 0