Coverage for pymend\docstring_parser\rest.py: 98%

162 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2024-04-20 19:09 +0200

1"""ReST-style docstring parsing.""" 

2 

3import inspect 

4import re 

5from typing import Optional, Union 

6 

7from .common import ( 

8 DEPRECATION_KEYWORDS, 

9 PARAM_KEYWORDS, 

10 RAISES_KEYWORDS, 

11 RETURNS_KEYWORDS, 

12 YIELDS_KEYWORDS, 

13 Docstring, 

14 DocstringDeprecated, 

15 DocstringMeta, 

16 DocstringParam, 

17 DocstringRaises, 

18 DocstringReturns, 

19 DocstringStyle, 

20 DocstringYields, 

21 ParseError, 

22 RenderingStyle, 

23 append_description, 

24 split_description, 

25) 

26 

27 

28def _build_param(args: list[str], desc: str) -> DocstringParam: 

29 """Build parameter entry from supplied arguments. 

30 

31 Parameters 

32 ---------- 

33 args : list[str] 

34 List of strings describing the parameter (name, type) 

35 desc : str 

36 String representing the parameter description. 

37 

38 Returns 

39 ------- 

40 DocstringParam 

41 The docstring object combining and structuring the raw info. 

42 

43 Raises 

44 ------ 

45 ParseError 

46 If an unexpected number of arguments were found. 

47 """ 

48 if len(args) == 3: 

49 _, type_name, arg_name = args 

50 if type_name.endswith("?"): 

51 is_optional = True 

52 type_name = type_name[:-1] 

53 else: 

54 is_optional = False 

55 elif len(args) == 2: 

56 _, arg_name = args 

57 type_name = None 

58 is_optional = None 

59 else: 

60 msg = f"Expected one or two arguments for a {args[0]} keyword." 

61 raise ParseError(msg) 

62 

63 match = re.match(r".*defaults to (.+)", desc, flags=re.DOTALL) 

64 default = match[1].rstrip(".") if match else None 

65 

66 return DocstringParam( 

67 args=args, 

68 description=desc, 

69 arg_name=arg_name, 

70 type_name=type_name, 

71 is_optional=is_optional, 

72 default=default, 

73 ) 

74 

75 

76def _build_return(args: list[str], desc: str) -> DocstringReturns: 

77 """Build return entry from supplied arguments. 

78 

79 Parameters 

80 ---------- 

81 args : list[str] 

82 List of strings describing the return value (name, type) 

83 desc : str 

84 String representing the return description. 

85 

86 Returns 

87 ------- 

88 DocstringReturns 

89 The docstring object combining and structuring the raw info. 

90 

91 Raises 

92 ------ 

93 ParseError 

94 If an unexpected number of arguments were found. 

95 """ 

96 if len(args) == 2: 

97 type_name = args[1] 

98 elif len(args) == 1: 

99 type_name = None 

100 else: 

101 msg = f"Expected one or no arguments for a {args[0]} keyword." 

102 raise ParseError(msg) 

103 

104 return DocstringReturns( 

105 args=args, 

106 description=desc, 

107 type_name=type_name, 

108 is_generator=False, 

109 ) 

110 

111 

112def _build_yield(args: list[str], desc: str) -> DocstringYields: 

113 """Build yield entry from supplied arguments. 

114 

115 Parameters 

116 ---------- 

117 args : list[str] 

118 List of strings describing the yielded value (name, type) 

119 desc : str 

120 String representing the yield value description. 

121 

122 Returns 

123 ------- 

124 DocstringYields 

125 The docstring object combining and structuring the raw info. 

126 

127 Raises 

128 ------ 

129 ParseError 

130 If an unexpected number of arguments were found. 

131 """ 

132 if len(args) == 2: 

133 type_name = args[1] 

134 elif len(args) == 1: 

135 type_name = None 

136 else: 

137 msg = f"Expected one or no arguments for a {args[0]} keyword." 

138 raise ParseError(msg) 

139 

140 return DocstringYields( 

141 args=args, 

142 description=desc, 

143 type_name=type_name, 

144 is_generator=True, 

145 ) 

146 

147 

148def _build_deprecation(args: list[str], desc: str) -> DocstringDeprecated: 

149 """Build deprecation entry from supplied arguments. 

150 

151 Parameters 

152 ---------- 

153 args : list[str] 

154 List of strings describing the deprecation 

155 desc : str 

156 Actual textual description. 

157 

158 Returns 

159 ------- 

160 DocstringDeprecated 

161 The docstring object combining and structuring the raw info. 

162 """ 

163 match = re.search( 

164 r"^(?P<version>v?((?:\d+)(?:\.[0-9a-z\.]+))) (?P<desc>.+)", 

165 desc, 

166 flags=re.I, 

167 ) 

168 return DocstringDeprecated( 

169 args=args, 

170 version=match["version"] if match else None, 

171 description=match["desc"] if match else desc, 

172 ) 

173 

174 

175def _build_raises(args: list[str], desc: str) -> DocstringRaises: 

176 """Build raises entry from supplied arguments. 

177 

178 Parameters 

179 ---------- 

180 args : list[str] 

181 List of strings describing the raised value (name, type) 

182 desc : str 

183 String representing the raised value description. 

184 

185 Returns 

186 ------- 

187 DocstringRaises 

188 The docstring object combining and structuring the raw info. 

189 

190 Raises 

191 ------ 

192 ParseError 

193 If an unexpected number of arguments were found. 

194 """ 

195 if len(args) == 2: 

196 type_name = args[1] 

197 elif len(args) == 1: 

198 type_name = None 

199 else: 

200 msg = f"Expected one or no arguments for a {args[0]} keyword." 

201 raise ParseError(msg) 

202 return DocstringRaises(args=args, description=desc, type_name=type_name) 

203 

204 

205def _build_meta(args: list[str], desc: str) -> DocstringMeta: 

206 """Build a fottomg meta entry from supplied arguments. 

207 

208 Parameters 

209 ---------- 

210 args : list[str] 

211 List of strings describing entry. 

212 desc : str 

213 String representing the entry description. 

214 

215 Returns 

216 ------- 

217 DocstringMeta 

218 The docstring object combining and structuring the raw info. 

219 """ 

220 key = args[0] 

221 

222 if key in PARAM_KEYWORDS: 

223 return _build_param(args, desc) 

224 

225 if key in RETURNS_KEYWORDS: 

226 return _build_return(args, desc) 

227 

228 if key in YIELDS_KEYWORDS: 

229 return _build_yield(args, desc) 

230 

231 if key in DEPRECATION_KEYWORDS: 

232 return _build_deprecation(args, desc) 

233 

234 if key in RAISES_KEYWORDS: 

235 return _build_raises(args, desc) 

236 

237 return DocstringMeta(args=args, description=desc) 

238 

239 

240def _get_chunks(text: str) -> tuple[str, str]: 

241 """Split the text into args (key, type, ...) and description. 

242 

243 Parameters 

244 ---------- 

245 text : str 

246 Text to split into chunks. 

247 

248 Returns 

249 ------- 

250 tuple[str, str] 

251 Args and description. 

252 """ 

253 if match := re.search("^:", text, flags=re.M): 

254 return text[: match.start()], text[match.start() :] 

255 return text, "" 

256 

257 

258def _get_split_chunks(chunk: str) -> tuple[list[str], str]: 

259 """Split an entry into args and description. 

260 

261 Parameters 

262 ---------- 

263 chunk : str 

264 Entry string to split. 

265 

266 Returns 

267 ------- 

268 tuple[list[str], str] 

269 Arguments of the entry and its description. 

270 

271 Raises 

272 ------ 

273 ParseError 

274 If the chunk could not be split into args and description. 

275 """ 

276 try: 

277 args_chunk, desc_chunk = chunk.lstrip(":").split(":", 1) 

278 except ValueError as ex: 

279 msg = f'Error parsing meta information near "{chunk}".' 

280 raise ParseError(msg) from ex 

281 return args_chunk.split(), desc_chunk.strip() 

282 

283 

284def _extract_type_info( 

285 docstring: Docstring, meta_chunk: str 

286) -> tuple[dict[str, str], dict[Optional[str], str], dict[Optional[str], str]]: 

287 """Extract type and description pairs and add other entries directly. 

288 

289 Parameters 

290 ---------- 

291 docstring : Docstring 

292 Docstring wrapper to add information to. 

293 meta_chunk : str 

294 Docstring text to extract information from. 

295 

296 Returns 

297 ------- 

298 types : dict[str, str] 

299 Dictionary matching parameters to their descriptions 

300 rtypes : dict[Optional[str], str] 

301 Dictionary matching return values to their descriptions 

302 ytypes : dict[Optional[str], str] 

303 Dictionary matching yielded values to their descriptions 

304 """ 

305 types: dict[str, str] = {} 

306 rtypes: dict[Optional[str], str] = {} 

307 ytypes: dict[Optional[str], str] = {} 

308 for chunk_match in re.finditer(r"(^:.*?)(?=^:|\Z)", meta_chunk, flags=re.S | re.M): 

309 chunk = chunk_match.group(0) 

310 if not chunk: 310 ↛ 311line 310 didn't jump to line 311, because the condition on line 310 was never true

311 continue 

312 

313 args, desc = _get_split_chunks(chunk) 

314 

315 if "\n" in desc: 

316 first_line, rest = desc.split("\n", 1) 

317 desc = first_line + "\n" + inspect.cleandoc(rest) 

318 

319 # Add special handling for :type a: typename 

320 if len(args) == 2 and args[0] == "type": 

321 types[args[1]] = desc 

322 elif len(args) in {1, 2} and args[0] == "rtype": 

323 rtypes[None if len(args) == 1 else args[1]] = desc 

324 elif len(args) in {1, 2} and args[0] == "ytype": 

325 ytypes[None if len(args) == 1 else args[1]] = desc 

326 else: 

327 docstring.meta.append(_build_meta(args, desc)) 

328 return types, rtypes, ytypes 

329 

330 

331def parse(text: Optional[str]) -> Docstring: 

332 """Parse the ReST-style docstring into its components. 

333 

334 Parameters 

335 ---------- 

336 text : Optional[str] 

337 docstring to parse 

338 

339 Returns 

340 ------- 

341 Docstring 

342 parsed docstring 

343 

344 Raises 

345 ------ 

346 ParseError 

347 If a section does not have two colons to be split on. 

348 """ 

349 ret = Docstring(style=DocstringStyle.REST) 

350 if not text: 

351 return ret 

352 

353 text = inspect.cleandoc(text) 

354 desc_chunk, meta_chunk = _get_chunks(text) 

355 

356 split_description(ret, desc_chunk) 

357 

358 types, rtypes, ytypes = _extract_type_info(ret, meta_chunk) 

359 

360 for meta in ret.meta: 

361 if isinstance(meta, DocstringParam): 

362 meta.type_name = meta.type_name or types.get(meta.arg_name) 

363 elif isinstance(meta, DocstringReturns): 

364 meta.type_name = meta.type_name or rtypes.get(meta.return_name) 

365 elif isinstance(meta, DocstringYields): 

366 meta.type_name = meta.type_name or ytypes.get(meta.yield_name) 

367 

368 if not any(isinstance(m, DocstringReturns) for m in ret.meta) and rtypes: 

369 for return_name, type_name in rtypes.items(): 

370 ret.meta.append( 

371 DocstringReturns( 

372 args=[], 

373 type_name=type_name, 

374 description=None, 

375 is_generator=False, 

376 return_name=return_name, 

377 ) 

378 ) 

379 

380 return ret 

381 

382 

383def process_desc( 

384 desc: Optional[str], rendering_style: RenderingStyle, indent: str = " " 

385) -> str: 

386 """Process the description for one element. 

387 

388 Parameters 

389 ---------- 

390 desc : Optional[str] 

391 Description to process 

392 rendering_style : RenderingStyle 

393 Rendering style to use. 

394 indent : str 

395 Indentation needed for that line (Default value = ' ') 

396 

397 Returns 

398 ------- 

399 str 

400 String representation of the docstrings description. 

401 """ 

402 if not desc: 

403 return "" 

404 

405 if rendering_style == RenderingStyle.CLEAN: 

406 (first, *rest) = desc.splitlines() 

407 return "\n".join([f" {first}"] + [indent + line for line in rest]) 

408 

409 if rendering_style == RenderingStyle.EXPANDED: 

410 (first, *rest) = desc.splitlines() 

411 return "\n".join(["\n" + indent + first] + [indent + line for line in rest]) 

412 

413 return f" {desc}" 

414 

415 

416def _append_param( 

417 param: DocstringParam, 

418 parts: list[str], 

419 rendering_style: RenderingStyle, 

420 indent: str, 

421) -> None: 

422 """Append one parameter entry to the output stream. 

423 

424 Parameters 

425 ---------- 

426 param : DocstringParam 

427 Structured representation of a parameter entry. 

428 parts : list[str] 

429 List of strings representing the final output of compose(). 

430 rendering_style : RenderingStyle 

431 Rendering style to use. 

432 indent : str 

433 Indentation needed for that line. 

434 """ 

435 if param.type_name: 

436 type_text = ( 

437 f" {param.type_name}? " if param.is_optional else f" {param.type_name} " 

438 ) 

439 else: 

440 type_text = " " 

441 if rendering_style == RenderingStyle.EXPANDED: 

442 text = f":param {param.arg_name}:" 

443 text += process_desc(param.description, rendering_style, indent) 

444 parts.append(text) 

445 if type_text[:-1]: 445 ↛ exitline 445 didn't return from function '_append_param', because the condition on line 445 was never false

446 parts.append(f":type {param.arg_name}:{type_text[:-1]}") 

447 else: 

448 text = f":param{type_text}{param.arg_name}:" 

449 text += process_desc(param.description, rendering_style, indent) 

450 parts.append(text) 

451 

452 

453def _append_return( 

454 meta: Union[DocstringReturns, DocstringYields], 

455 parts: list[str], 

456 rendering_style: RenderingStyle, 

457 indent: str, 

458) -> None: 

459 """Append one return/yield entry to the output stream. 

460 

461 Parameters 

462 ---------- 

463 meta : Union[DocstringReturns, DocstringYields] 

464 Structured representation of a return/yield entry. 

465 parts : list[str] 

466 List of strings representing the final output of compose(). 

467 rendering_style : RenderingStyle 

468 Rendering style to use. 

469 indent : str 

470 Indentation needed for that line. 

471 """ 

472 type_text = f" {meta.type_name}" if meta.type_name else "" 

473 key = "yields" if isinstance(meta, DocstringYields) else "returns" 

474 

475 if rendering_style == RenderingStyle.EXPANDED: 

476 if meta.description: 

477 text = f":{key}:" 

478 text += process_desc(meta.description, rendering_style, indent) 

479 parts.append(text) 

480 if type_text: 480 ↛ exitline 480 didn't return from function '_append_return', because the condition on line 480 was never false

481 return_key = "rtype" if isinstance(meta, DocstringReturns) else "ytype" 

482 parts.append(f":{return_key}:{type_text}") 

483 else: 

484 text = f":{key}{type_text}:" 

485 text += process_desc(meta.description, rendering_style, indent) 

486 parts.append(text) 

487 

488 

489def compose( 

490 docstring: Docstring, 

491 rendering_style: RenderingStyle = RenderingStyle.COMPACT, 

492 indent: str = " ", 

493) -> str: 

494 """Render a parsed docstring into docstring text. 

495 

496 Parameters 

497 ---------- 

498 docstring : Docstring 

499 parsed docstring representation 

500 rendering_style : RenderingStyle 

501 the style to render docstrings (Default value = RenderingStyle.COMPACT) 

502 indent : str 

503 the characters used as indentation in the docstring string 

504 (Default value = ' ') 

505 

506 Returns 

507 ------- 

508 str 

509 docstring text 

510 """ 

511 parts: list[str] = [] 

512 append_description(docstring, parts) 

513 

514 for meta in docstring.meta: 

515 if isinstance(meta, DocstringParam): 

516 _append_param(meta, parts, rendering_style, indent) 

517 elif isinstance(meta, (DocstringReturns, DocstringYields)): 

518 _append_return(meta, parts, rendering_style, indent) 

519 elif isinstance(meta, DocstringRaises): 

520 type_text = f" {meta.type_name} " if meta.type_name else "" 

521 text = ( 

522 f":raises{type_text}:" 

523 f"{process_desc(meta.description, rendering_style, indent)}" 

524 ) 

525 parts.append(text) 

526 else: 

527 text = ( 

528 f":{' '.join(meta.args)}:" 

529 f"{process_desc(meta.description, rendering_style, indent)}" 

530 ) 

531 parts.append(text) 

532 return "\n".join(parts)