Coverage for pymend\pymendapp.py: 0%

1#!/usr/bin/python

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

3

4 import platform

5 import re

6 import traceback

7 from pathlib import Path

8 from re import Pattern

9 from typing import Any, Optional, Union

10

11 import click

12 from click.core import ParameterSource

13

14 import pymend.docstring_parser as dsp

15 from pymend import PyComment, __version__

16

17 from .const import DEFAULT_EXCLUDES

18 from .files import find_pyproject_toml, parse_pyproject_toml

19 from .output import out

20 from .report import Report

21 from .types import FixerSettings

22

23 STRING_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

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

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

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

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

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

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

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