Coverage for pymend\pymendapp.py: 0%
127 statements
« prev ^ index » next coverage.py v7.3.2, created at 2024-04-20 19:09 +0200
« 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."""
4import platform
5import re
6import traceback
7from pathlib import Path
8from re import Pattern
9from typing import Any, Optional, Union
11import click
12from click.core import ParameterSource
14import pymend.docstring_parser as dsp
15from pymend import PyComment, __version__
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
23STRING_TO_STYLE = {
24 "rest": dsp.DocstringStyle.REST,
25 "javadoc": dsp.DocstringStyle.EPYDOC,
26 "numpydoc": dsp.DocstringStyle.NUMPYDOC,
27 "google": dsp.DocstringStyle.GOOGLE,
28}
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.
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
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))
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.
58 Parameters
59 ----------
60 style : str
61 String representation of the style to use.
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
73def re_compile_maybe_verbose(regex: str) -> Pattern[str]:
74 """Compile a regular expression string in `regex`.
76 If it contains newlines, use verbose mode.
78 Parameters
79 ----------
80 regex : str
81 Regex to compile.
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
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.
101 Parameters
102 ----------
103 value : Optional[str]
104 Regex pattern to validate.
106 Returns
107 -------
108 Optional[Pattern[str]]
109 Compiled regex pattern or None if the input was None.
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
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..
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.
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))
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`.
201 Returns the path to a successfully found and read configuration file, None
202 otherwise.
204 Parameters
205 ----------
206 ctx : click.Context
207 Context containing preexisting default values.
208 value : Optional[str]
209 Optionally path to the config file.
211 Returns
212 -------
213 Optional[str]
214 Path to the config file if one was found or specified.
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
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
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 }
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 )
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 )
260 default_map: dict[str, Any] = {}
261 if ctx.default_map:
262 default_map.update(ctx.default_map)
263 default_map.update(config)
265 ctx.default_map = default_map
266 return value
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)
529 if not src:
530 out(main.get_usage(ctx) + "\n\nError: Missing argument 'SRC ...'.")
531 ctx.exit(1)
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}")
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 )
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 )
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)