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

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 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`. 

13 

14from __future__ import annotations 

15 

16import argparse 

17import json 

18import sys 

19import traceback 

20from contextlib import contextmanager 

21from io import StringIO 

22from typing import TYPE_CHECKING, Any 

23 

24from pytkdocs import debug 

25from pytkdocs.loader import Loader 

26from pytkdocs.serializer import serialize_object 

27 

28if TYPE_CHECKING: 

29 from collections.abc import Iterator 

30 

31 from pytkdocs.objects import Object 

32 

33 

34class _DebugInfo(argparse.Action): 

35 def __init__(self, nargs: int | str | None = 0, **kwargs: Any) -> None: 

36 super().__init__(nargs=nargs, **kwargs) 

37 

38 def __call__(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002 

39 debug.print_debug_info() 

40 sys.exit(0) 

41 

42 

43def process_config(config: dict) -> dict: 

44 """Process a loading configuration. 

45 

46 The `config` argument is a dictionary looking like this: 

47 

48 ```python 

49 { 

50 "objects": [ 

51 {"path": "python.dotted.path.to.the.object1"}, 

52 {"path": "python.dotted.path.to.the.object2"}, 

53 ] 

54 } 

55 ``` 

56 

57 The result is a dictionary looking like this: 

58 

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 ``` 

87 

88 Arguments: 

89 config: The configuration. 

90 

91 Returns: 

92 The collected documentation along with the errors that occurred. 

93 """ 

94 collected = [] 

95 loading_errors = [] 

96 parsing_errors = {} 

97 

98 for obj_config in config["objects"]: 

99 path = obj_config.pop("path") 

100 members = obj_config.pop("members", set()) 

101 

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) 

105 

106 obj = loader.get_object_documentation(path, members) 

107 

108 loading_errors.extend(loader.errors) 

109 parsing_errors.update(extract_errors(obj)) 

110 

111 serialized_obj = serialize_object(obj) 

112 collected.append(serialized_obj) 

113 

114 return {"loading_errors": loading_errors, "parsing_errors": parsing_errors, "objects": collected} 

115 

116 

117def process_json(json_input: str) -> dict: 

118 """Process JSON input. 

119 

120 Simply load the JSON as a Python dictionary, then pass it to [`process_config`][pytkdocs.cli.process_config]. 

121 

122 Arguments: 

123 json_input: The JSON to load. 

124 

125 Returns: 

126 The result of the call to [`process_config`][pytkdocs.cli.process_config]. 

127 """ 

128 return process_config(json.loads(json_input)) 

129 

130 

131def extract_docstring_parsing_errors(errors: dict, obj: Object) -> None: 

132 """Recursion helper. 

133 

134 Update the `errors` dictionary by side-effect. Recurse on the object's children. 

135 

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) 

144 

145 

146def extract_errors(obj: Object) -> dict: 

147 """Extract the docstring parsing errors of each object, recursively, into a flat dictionary. 

148 

149 Arguments: 

150 obj: An object from `pytkdocs.objects`. 

151 

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 

158 

159 

160def get_parser() -> argparse.ArgumentParser: 

161 """Return the program argument parser. 

162 

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 

177 

178 

179@contextmanager 

180def discarded_stdout() -> Iterator[None]: 

181 """Discard standard output. 

182 

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() 

190 

191 yield 

192 

193 # Flush imported modules' output, and restore true sys.stdout 

194 sys.stdout.flush() 

195 sys.stdout = old_stdout 

196 

197 

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

199 """Run the main program. 

200 

201 This function is executed when you type `pytkdocs` or `python -m pytkdocs`. 

202 

203 Parameters: 

204 args: Arguments passed from the command line. 

205 

206 Returns: 

207 An exit code. 

208 """ 

209 parser = get_parser() 

210 parsed_args: argparse.Namespace = parser.parse_args(args) 

211 

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) 

226 

227 return 0