diff --git a/docs/command_line.md b/docs/command_line.md index 17d1234..1e31c34 100644 --- a/docs/command_line.md +++ b/docs/command_line.md @@ -3,6 +3,9 @@ The reference for docstub's command line interface. It uses [Click](https://click.palletsprojects.com/en/stable/), so [shell completion](https://click.palletsprojects.com/en/stable/shell-completion/) can be enabled. +Colored command line output can be disabled by [setting the environment variable `NO_COLOR=1`](https://no-color.org). + + ## `docstub` @@ -14,8 +17,10 @@ Usage: docstub [OPTIONS] COMMAND [ARGS]... Generate Python stub files from docstrings. Options: - --version Show the version and exit. - -h, --help Show this message and exit. + --version + Show the version and exit. + -h, --help + Show this message and exit. Commands: clean Clean the cache. @@ -35,35 +40,43 @@ Usage: docstub run [OPTIONS] PACKAGE_PATH Generate Python stub files. - Given a `PACKAGE_PATH` to a Python package, generate stub files for it. Type + Given a PACKAGE_PATH to a Python package, generate stub files for it. Type descriptions in docstrings will be used to fill in missing inline type annotations or to override them. Options: - -o, --out-dir PATH Set output directory explicitly. Stubs will be - directly written into that directory while preserving - the directory structure under `PACKAGE_PATH`. - Otherwise, stubs are generated inplace. - --config PATH Set one or more configuration file(s) explicitly. - Otherwise, it will look for a `pyproject.toml` or - `docstub.toml` in the current directory. - --ignore GLOB Ignore files matching this glob-style pattern. Can be - used multiple times. - --group-errors Group identical errors together and list where they - occurred. Will delay showing errors until all files - have been processed. Otherwise, simply report errors - as the occur. - --allow-errors INT Allow this many or fewer errors. If docstub reports - more, exit with error code '1'. This is useful to - adopt docstub gradually. [default: 0; x>=0] - -W, --fail-on-warning Return non-zero exit code when a warning is raised. - Will add to '--allow-errors'. - --no-cache Ignore pre-existing cache and don't create a new one. - -v, --verbose Print more details. Use once to show information - messages. Use -vv to print debug messages. - -q, --quiet Print less details. Use once to hide warnings. Use - -qq to completely silence output. - -h, --help Show this message and exit. + -o, --out-dir PATH + Set output directory explicitly. Stubs will be directly written into + that directory while preserving the directory structure under + PACKAGE_PATH. Otherwise, stubs are generated inplace. + --config PATH + Set one or more configuration file(s) explicitly. Otherwise, it will + look for a `pyproject.toml` or `docstub.toml` in the current + directory. + --ignore GLOB + Ignore files matching this glob-style pattern. Can be used multiple + times. + -g, --group-errors + Group identical errors together and list where they occurred. Will + delay showing errors until all files have been processed. Otherwise, + simply report errors as the occur. + --allow-errors INT + Allow this many or fewer errors. If docstub reports more, exit with + error code 1. This is useful to adopt docstub gradually. [default: + 0; x>=0] + -W, --fail-on-warning + Return non-zero exit code when a warning is raised. Will add to + --allow-errors. + --no-cache + Ignore pre-existing cache and don't create a new one. + -v, --verbose + Print more details. Use once to show information messages. Use -vv to + print debug messages. + -q, --quiet + Print less details. Use once to hide warnings. Use -qq to completely + silence output. + -h, --help + Show this message and exit. ``` @@ -83,11 +96,14 @@ Usage: docstub clean [OPTIONS] one exists, remove it. Options: - -v, --verbose Print more details. Use once to show information messages. - Use -vv to print debug messages. - -q, --quiet Print less details. Use once to hide warnings. Use -qq to - completely silence output. - -h, --help Show this message and exit. + -v, --verbose + Print more details. Use once to show information messages. Use -vv to + print debug messages. + -q, --quiet + Print less details. Use once to hide warnings. Use -qq to completely + silence output. + -h, --help + Show this message and exit. ``` diff --git a/pyproject.toml b/pyproject.toml index 34bb588..f5ea050 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,6 +104,7 @@ ignore = [ "PLR2004", # Magic value used in comparison "ISC001", # Conflicts with formatter "RET504", # Assignment before `return` statement facilitates debugging + "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` "PTH123", # Using builtin open() instead of Path.open() is fine "SIM108", # Terniary operator is always more readable "SIM103", # Don't recommend returning the condition directly diff --git a/src/docstub-stubs/_cli.pyi b/src/docstub-stubs/_cli.pyi index d098deb..cd9690a 100644 --- a/src/docstub-stubs/_cli.pyi +++ b/src/docstub-stubs/_cli.pyi @@ -11,9 +11,11 @@ from pathlib import Path from typing import Literal import click +from _typeshed import Incomplete from ._analysis import PyImport, TypeCollector, TypeMatcher, common_known_types from ._cache import CACHE_DIR_NAME, FileCache, validate_cache +from ._cli_help import HelpFormatter from ._config import Config from ._path_utils import ( STUB_HEADER_COMMENT, @@ -37,6 +39,9 @@ def _collect_type_info( ) -> tuple[dict[str, PyImport], dict[str, PyImport]]: ... def _format_unknown_names(names: Iterable[str]) -> str: ... def log_execution_time() -> None: ... + +click.Context.formatter_class = HelpFormatter + @click.group() def cli() -> None: ... def _add_verbosity_options(func: Callable) -> Callable: ... diff --git a/src/docstub-stubs/_cli_help.pyi b/src/docstub-stubs/_cli_help.pyi new file mode 100644 index 0000000..1d6cefb --- /dev/null +++ b/src/docstub-stubs/_cli_help.pyi @@ -0,0 +1,44 @@ +# File generated with docstub + +import logging +import os +import re +from collections.abc import Sequence +from typing import IO, Any, ClassVar + +import click +from click.formatting import iter_rows, measure_table, wrap_text + +logger: logging.Logger + +try: + from click._compat import should_strip_ansi as _click_should_strip_ansi + +except Exception: + + def _click_should_strip_ansi( + stream: IO[Any] | None = ..., color: bool | None = ... + ) -> bool: ... + +def should_strip_ansi( + stream: IO[Any] | None = ..., color: bool | None = ... +) -> bool: ... + +class HelpFormatter(click.formatting.HelpFormatter): + strip_ansi: bool + + rule_defs: ClassVar[dict[str, tuple[str, str]]] + + def __init__(self, *args: Any, **kwargs: Any) -> None: ... + def write_dl( + self, + rows: Sequence[tuple[str, str]], + *args: Any, + **kwargs: Any, + ) -> None: ... + def write_heading(self, heading: str) -> None: ... + def write_usage( + self, prog: str, args: str = ..., prefix: str | None = ... + ) -> None: ... + def _highlight_last(self, *, n: int, rules: list[str]) -> None: ... + def _highlight(self, string: str, *, rules: list[str]) -> str: ... diff --git a/src/docstub-stubs/_report.pyi b/src/docstub-stubs/_report.pyi index 2c16253..291f8cc 100644 --- a/src/docstub-stubs/_report.pyi +++ b/src/docstub-stubs/_report.pyi @@ -8,6 +8,8 @@ from typing import Any, ClassVar, Literal, Self, TextIO import click +from ._cli_help import should_strip_ansi + logger: logging.Logger @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) diff --git a/src/docstub/_cli.py b/src/docstub/_cli.py index 141c622..0c64e3a 100644 --- a/src/docstub/_cli.py +++ b/src/docstub/_cli.py @@ -15,6 +15,7 @@ common_known_types, ) from ._cache import CACHE_DIR_NAME, FileCache, validate_cache +from ._cli_help import HelpFormatter from ._config import Config from ._path_utils import ( STUB_HEADER_COMMENT, @@ -213,7 +214,11 @@ def log_execution_time(): logger.info("Finished in %s", formated_duration) +# Overwrite click's default formatter class (stubtest balks at this) # docstub: off +click.Context.formatter_class = HelpFormatter + + @click.group() # docstub: on @click.version_option(__version__) @@ -262,7 +267,7 @@ def _add_verbosity_options(func): metavar="PATH", help="Set output directory explicitly. " "Stubs will be directly written into that directory while preserving the directory " - "structure under `PACKAGE_PATH`. " + "structure under PACKAGE_PATH. " "Otherwise, stubs are generated inplace.", ) @click.option( @@ -283,6 +288,7 @@ def _add_verbosity_options(func): help="Ignore files matching this glob-style pattern. Can be used multiple times.", ) @click.option( + "-g", "--group-errors", is_flag=True, help="Group identical errors together and list where they occurred. " @@ -296,7 +302,7 @@ def _add_verbosity_options(func): show_default=True, metavar="INT", help="Allow this many or fewer errors. " - "If docstub reports more, exit with error code '1'. " + "If docstub reports more, exit with error code 1. " "This is useful to adopt docstub gradually. ", ) @click.option( @@ -304,7 +310,7 @@ def _add_verbosity_options(func): "--fail-on-warning", is_flag=True, help="Return non-zero exit code when a warning is raised. " - "Will add to '--allow-errors'.", + "Will add to --allow-errors.", ) @click.option( "--no-cache", @@ -329,7 +335,7 @@ def run( ): """Generate Python stub files. - Given a `PACKAGE_PATH` to a Python package, generate stub files for it. + Given a PACKAGE_PATH to a Python package, generate stub files for it. Type descriptions in docstrings will be used to fill in missing inline type annotations or to override them. \f @@ -456,7 +462,8 @@ def run( logger.warning("Syntax errors: %i", syntax_error_count) if unknown_type_names: logger.warning( - "Unknown type names: %i", + "Unknown type names: %i (locations: %i)", + len(set(unknown_type_names)), len(unknown_type_names), extra={"details": _format_unknown_names(unknown_type_names)}, ) diff --git a/src/docstub/_cli_help.py b/src/docstub/_cli_help.py new file mode 100644 index 0000000..ddc64a2 --- /dev/null +++ b/src/docstub/_cli_help.py @@ -0,0 +1,214 @@ +import logging +import os +import re + +import click +from click.formatting import iter_rows, measure_table, wrap_text + +logger: logging.Logger = logging.getLogger(__name__) + + +# Be defensive about using click's non-public `should_strip_ansi` +try: + from click._compat import ( + should_strip_ansi as _click_should_strip_ansi, + ) + +except Exception: + logger.exception("Unexpected error while using click's `should_strip_ansi`") + + def _click_should_strip_ansi(stream=None, color=None): + """ + Parameters + ---------- + stream : IO[Any], optional + color : bool, optional + + Returns + ------- + should_strip : bool + """ + return True + + +def should_strip_ansi(stream=None, color=None): + """ + + Parameters + ---------- + stream : IO[Any], optional + color : bool, optional + + Returns + ------- + should_strip : bool + """ + # Respect https://no-color.org + NO_COLOR_ENV = os.getenv("NO_COLOR", "").lower() not in ("0", "false", "no", "") + return NO_COLOR_ENV or _click_should_strip_ansi(stream, color) + + +class HelpFormatter(click.formatting.HelpFormatter): + """Custom help formatter for click. + + Attributes + ---------- + rule_defs : ClassVar[dict of {str: tuple[str, str]}] + strip_ansi : bool + Defaults to :func:`should_strip_ansi()`. + + Examples + -------- + To use this formatter with click: + + >>> import click + >>> click.Context.formatter_class = HelpFormatter # doctest: +SKIP + """ + + rule_defs = { + "dl-command": ( + r"(\n |^ )(\w[\w-]+)(?= )", + r"\1" + click.style(r"\g<2>", bold=True, fg="magenta"), + ), + "short-opt": (r" -\w+\b", click.style(r"\g<0>", bold=True, fg="red")), + "long-opt": (r" --\w[\w-]+\b", click.style(r"\g<0>", bold=True, fg="magenta")), + "dl-opt-arg": (r"(?", fg="magenta")), + "heading": (r"[^:]+:", click.style(r"\g<0>", bold=True)), + } + + def __init__(self, *args, **kwargs): + """ + Parameters + ---------- + *args, **kwargs : Any + """ + super().__init__(*args, **kwargs) + self.strip_ansi = should_strip_ansi() + + def write_dl( + self, + rows, + *args, + **kwargs, + ): + """Print definition list. + + Parameters + ---------- + rows : Sequence[tuple[str, str]] + *args, **kwargs : Any + """ + if not rows[0][0].strip().startswith("-"): + dl_start = len(self.buffer) + super().write_dl(rows, *args, **kwargs) + self._highlight_last( + n=len(self.buffer) - dl_start, + rules=["dl-command"], + ) + return + + # Add intend so options like "-v, --verbose" and "--config" are aligned + # on the "--" + rows = [ + (f" {key}" if key.lstrip().startswith("--") else key, value) + for key, value in rows + ] + + rows = list(rows) + widths = measure_table(rows) + if len(widths) != 2: + raise TypeError("Expected two columns for definition list") + + for first, second in iter_rows(rows, len(widths)): + self.write(f"{'':>{self.current_indent}}{first}") + self._highlight_last(n=1, rules=["short-opt", "long-opt", "dl-opt-arg"]) + if not second: + self.write("\n") + continue + + self.write("\n") + self.write(" " * (8 + self.current_indent)) + + text_width = max(self.width - 8, 10) + wrapped_text = wrap_text(second, text_width, preserve_paragraphs=True) + lines = wrapped_text.splitlines() + + if lines: + self.write(f"{lines[0]}\n") + + for line in lines[1:]: + self.write(f"{'':>{8 + self.current_indent}}{line}\n") + else: + self.write("\n") + + def write_heading(self, heading): + """ + Parameters + ---------- + heading : str + """ + super().write_heading(heading) + if not self.strip_ansi: + self._highlight_last(n=1, rules=["heading"]) + + def write_usage(self, prog, args="", prefix=None): + """ + Parameters + ---------- + prog : str + args : str, optional + prefix : str, optional + """ + if prefix is None: + prefix = "Usage: " + + start = len(self.buffer) + super().write_usage(prog, args, prefix=prefix) + + re_prog = re.escape(prog).replace(r"\ ", r"\s+") + re_args = re.escape(args).replace(r"\ ", r"\s+") + re_prefix = re.escape(prefix).replace(r"\ ", r"\s+") + + self._highlight_last( + n=len(self.buffer) - start, + rules=[ + (re_prog, click.style(r"\g<0>", bold=True, fg="magenta")), + (re_args, click.style(r"\g<0>", fg="magenta")), + (re_prefix, click.style(r"\g<0>", bold=True)), + ], + ) + + def _highlight_last(self, *, n, rules): + """Highlight the last `n` elements in the buffer according to `rules`. + + Parameters + ---------- + n : int + rules : list[str] + """ + last = [self.buffer.pop() for _ in range(n)][::-1] + string = "".join(last) + string = self._highlight(string, rules=rules) + self.buffer.append(string) + + def _highlight(self, string, *, rules): + """Highlight `string` according to `rules`. + + Parameters + ---------- + string : str + rules : list[str] + + Returns + ------- + string : str + """ + if self.strip_ansi: + return string + + rules = ( + self.rule_defs[rule] if isinstance(rule, str) else rule for rule in rules + ) + for pattern, substitute in rules: + string = re.sub(pattern, substitute, string, flags=re.DOTALL) + return string diff --git a/src/docstub/_report.py b/src/docstub/_report.py index e2c377d..541bdc7 100644 --- a/src/docstub/_report.py +++ b/src/docstub/_report.py @@ -7,6 +7,8 @@ import click +from ._cli_help import should_strip_ansi + logger: logging.Logger = logging.getLogger(__name__) @@ -189,7 +191,7 @@ class ReportHandler(logging.StreamHandler): level_to_color : ClassVar[dict[int, str]] """ - level_to_color = { # noqa: RUF012 + level_to_color = { logging.DEBUG: "bright_black", logging.INFO: "cyan", logging.WARNING: "yellow", @@ -212,14 +214,7 @@ def __init__(self, stream=None, group_errors=False): self.error_count = 0 self.warning_count = 0 - # Be defensive about using click's non-public `should_strip_ansi` - try: - from click._compat import should_strip_ansi # noqa: PLC0415 - - self.strip_ansi = should_strip_ansi(self.stream) - except Exception: - self.strip_ansi = True - logger.exception("Unexpected error while using click's `should_strip_ansi`") + self.strip_ansi = should_strip_ansi(self.stream) def format(self, record): """Format a log record. diff --git a/tests/test_analysis.py b/tests/test_analysis.py index 11bdbbf..4dfde37 100644 --- a/tests/test_analysis.py +++ b/tests/test_analysis.py @@ -164,12 +164,12 @@ def test_imports(self, module_factory): class Test_TypeMatcher: - type_prefixes = { # noqa: RUF012 + type_prefixes = { "np": PyImport(import_="numpy", as_="np"), "foo.bar.Baz": PyImport(from_="foo.bar", import_="Baz"), } - types = { # noqa: RUF012 + types = { "dict": PyImport(implicit="dict"), "foo.bar": PyImport(from_="foo", import_="bar"), "foo.bar.Baz": PyImport(from_="foo.bar", import_="Baz"), diff --git a/tests/test_cli_help.py b/tests/test_cli_help.py new file mode 100644 index 0000000..345be9a --- /dev/null +++ b/tests/test_cli_help.py @@ -0,0 +1,80 @@ +from functools import partial + +import click + +from docstub._cli_help import HelpFormatter + +bold = partial(click.style, bold=True) +bold_red = partial(click.style, bold=True, fg="red") +bold_magenta = partial(click.style, bold=True, fg="magenta") +magenta = partial(click.style, fg="magenta") + + +class Test_HelpFormatter: + def test_write_dl_options(self): + formatter = HelpFormatter() + formatter.strip_ansi = False + rows = [ + ("-v, --verbose", "verbose option -vv "), + ("--out-file PATH", "file to write to. this-should-not-be-colored"), + ] + formatter.current_indent = 2 + formatter.write_dl(rows) + dl = formatter.getvalue() + + assert click.unstyle(dl) == ( + " -v, --verbose\n" + " verbose option -vv\n" + " --out-file PATH\n" + " file to write to. this-should-not-be-colored\n" + ) + assert dl == ( + f" {bold_red(' -v')},{bold_magenta(' --verbose')}\n" + " verbose option -vv\n" + f" {bold_magenta(' --out-file')} {magenta('PATH')}\n" + " file to write to. this-should-not-be-colored\n" + ) + + def test_write_dl_commands(self): + formatter = HelpFormatter() + formatter.strip_ansi = False + rows = [ + ("run", "Run something"), + ("clean-cache", "Remove the cache"), + ] + formatter.current_indent = 2 + formatter.write_dl(rows) + dl = formatter.getvalue() + + assert click.unstyle(dl) == ( + " run Run something\n" + " clean-cache Remove the cache\n" + ) # fmt: skip + assert dl == ( + f" {bold_magenta('run')} Run something\n" + f" {bold_magenta('clean-cache')} Remove the cache\n" + ) + + def test_heading(self): + formatter = HelpFormatter() + formatter.strip_ansi = False + formatter.write_heading("Other options") + heading = formatter.getvalue() + assert click.unstyle(heading) == "Other options:\n" + assert heading == bold("Other options:") + "\n" + + def test_usage(self): + formatter = HelpFormatter() + formatter.strip_ansi = False + formatter.write_usage( + prog="some command", args="[OPTIONS] COMMAND [ARGS]", prefix="Benutzung: " + ) + usage = formatter.getvalue() + assert ( + click.unstyle(usage) == "Benutzung: some command [OPTIONS] COMMAND [ARGS]\n" + ) + assert usage == ( + f"{bold('Benutzung: ')}" + f"{bold_magenta('some command')} " + f"{magenta('[OPTIONS] COMMAND [ARGS]')}\n" + )