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

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

2

3 import inspect

4 import re

5 from typing import Optional, Union

6

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

28 def _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

76 def _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

112 def _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

148 def _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

175 def _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

205 def _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

240 def _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

258 def _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

284 def _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

331 def 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

383 def 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

416 def _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

453 def _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

489 def 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)