Coverage for pymend\pymendapp.py: 0%

127 statements  

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

1#!/usr/bin/python 

2"""Command line interface for pymend.""" 

3 

4import platform 

5import re 

6import traceback 

7from pathlib import Path 

8from re import Pattern 

9from typing import Any, Optional, Union 

10 

11import click 

12from click.core import ParameterSource 

13 

14import pymend.docstring_parser as dsp 

15from pymend import PyComment, __version__ 

16 

17from .const import DEFAULT_EXCLUDES 

18from .files import find_pyproject_toml, parse_pyproject_toml 

19from .output import out 

20from .report import Report 

21from .types import FixerSettings 

22 

23STRING_TO_STYLE = { 

24 "rest": dsp.DocstringStyle.REST, 

25 "javadoc": dsp.DocstringStyle.EPYDOC, 

26 "numpydoc": dsp.DocstringStyle.NUMPYDOC, 

27 "google": dsp.DocstringStyle.GOOGLE, 

28} 

29 

30 

31def path_is_excluded( 

32 normalized_path: str, 

33 pattern: Optional[Pattern[str]], 

34) -> bool: 

35 """Check if a path is excluded because it matches and exclusion regex. 

36 

37 Parameters 

38 ---------- 

39 normalized_path : str 

40 Normalized path to check 

41 pattern : Optional[Pattern[str]] 

42 Optionally a regex pattern to check against 

43 

44 Returns 

45 ------- 

46 bool 

47 True if the path is excluded by the regex. 

48 """ 

49 match = pattern.search(normalized_path) if pattern else None 

50 return bool(match and match.group(0)) 

51 

52 

53def style_option_callback( 

54 _c: click.Context, _p: Union[click.Option, click.Parameter], style: str 

55) -> dsp.DocstringStyle: 

56 """Compute the output style from a --output_stye flag. 

57 

58 Parameters 

59 ---------- 

60 style : str 

61 String representation of the style to use. 

62 

63 Returns 

64 ------- 

65 dsp.DocstringStyle 

66 Style to use. 

67 """ 

68 if style in STRING_TO_STYLE: 

69 return STRING_TO_STYLE[style] 

70 return dsp.DocstringStyle.AUTO 

71 

72 

73def re_compile_maybe_verbose(regex: str) -> Pattern[str]: 

74 """Compile a regular expression string in `regex`. 

75 

76 If it contains newlines, use verbose mode. 

77 

78 Parameters 

79 ---------- 

80 regex : str 

81 Regex to compile. 

82 

83 Returns 

84 ------- 

85 Pattern[str] 

86 Compiled regex. 

87 """ 

88 if "\n" in regex: 

89 regex = "(?x)" + regex 

90 compiled: Pattern[str] = re.compile(regex) 

91 return compiled 

92 

93 

94def validate_regex( 

95 _ctx: click.Context, 

96 _param: click.Parameter, 

97 value: Optional[str], 

98) -> Optional[Pattern[str]]: 

99 """Validate the regex from command line. 

100 

101 Parameters 

102 ---------- 

103 value : Optional[str] 

104 Regex pattern to validate. 

105 

106 Returns 

107 ------- 

108 Optional[Pattern[str]] 

109 Compiled regex pattern or None if the input was None. 

110 

111 Raises 

112 ------ 

113 click.BadParameter 

114 If the value is not a valid regex. 

115 """ 

116 try: 

117 return re_compile_maybe_verbose(value) if value is not None else None 

118 except re.error as e: 

119 msg = f"Not a valid regular expression: {e}" 

120 raise click.BadParameter(msg) from None 

121 

122 

123def run( 

124 files: tuple[str, ...], 

125 *, 

126 overwrite: bool = False, 

127 output_style: dsp.DocstringStyle = dsp.DocstringStyle.NUMPYDOC, 

128 input_style: dsp.DocstringStyle = dsp.DocstringStyle.AUTO, 

129 exclude: Pattern[str], 

130 extend_exclude: Optional[Pattern[str]], 

131 report: Report, 

132 fixer_settings: FixerSettings, 

133) -> None: 

134 r"""Run pymend over the list of files.. 

135 

136 Parameters 

137 ---------- 

138 files : tuple[str, ...] 

139 List of files to analyze and fix. 

140 overwrite : bool 

141 Whether to overwrite the source file directly instead of creating 

142 a patch. (Default value = False) 

143 output_style : dsp.DocstringStyle 

144 Output style to use for the modified docstrings. 

145 (Default value = dsp.DocstringStyle.NUMPYDOC) 

146 input_style : dsp.DocstringStyle 

147 Input docstring style. 

148 Auto means that the style is detected automatically. Can cause issues when 

149 styles are mixed in examples or descriptions." 

150 (Default value = dsp.DocstringStyle.AUTO) 

151 exclude : Pattern[str] 

152 Optional regex pattern to use to exclude files from reformatting. 

153 extend_exclude : Optional[Pattern[str]] 

154 Additional regexes to add onto the exclude pattern. 

155 Useful if one just wants to add some to the existing default. 

156 report : Report 

157 Reporter for pretty communication with the user. 

158 fixer_settings : FixerSettings 

159 Settings for which fixes should be performed. 

160 

161 Raises 

162 ------ 

163 AssertionError 

164 If the input and output lines are identical but pymend reports 

165 some elements to have changed. 

166 """ 

167 for file in files: 

168 if path_is_excluded(file, exclude): 

169 report.path_ignored(file, "matches the --exclude regular expression") 

170 continue 

171 if path_is_excluded(file, extend_exclude): 

172 report.path_ignored(file, "matches the --extend-exclude regular expression") 

173 continue 

174 try: 

175 comment = PyComment( 

176 Path(file), 

177 output_style=output_style, 

178 input_style=input_style, 

179 fixer_settings=fixer_settings, 

180 ) 

181 n_issues, issue_report = comment.report_issues() 

182 # Not using ternary when the calls have side effects 

183 if overwrite: # noqa: SIM108 

184 changed = comment.output_fix() 

185 else: 

186 changed = comment.output_patch() 

187 report.done( 

188 file, changed=changed, issues=bool(n_issues), issue_report=issue_report 

189 ) 

190 except Exception as exc: # noqa: BLE001 

191 if report.verbose: 

192 traceback.print_exc() 

193 report.failed(file, str(exc)) 

194 

195 

196def read_pyproject_toml( 

197 ctx: click.Context, _param: click.Parameter, value: Optional[str] 

198) -> Optional[str]: 

199 """Inject Pymend configuration from "pyproject.toml" into defaults in `ctx`. 

200 

201 Returns the path to a successfully found and read configuration file, None 

202 otherwise. 

203 

204 Parameters 

205 ---------- 

206 ctx : click.Context 

207 Context containing preexisting default values. 

208 value : Optional[str] 

209 Optionally path to the config file. 

210 

211 Returns 

212 ------- 

213 Optional[str] 

214 Path to the config file if one was found or specified. 

215 

216 Raises 

217 ------ 

218 click.FileError 

219 If there was a problem reading the configuration file. 

220 click.BadOptionUsage 

221 If the value passed for `exclude` was not a string. 

222 click.BadOptionUsage 

223 If the value passed for `extended_exclude` was not a string. 

224 """ 

225 if not value: 

226 value = find_pyproject_toml(ctx.params.get("src", ())) 

227 if value is None: 

228 return None 

229 

230 try: 

231 config = parse_pyproject_toml(value) 

232 except (OSError, ValueError) as e: 

233 raise click.FileError( 

234 filename=value, hint=f"Error reading configuration file: {e}" 

235 ) from None 

236 

237 if not config: 

238 return None 

239 # Sanitize the values to be Click friendly. For more information please see: 

240 # https://github.com/psf/black/issues/1458 

241 # https://github.com/pallets/click/issues/1567 

242 config: dict[str, Any] = { 

243 k: str(v) if not isinstance(v, (list, dict)) else v for k, v in config.items() 

244 } 

245 

246 exclude = config.get("exclude") 

247 if exclude is not None and not isinstance(exclude, str): 

248 raise click.BadOptionUsage( 

249 "exclude", # noqa: EM101 

250 "Config key exclude must be a string", 

251 ) 

252 

253 extend_exclude = config.get("extend_exclude") 

254 if extend_exclude is not None and not isinstance(extend_exclude, str): 

255 raise click.BadOptionUsage( 

256 "extend-exclude", # noqa: EM101 

257 "Config key extend-exclude must be a string", 

258 ) 

259 

260 default_map: dict[str, Any] = {} 

261 if ctx.default_map: 

262 default_map.update(ctx.default_map) 

263 default_map.update(config) 

264 

265 ctx.default_map = default_map 

266 return value 

267 

268 

269@click.command( 

270 context_settings={"help_option_names": ["-h", "--help"]}, 

271 help="Create, update or convert docstrings.", 

272) 

273@click.option( 

274 "--write/--diff", 

275 is_flag=True, 

276 default=False, 

277 help="Directly overwrite the source files instead of just producing a patch.", 

278) 

279@click.option( 

280 "-o", 

281 "--output-style", 

282 type=click.Choice(list(STRING_TO_STYLE)), 

283 callback=style_option_callback, 

284 multiple=False, 

285 default="numpydoc", 

286 help=("Output docstring style."), 

287) 

288@click.option( 

289 "-i", 

290 "--input-style", 

291 type=click.Choice([*list(STRING_TO_STYLE), "auto"]), 

292 callback=style_option_callback, 

293 multiple=False, 

294 default="auto", 

295 help=( 

296 "Input docstring style." 

297 " Auto means that the style is detected automatically. Can cause issues when" 

298 " styles are mixed in examples or descriptions." 

299 ), 

300) 

301@click.option( 

302 "--check", 

303 is_flag=True, 

304 help=( 

305 "Perform check if file is properly docstringed." 

306 " Also reports negatively on pymend defaults." 

307 " Return code 0 means everything was perfect." 

308 " Return code 1 means some files would has issues." 

309 " Return code 123 means there was an internal error." 

310 ), 

311) 

312@click.option( 

313 "--exclude", 

314 type=str, 

315 callback=validate_regex, 

316 help=( 

317 "A regular expression that matches files and directories that should be" 

318 " excluded. An empty value means no paths are excluded." 

319 " Use forward slashes for directories on all platforms (Windows, too)." 

320 f"[default: {DEFAULT_EXCLUDES}]" 

321 ), 

322 show_default=False, 

323) 

324@click.option( 

325 "--extend-exclude", 

326 type=str, 

327 callback=validate_regex, 

328 help=( 

329 "Like --exclude, but adds additional files and directories on top of the" 

330 " excluded ones. (Useful if you simply want to add to the default)" 

331 ), 

332) 

333@click.option( 

334 "--force-params/--unforce-params", 

335 type=bool, 

336 is_flag=True, 

337 default=True, 

338 help="Whether to force a parameter section even if" 

339 " there is already an existing docstring. " 

340 "If set will also fill force the parameters section to name every parameter.", 

341) 

342@click.option( 

343 "--force-params-min-n-params", 

344 type=int, 

345 default=0, 

346 help="Minimum number of arguments detected in the signature " 

347 "to actually enforce parameters." 

348 " If less than the specified numbers of arguments are" 

349 " detected then a parameters section is only build for new docstrings." 

350 " No new sections are created for existing docstrings and existing sections" 

351 " are not extended. Only has an effect with --force-params set to true.", 

352) 

353@click.option( 

354 "--force-meta-min-func-length", 

355 type=int, 

356 default=0, 

357 help="Minimum number statements in the function body " 

358 "to actually enforce parameters." 

359 " If less than the specified numbers of arguments are" 

360 " detected then a parameters section is only build for new docstrings." 

361 " No new sections are created for existing docstrings and existing sections" 

362 " are not extended. Only has an effect with" 

363 " `--force-params` or `--force-return` set to true.", 

364) 

365@click.option( 

366 "--force-return/--unforce-return", 

367 type=bool, 

368 is_flag=True, 

369 default=True, 

370 help="Whether to force a return/yield section even if" 

371 " there is already an existing docstring. " 

372 "Will only actually force return/yield sections" 

373 " if any value return or yield is found in the body.", 

374) 

375@click.option( 

376 "--force-raises/--unforce-raises", 

377 type=bool, 

378 is_flag=True, 

379 default=True, 

380 help="Whether to force a raises section even if" 

381 " there is already an existing docstring." 

382 " Will only actually force the section if any raises were detected in the body." 

383 " However, if set it will force on entry in the section per raise detected.", 

384) 

385@click.option( 

386 "--force-methods/--unforce-methods", 

387 type=bool, 

388 is_flag=True, 

389 default=False, 

390 help="Whether to force a methods section for classes even if" 

391 " there is already an existing docstring." 

392 " If set it will force on entry in the section per method found." 

393 " If only some methods are desired to be specified then this should be left off.", 

394) 

395@click.option( 

396 "--force-attributes/--unforce-attributes", 

397 type=bool, 

398 is_flag=True, 

399 default=False, 

400 help="Whether to force an attributes section for classes even if" 

401 " there is already an existing docstring." 

402 " If set it will force on entry in the section per attribute found." 

403 " If only some attributes are desired then this should be left off.", 

404) 

405@click.option( 

406 "--ignore-privates/--handle-privates", 

407 is_flag=True, 

408 default=True, 

409 help="Whether to ignore attributes and methods that start with an underscore '_'." 

410 " This also means that methods with two underscores are ignored." 

411 " Consequently turning this off also forces processing of such methods." 

412 " Dunder methods are an exception and are" 

413 " always ignored regardless of this setting.", 

414) 

415@click.option( 

416 "--ignore-unused-arguments/--handle-unused-arguments", 

417 is_flag=True, 

418 default=True, 

419 help="Whether to ignore arguments starting with an underscore '_'" 

420 " are ignored when building parameter sections.", 

421) 

422@click.option( 

423 "--ignored-decorators", 

424 multiple=True, 

425 default=["overload"], 

426 help="Decorators that, if present," 

427 " should cause a function to be ignored for docstring analysis and generation.", 

428) 

429@click.option( 

430 "--ignored-functions", 

431 multiple=True, 

432 default=["main"], 

433 help="Functions that should be ignored for docstring analysis and generation." 

434 " Only exact matches are ignored. This is not a regex pattern.", 

435) 

436@click.option( 

437 "--ignored-classes", 

438 multiple=True, 

439 default=[], 

440 help="Classes that should be ignored for docstring analysis and generation." 

441 " Only exact matches are ignored. This is not a regex pattern.", 

442) 

443@click.option( 

444 "--force-defaults/--unforce-defaults", 

445 is_flag=True, 

446 default=True, 

447 help="Whether to enforce descriptions need to" 

448 " name/explain the default value of their parameter.", 

449) 

450@click.option( 

451 "-q", 

452 "--quiet", 

453 is_flag=True, 

454 help=( 

455 "Don't emit non-error messages to stderr. Errors are still emitted; silence" 

456 " those with 2>/dev/null." 

457 ), 

458) 

459@click.option( 

460 "-v", 

461 "--verbose", 

462 is_flag=True, 

463 help=( 

464 "Also emit messages to stderr about files that were not changed or were ignored" 

465 " due to exclusion patterns." 

466 ), 

467) 

468@click.version_option( 

469 version=__version__, 

470 message=( 

471 f"%(prog)s, %(version)s\n" 

472 f"Python ({platform.python_implementation()}) {platform.python_version()}" 

473 ), 

474) 

475@click.argument( 

476 "src", 

477 nargs=-1, 

478 type=click.Path( 

479 exists=True, file_okay=True, dir_okay=False, readable=True, allow_dash=False 

480 ), 

481 is_eager=True, 

482 metavar="SRC ...", 

483) 

484@click.option( 

485 "--config", 

486 type=click.Path( 

487 exists=True, 

488 file_okay=True, 

489 dir_okay=False, 

490 readable=True, 

491 allow_dash=False, 

492 path_type=str, 

493 ), 

494 is_eager=True, 

495 callback=read_pyproject_toml, 

496 help="Read configuration from FILE path.", 

497) 

498@click.pass_context 

499def main( # pylint: disable=too-many-arguments, too-many-locals # noqa: PLR0913 

500 ctx: click.Context, 

501 *, 

502 write: bool, 

503 output_style: dsp.DocstringStyle, 

504 input_style: dsp.DocstringStyle, 

505 check: bool, 

506 exclude: Optional[Pattern[str]], 

507 extend_exclude: Optional[Pattern[str]], 

508 force_params: bool, 

509 force_params_min_n_params: bool, 

510 force_meta_min_func_length: bool, 

511 force_return: bool, 

512 force_raises: bool, 

513 force_methods: bool, 

514 force_attributes: bool, 

515 ignore_privates: bool, 

516 ignore_unused_arguments: bool, 

517 ignored_decorators: list[str], 

518 ignored_functions: list[str], 

519 ignored_classes: list[str], 

520 force_defaults: bool, 

521 quiet: bool, 

522 verbose: bool, 

523 src: tuple[str, ...], 

524 config: Optional[str], 

525) -> None: 

526 """Create, update or convert docstrings.""" 

527 ctx.ensure_object(dict) 

528 

529 if not src: 

530 out(main.get_usage(ctx) + "\n\nError: Missing argument 'SRC ...'.") 

531 ctx.exit(1) 

532 

533 if verbose and config: 

534 config_source = ctx.get_parameter_source("config") 

535 if config_source in ( 

536 ParameterSource.DEFAULT, 

537 ParameterSource.DEFAULT_MAP, 

538 ): 

539 out("Using configuration from project root.", fg="blue") 

540 else: 

541 out(f"Using configuration in '{config}'.", fg="blue") 

542 if ctx.default_map: 

543 for param, value in ctx.default_map.items(): 

544 out(f"{param}: {value}") 

545 

546 report = Report(check=check, diff=not write, quiet=quiet, verbose=verbose) 

547 fixer_settings = FixerSettings( 

548 force_params=force_params, 

549 force_return=force_return, 

550 force_raises=force_raises, 

551 force_methods=force_methods, 

552 force_attributes=force_attributes, 

553 force_params_min_n_params=force_params_min_n_params, 

554 force_meta_min_func_length=force_meta_min_func_length, 

555 ignore_privates=ignore_privates, 

556 ignore_unused_arguments=ignore_unused_arguments, 

557 ignored_decorators=ignored_decorators, 

558 ignored_functions=ignored_functions, 

559 ignored_classes=ignored_classes, 

560 force_defaults=force_defaults, 

561 ) 

562 

563 run( 

564 src, 

565 overwrite=write, 

566 output_style=output_style, 

567 input_style=input_style, 

568 exclude=exclude or DEFAULT_EXCLUDES, 

569 extend_exclude=extend_exclude, 

570 report=report, 

571 fixer_settings=fixer_settings, 

572 ) 

573 

574 if verbose or not quiet: 

575 if verbose or report.change_count or report.failure_count: 

576 out() 

577 error_msg = "Oh no! 💥 💔 💥" 

578 out(error_msg if report.return_code else "All done! ✨ 🍰 ✨") 

579 click.echo(str(report), err=True) 

580 ctx.exit(report.return_code)