Coverage for src/pytkdocs/cli.py: 95.00%

Shortcuts on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

64 statements  

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

2# 

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

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

5# 

6# - When you run `python -m pytkdocs` python will execute 

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

8# `pytkdocs.__main__` in `sys.modules`. 

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

10# there's no `pytkdocs.__main__` in `sys.modules`. 

11 

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

13 

14import argparse 

15import json 

16import sys 

17import traceback 

18from contextlib import contextmanager 

19from io import StringIO 

20from typing import Dict, List, Optional 

21 

22from pytkdocs.loader import Loader 

23from pytkdocs.objects import Object 

24from pytkdocs.serializer import serialize_object 

25 

26 

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

28 """ 

29 Process a loading configuration. 

30 

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

32 

33 ```python 

34 { 

35 "objects": [ 

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

37 {"path": "python.dotted.path.to.the.object2"} 

38 ] 

39 } 

40 ``` 

41 

42 The result is a dictionary looking like this: 

43 

44 ```python 

45 { 

46 "loading_errors": [ 

47 "message1", 

48 "message2", 

49 ], 

50 "parsing_errors": { 

51 "path.to.object1": [ 

52 "message1", 

53 "message2", 

54 ], 

55 "path.to.object2": [ 

56 "message1", 

57 "message2", 

58 ] 

59 }, 

60 "objects": [ 

61 { 

62 "path": "path.to.object1", 

63 # other attributes, see the documentation for `pytkdocs.objects` or `pytkdocs.serializer` 

64 }, 

65 { 

66 "path": "path.to.object2", 

67 # other attributes, see the documentation for `pytkdocs.objects` or `pytkdocs.serializer` 

68 }, 

69 ] 

70 } 

71 ``` 

72 

73 Arguments: 

74 config: The configuration. 

75 

76 Returns: 

77 The collected documentation along with the errors that occurred. 

78 """ 

79 collected = [] 

80 loading_errors = [] 

81 parsing_errors = {} 

82 

83 for obj_config in config["objects"]: 

84 path = obj_config.pop("path") 

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

86 

87 if isinstance(members, list): 87 ↛ 88line 87 didn't jump to line 88, because the condition on line 87 was never true

88 members = set(members) 

89 loader = Loader(**obj_config) 

90 

91 obj = loader.get_object_documentation(path, members) 

92 

93 loading_errors.extend(loader.errors) 

94 parsing_errors.update(extract_errors(obj)) 

95 

96 serialized_obj = serialize_object(obj) 

97 collected.append(serialized_obj) 

98 

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

100 

101 

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

103 """ 

104 Process JSON input. 

105 

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

107 

108 Arguments: 

109 json_input: The JSON to load. 

110 

111 Returns: 

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

113 """ 

114 return process_config(json.loads(json_input)) 

115 

116 

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

118 """ 

119 Recursion helper. 

120 

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

122 

123 Arguments: 

124 errors: The dictionary to update. 

125 obj: The object. 

126 """ 

127 if hasattr(obj, "docstring_errors") and obj.docstring_errors: # noqa: WPS421 (hasattr) 127 ↛ 128line 127 didn't jump to line 128, because the condition on line 127 was never true

128 errors[obj.path] = obj.docstring_errors 

129 for child in obj.children: 

130 extract_docstring_parsing_errors(errors, child) 

131 

132 

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

134 """ 

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

136 

137 Arguments: 

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

139 

140 Returns: 

141 A flat dictionary. Keys are the objects' names. 

142 """ 

143 parsing_errors: Dict[str, List[str]] = {} 

144 extract_docstring_parsing_errors(parsing_errors, obj) 

145 return parsing_errors 

146 

147 

148def get_parser() -> argparse.ArgumentParser: 

149 """ 

150 Return the program argument parser. 

151 

152 Returns: 

153 The argument parser for the program. 

154 """ 

155 parser = argparse.ArgumentParser(prog="pytkdocs") 

156 parser.add_argument( 

157 "-1", 

158 "--line-by-line", 

159 action="store_true", 

160 dest="line_by_line", 

161 help="Process each line read on stdin, one by one.", 

162 ) 

163 return parser 

164 

165 

166@contextmanager 

167def discarded_stdout(): 

168 """ 

169 Discard standard output. 

170 

171 Yields: 

172 Nothing: We only yield to act as a context manager. 

173 """ 

174 # Discard things printed at import time to avoid corrupting our JSON output 

175 # See https://github.com/pawamoy/pytkdocs/issues/24 

176 old_stdout = sys.stdout 

177 sys.stdout = StringIO() 

178 

179 yield 

180 

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

182 sys.stdout.flush() 

183 sys.stdout = old_stdout 

184 

185 

186def main(args: Optional[List[str]] = None) -> int: 

187 """ 

188 Run the main program. 

189 

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

191 

192 Arguments: 

193 args: Arguments passed from the command line. 

194 

195 Returns: 

196 An exit code. 

197 """ 

198 parser = get_parser() 

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

200 

201 if parsed_args.line_by_line: 

202 for line in sys.stdin: 

203 with discarded_stdout(): 

204 try: 

205 output = json.dumps(process_json(line)) 

206 except Exception as error: # noqa: W0703 (we purposely catch everything) 

207 # Don't fail on error. We must handle the next inputs. 

208 # Instead, print error as JSON. 

209 output = json.dumps({"error": str(error), "traceback": traceback.format_exc()}) 

210 print(output) # noqa: WPS421 (we need to print at some point) 

211 else: 

212 with discarded_stdout(): 

213 output = json.dumps(process_json(sys.stdin.read())) 

214 print(output) # noqa: WPS421 (we need to print at some point) 

215 

216 return 0