Coverage for src/pytkdocs/parsers/docstrings/numpy.py: 74.11%

146 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-03-09 18:24 +0100

1"""This module defines functions and classes to parse docstrings into structured data.""" 

2 

3import re 

4from re import Pattern 

5from typing import Any, Optional 

6 

7from docstring_parser import parse 

8from docstring_parser.common import Docstring, DocstringMeta 

9 

10from pytkdocs.parsers.docstrings.base import AnnotatedObject, Attribute, Parameter, Parser, Section, empty 

11 

12RE_DOCTEST_BLANKLINE: Pattern = re.compile(r"^\s*<BLANKLINE>\s*$") 

13"""Regular expression to match lines of the form `<BLANKLINE>`.""" 

14RE_DOCTEST_FLAGS: Pattern = re.compile(r"(\s*#\s*doctest:.+)$") 

15"""Regular expression to match lines containing doctest flags of the form `# doctest: +FLAG`.""" 

16 

17 

18class Numpy(Parser): 

19 """A Numpy-style docstrings parser.""" 

20 

21 def __init__(self, trim_doctest_flags: bool = True, **kwargs: Any) -> None: # noqa: FBT001, FBT002, ARG002 

22 """Initialize the objects. 

23 

24 Arguments: 

25 trim_doctest_flags: Whether to remove doctest flags. 

26 """ 

27 super().__init__() 

28 self.trim_doctest_flags = trim_doctest_flags 

29 self.section_reader = { 

30 Section.Type.PARAMETERS: self.read_parameters_section, 

31 Section.Type.EXCEPTIONS: self.read_exceptions_section, 

32 Section.Type.EXAMPLES: self.read_examples_section, 

33 Section.Type.ATTRIBUTES: self.read_attributes_section, 

34 Section.Type.RETURN: self.read_return_section, 

35 } 

36 

37 def parse_sections(self, docstring: str) -> list[Section]: # noqa: D102 

38 if "signature" not in self.context: 38 ↛ 39line 38 didn't jump to line 39 because the condition on line 38 was never true

39 self.context["signature"] = getattr(self.context["obj"], "signature", None) 

40 if "annotation" not in self.context: 40 ↛ 42line 40 didn't jump to line 42 because the condition on line 40 was always true

41 self.context["annotation"] = getattr(self.context["obj"], "type", empty) 

42 if "attributes" not in self.context: 42 ↛ 45line 42 didn't jump to line 45 because the condition on line 42 was always true

43 self.context["attributes"] = {} 

44 

45 docstring_obj = parse(docstring) 

46 description_all = ( 

47 none_str_cast(docstring_obj.short_description) + "\n\n" + none_str_cast(docstring_obj.long_description) 

48 ).strip() 

49 sections = [Section(Section.Type.MARKDOWN, description_all)] if description_all else [] 

50 sections_other = [ 

51 reader(docstring_obj) if sec == Section.Type.RETURN else reader(docstring, docstring_obj) # type: ignore[operator] 

52 for (sec, reader) in self.section_reader.items() 

53 ] 

54 sections.extend([sec for sec in sections_other if sec]) 

55 return sections 

56 

57 def read_parameters_section( 

58 self, 

59 docstring: str, 

60 docstring_obj: Docstring, 

61 ) -> Optional[Section]: 

62 """Parse a "parameters" section. 

63 

64 Arguments: 

65 docstring: The raw docstring. 

66 docstring_obj: Docstring object parsed by docstring_parser. 

67 

68 Returns: 

69 A `Section` object (or `None` if section is empty). 

70 """ 

71 parameters = [] 

72 

73 docstring_params = [p for p in docstring_obj.params if p.args[0] == "param"] 

74 

75 for param in docstring_params: 

76 name = param.arg_name 

77 kind = None 

78 type_name = param.type_name 

79 default = param.default or empty 

80 try: 

81 signature_param = self.context["signature"].parameters[name.lstrip("*")] 

82 except (AttributeError, KeyError): 

83 self.error(f"No type annotation for parameter '{name}'") 

84 else: 

85 if signature_param.annotation is not empty: 

86 type_name = signature_param.annotation 

87 if signature_param.default is not empty: 

88 default = signature_param.default 

89 kind = signature_param.kind 

90 

91 description = param.description or "" 

92 if not description: 

93 self.error(f"No description for parameter '{name}'") 

94 

95 parameters.append( 

96 Parameter( 

97 name=param.arg_name, 

98 annotation=type_name, 

99 description=description, 

100 default=default, 

101 kind=kind, 

102 ), 

103 ) 

104 

105 if parameters: 

106 return Section(Section.Type.PARAMETERS, parameters) 

107 if re.search("Parameters\n", docstring): 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true

108 self.error("Empty parameter section") 

109 return None 

110 

111 def read_attributes_section( 

112 self, 

113 docstring: str, 

114 docstring_obj: Docstring, 

115 ) -> Optional[Section]: 

116 """Parse an "attributes" section. 

117 

118 Arguments: 

119 docstring: The raw docstring. 

120 docstring_obj: Docstring object parsed by docstring_parser. 

121 

122 Returns: 

123 A `Section` object (or `None` if section is empty). 

124 """ 

125 attributes = [] 

126 docstring_attributes = [p for p in docstring_obj.params if p.args[0] == "attribute"] 

127 

128 for attr in docstring_attributes: 128 ↛ 129line 128 didn't jump to line 129 because the loop on line 128 never started

129 description = attr.description or "" 

130 if not description: 

131 self.error(f"No description for attribute '{attr.arg_name}'") 

132 attributes.append( 

133 Attribute( 

134 name=attr.arg_name, 

135 annotation=attr.type_name, 

136 description=description, 

137 ), 

138 ) 

139 

140 if attributes: 140 ↛ 141line 140 didn't jump to line 141 because the condition on line 140 was never true

141 return Section(Section.Type.ATTRIBUTES, attributes) 

142 if re.search("Attributes\n", docstring): 142 ↛ 143line 142 didn't jump to line 143 because the condition on line 142 was never true

143 self.error("Empty attributes section") 

144 return None 

145 

146 def read_exceptions_section( 

147 self, 

148 docstring: str, 

149 docstring_obj: Docstring, 

150 ) -> Optional[Section]: 

151 """Parse an "exceptions" section. 

152 

153 Arguments: 

154 docstring: The raw docstring. 

155 docstring_obj: Docstring object parsed by docstring_parser. 

156 

157 Returns: 

158 A `Section` object (or `None` if section is empty). 

159 """ 

160 exceptions = [] 

161 except_obj = docstring_obj.raises 

162 

163 for exception in except_obj: 

164 description = exception.description or "" 

165 if not description: 

166 self.error(f"No description for exception '{exception.type_name}'") 

167 exceptions.append(AnnotatedObject(exception.type_name, description)) 

168 

169 if exceptions: 

170 return Section(Section.Type.EXCEPTIONS, exceptions) 

171 if re.search("Raises\n", docstring): 171 ↛ 172line 171 didn't jump to line 172 because the condition on line 171 was never true

172 self.error("Empty exceptions section") 

173 return None 

174 

175 def read_return_section( 

176 self, 

177 docstring_obj: Docstring, 

178 ) -> Optional[Section]: 

179 """Parse a "returns" section. 

180 

181 Arguments: 

182 docstring_obj: Docstring object parsed by docstring_parser. 

183 

184 Returns: 

185 A `Section` object (or `None` if section is empty). 

186 """ 

187 if docstring_obj.returns: 

188 return_obj = docstring_obj.returns 

189 

190 if return_obj.description: 

191 description = return_obj.description 

192 else: 

193 self.error("Empty return description") 

194 description = "" 

195 

196 if self.context["signature"]: 

197 annotation = self.context["signature"].return_annotation 

198 else: 

199 annotation = self.context["annotation"] 

200 

201 if annotation is empty and return_obj.type_name: 

202 annotation = return_obj.type_name 

203 

204 if not annotation: 204 ↛ 205line 204 didn't jump to line 205 because the condition on line 204 was never true

205 self.error("No return type annotation") 

206 annotation = "" 

207 

208 if annotation or description: 208 ↛ 211line 208 didn't jump to line 211 because the condition on line 208 was always true

209 return Section(Section.Type.RETURN, AnnotatedObject(annotation, description)) 

210 

211 return None 

212 

213 def read_examples_section( 

214 self, 

215 docstring: str, 

216 docstring_obj: Docstring, 

217 ) -> Optional[Section]: 

218 """Parse an "examples" section. 

219 

220 Arguments: 

221 docstring: The raw docstring. 

222 docstring_obj: Docstring object parsed by docstring_parser. 

223 

224 Returns: 

225 A `Section` object (or `None` if section is empty). 

226 """ 

227 text = next( 

228 ( 

229 meta.description 

230 for meta in docstring_obj.meta 

231 if isinstance(meta, DocstringMeta) and meta.args[0] == "examples" 

232 ), 

233 "", 

234 ) 

235 

236 sub_sections = [] 

237 in_code_example = False 

238 in_code_block = False 

239 current_text: list[str] = [] 

240 current_example: list[str] = [] 

241 

242 if text: 

243 for line in text.split("\n"): 

244 if is_empty_line(line): 

245 if in_code_example: 245 ↛ 246line 245 didn't jump to line 246 because the condition on line 245 was never true

246 if current_example: 

247 sub_sections.append((Section.Type.EXAMPLES, "\n".join(current_example))) 

248 current_example = [] 

249 in_code_example = False 

250 else: 

251 current_text.append(line) 

252 

253 elif in_code_example: 253 ↛ 254line 253 didn't jump to line 254 because the condition on line 253 was never true

254 if self.trim_doctest_flags: 

255 line = RE_DOCTEST_FLAGS.sub("", line) # noqa: PLW2901 

256 line = RE_DOCTEST_BLANKLINE.sub("", line) # noqa: PLW2901 

257 current_example.append(line) 

258 

259 elif line.startswith("```"): 259 ↛ 260line 259 didn't jump to line 260 because the condition on line 259 was never true

260 in_code_block = not in_code_block 

261 current_text.append(line) 

262 

263 elif in_code_block: 263 ↛ 264line 263 didn't jump to line 264 because the condition on line 263 was never true

264 current_text.append(line) 

265 

266 elif line.startswith(">>>"): 266 ↛ 267line 266 didn't jump to line 267 because the condition on line 266 was never true

267 if current_text: 

268 sub_sections.append((Section.Type.MARKDOWN, "\n".join(current_text))) 

269 current_text = [] 

270 in_code_example = True 

271 

272 if self.trim_doctest_flags: 

273 line = RE_DOCTEST_FLAGS.sub("", line) # noqa: PLW2901 

274 current_example.append(line) 

275 else: 

276 current_text.append(line) 

277 

278 if current_text: 

279 sub_sections.append((Section.Type.MARKDOWN, "\n".join(current_text))) 

280 elif current_example: 280 ↛ 281line 280 didn't jump to line 281 because the condition on line 280 was never true

281 sub_sections.append((Section.Type.EXAMPLES, "\n".join(current_example))) 

282 

283 if sub_sections: 

284 return Section(Section.Type.EXAMPLES, sub_sections) 

285 

286 if re.search("Examples\n", docstring): 286 ↛ 287line 286 didn't jump to line 287 because the condition on line 286 was never true

287 self.error("Empty examples section") 

288 return None 

289 

290 

291def is_empty_line(line: str) -> bool: 

292 """Tell if a line is empty. 

293 

294 Arguments: 

295 line: The line to check. 

296 

297 Returns: 

298 True if the line is empty or composed of blanks only, False otherwise. 

299 """ 

300 return not line.strip() 

301 

302 

303def none_str_cast(string: Optional[str]) -> str: # noqa: D103 

304 return string or ""