X-Git-Url: https://git.madduck.net/etc/vim.git/blobdiff_plain/6fa60ba39d137bff08a7aa78beffa4773eb24121..4809e365d43ff5f837f9dacbc1f3d04975fe35e8:/black.py?ds=inline diff --git a/black.py b/black.py index 61b884a..fd0364b 100644 --- a/black.py +++ b/black.py @@ -1,23 +1,27 @@ import asyncio -import pickle from asyncio.base_events import BaseEventLoop from concurrent.futures import Executor, ProcessPoolExecutor +from datetime import datetime from enum import Enum, Flag -from functools import partial, wraps +from functools import lru_cache, partial, wraps +import io +import itertools import keyword import logging from multiprocessing import Manager import os from pathlib import Path +import pickle import re -import tokenize import signal import sys +import tokenize from typing import ( Any, Callable, Collection, Dict, + Generator, Generic, Iterable, Iterator, @@ -27,7 +31,6 @@ from typing import ( Sequence, Set, Tuple, - Type, TypeVar, Union, cast, @@ -36,6 +39,7 @@ from typing import ( from appdirs import user_cache_dir from attr import dataclass, Factory import click +import toml # lib2to3 fork from blib2to3.pytree import Node, Leaf, type_repr @@ -44,10 +48,10 @@ from blib2to3.pgen2 import driver, token from blib2to3.pgen2.parse import ParseError -__version__ = "18.5b1" +__version__ = "18.9b0" DEFAULT_LINE_LENGTH = 88 DEFAULT_EXCLUDES = ( - r"/(\.git|\.hg|\.mypy_cache|\.tox|\.venv|_build|buck-out|build|dist)/" + r"/(\.git|\.hg|\.mypy_cache|\.nox|\.tox|\.venv|_build|buck-out|build|dist)/" ) DEFAULT_INCLUDES = r"\.pyi?$" CACHE_DIR = Path(user_cache_dir("black", version=__version__)) @@ -56,6 +60,7 @@ CACHE_DIR = Path(user_cache_dir("black", version=__version__)) # types FileContent = str Encoding = str +NewLine = str Depth = int NodeType = int LeafID = int @@ -75,49 +80,29 @@ syms = pygram.python_symbols class NothingChanged(UserWarning): - """Raised by :func:`format_file` when reformatted code is the same as source.""" + """Raised when reformatted code is the same as source.""" class CannotSplit(Exception): - """A readable split that fits the allotted line length is impossible. - - Raised by :func:`left_hand_split`, :func:`right_hand_split`, and - :func:`delimiter_split`. - """ - - -class FormatError(Exception): - """Base exception for `# fmt: on` and `# fmt: off` handling. - - It holds the number of bytes of the prefix consumed before the format - control comment appeared. - """ - - def __init__(self, consumed: int) -> None: - super().__init__(consumed) - self.consumed = consumed - - def trim_prefix(self, leaf: Leaf) -> None: - leaf.prefix = leaf.prefix[self.consumed :] - - def leaf_from_consumed(self, leaf: Leaf) -> Leaf: - """Returns a new Leaf from the consumed part of the prefix.""" - unformatted_prefix = leaf.prefix[: self.consumed] - return Leaf(token.NEWLINE, unformatted_prefix) + """A readable split that fits the allotted line length is impossible.""" -class FormatOn(FormatError): - """Found a comment like `# fmt: on` in the file.""" - - -class FormatOff(FormatError): - """Found a comment like `# fmt: off` in the file.""" +class InvalidInput(ValueError): + """Raised when input source code fails all parse attempts.""" class WriteBack(Enum): NO = 0 YES = 1 DIFF = 2 + CHECK = 3 + + @classmethod + def from_configuration(cls, *, check: bool, diff: bool) -> "WriteBack": + if check and not diff: + return cls.CHECK + + return cls.DIFF if diff else cls.YES class Changed(Enum): @@ -131,67 +116,119 @@ class FileMode(Flag): PYTHON36 = 1 PYI = 2 NO_STRING_NORMALIZATION = 4 + NO_NUMERIC_UNDERSCORE_NORMALIZATION = 8 + + @classmethod + def from_configuration( + cls, + *, + py36: bool, + pyi: bool, + skip_string_normalization: bool, + skip_numeric_underscore_normalization: bool, + ) -> "FileMode": + mode = cls.AUTO_DETECT + if py36: + mode |= cls.PYTHON36 + if pyi: + mode |= cls.PYI + if skip_string_normalization: + mode |= cls.NO_STRING_NORMALIZATION + if skip_numeric_underscore_normalization: + mode |= cls.NO_NUMERIC_UNDERSCORE_NORMALIZATION + return mode + + +def read_pyproject_toml( + ctx: click.Context, param: click.Parameter, value: Union[str, int, bool, None] +) -> Optional[str]: + """Inject Black configuration from "pyproject.toml" into defaults in `ctx`. + + Returns the path to a successfully found and read configuration file, None + otherwise. + """ + assert not isinstance(value, (int, bool)), "Invalid parameter type passed" + if not value: + root = find_project_root(ctx.params.get("src", ())) + path = root / "pyproject.toml" + if path.is_file(): + value = str(path) + else: + return None + + try: + pyproject_toml = toml.load(value) + config = pyproject_toml.get("tool", {}).get("black", {}) + except (toml.TomlDecodeError, OSError) as e: + raise click.BadOptionUsage(f"Error reading configuration file: {e}", ctx) + + if not config: + return None + + if ctx.default_map is None: + ctx.default_map = {} + ctx.default_map.update( # type: ignore # bad types in .pyi + {k.replace("--", "").replace("-", "_"): v for k, v in config.items()} + ) + return value -@click.command() +@click.command(context_settings=dict(help_option_names=["-h", "--help"])) @click.option( "-l", "--line-length", type=int, default=DEFAULT_LINE_LENGTH, - help="How many character per line to allow.", + help="How many characters per line to allow.", show_default=True, ) @click.option( - "--check", + "--py36", is_flag=True, help=( - "Don't write the files back, just return the status. Return code 0 " - "means nothing would change. Return code 1 means some files would be " - "reformatted. Return code 123 means there was an internal error." + "Allow using Python 3.6-only syntax on all input files. This will put " + "trailing commas in function signatures and calls also after *args and " + "**kwargs. [default: per-file auto-detection]" ), ) @click.option( - "--diff", + "--pyi", is_flag=True, - help="Don't write the files back, just output a diff for each file on stdout.", + help=( + "Format all input files like typing stubs regardless of file extension " + "(useful when piping source on standard input)." + ), ) @click.option( - "--fast/--safe", + "-S", + "--skip-string-normalization", is_flag=True, - help="If --fast given, skip temporary sanity checks. [default: --safe]", + help="Don't normalize string quotes or prefixes.", ) @click.option( - "-q", - "--quiet", + "-N", + "--skip-numeric-underscore-normalization", is_flag=True, - help=( - "Don't emit non-error messages to stderr. Errors are still emitted, " - "silence those with 2>/dev/null." - ), + help="Don't normalize underscores in numeric literals.", ) @click.option( - "--pyi", + "--check", is_flag=True, help=( - "Consider all input files typing stubs regardless of file extension " - "(useful when piping source on standard input)." + "Don't write the files back, just return the status. Return code 0 " + "means nothing would change. Return code 1 means some files would be " + "reformatted. Return code 123 means there was an internal error." ), ) @click.option( - "--py36", + "--diff", is_flag=True, - help=( - "Allow using Python 3.6-only syntax on all input files. This will put " - "trailing commas in function signatures and calls also after *args and " - "**kwargs. [default: per-file auto-detection]" - ), + help="Don't write the files back, just output a diff for each file on stdout.", ) @click.option( - "-S", - "--skip-string-normalization", + "--fast/--safe", is_flag=True, - help="Don't normalize string quotes or prefixes.", + help="If --fast given, skip temporary sanity checks. [default: --safe]", ) @click.option( "--include", @@ -199,8 +236,10 @@ class FileMode(Flag): default=DEFAULT_INCLUDES, help=( "A regular expression that matches files and directories that should be " - "included on recursive searches. On Windows, use forward slashes for " - "directories." + "included on recursive searches. An empty value means all files are " + "included regardless of the name. Use forward slashes for directories on " + "all platforms (Windows, too). Exclusions are calculated first, inclusions " + "later." ), show_default=True, ) @@ -210,11 +249,30 @@ class FileMode(Flag): default=DEFAULT_EXCLUDES, help=( "A regular expression that matches files and directories that should be " - "excluded on recursive searches. On Windows, use forward slashes for " - "directories." + "excluded on recursive searches. An empty value means no paths are excluded. " + "Use forward slashes for directories on all platforms (Windows, too). " + "Exclusions are calculated first, inclusions later." ), show_default=True, ) +@click.option( + "-q", + "--quiet", + is_flag=True, + help=( + "Don't emit non-error messages to stderr. Errors are still emitted, " + "silence those with 2>/dev/null." + ), +) +@click.option( + "-v", + "--verbose", + is_flag=True, + help=( + "Also emit messages to stderr about files that were not changed or were " + "ignored due to --exclude=." + ), +) @click.version_option(version=__version__) @click.argument( "src", @@ -222,6 +280,16 @@ class FileMode(Flag): type=click.Path( exists=True, file_okay=True, dir_okay=True, readable=True, allow_dash=True ), + is_eager=True, +) +@click.option( + "--config", + type=click.Path( + exists=False, file_okay=True, dir_okay=False, readable=True, allow_dash=False + ), + is_eager=True, + callback=read_pyproject_toml, + help="Read configuration from PATH.", ) @click.pass_context def main( @@ -233,57 +301,56 @@ def main( pyi: bool, py36: bool, skip_string_normalization: bool, + skip_numeric_underscore_normalization: bool, quiet: bool, + verbose: bool, include: str, exclude: str, - src: List[str], + src: Tuple[str], + config: Optional[str], ) -> None: """The uncompromising code formatter.""" - sources: List[Path] = [] + write_back = WriteBack.from_configuration(check=check, diff=diff) + mode = FileMode.from_configuration( + py36=py36, + pyi=pyi, + skip_string_normalization=skip_string_normalization, + skip_numeric_underscore_normalization=skip_numeric_underscore_normalization, + ) + if config and verbose: + out(f"Using configuration from {config}.", bold=False, fg="blue") try: - include_regex = re.compile(include) + include_regex = re_compile_maybe_verbose(include) except re.error: err(f"Invalid regular expression for include given: {include!r}") ctx.exit(2) try: - exclude_regex = re.compile(exclude) + exclude_regex = re_compile_maybe_verbose(exclude) except re.error: err(f"Invalid regular expression for exclude given: {exclude!r}") ctx.exit(2) + report = Report(check=check, quiet=quiet, verbose=verbose) + root = find_project_root(src) + sources: Set[Path] = set() for s in src: p = Path(s) if p.is_dir(): - sources.extend(gen_python_files_in_dir(p, include_regex, exclude_regex)) - elif p.is_file(): + sources.update( + gen_python_files_in_dir(p, root, include_regex, exclude_regex, report) + ) + elif p.is_file() or s == "-": # if a file was explicitly given, we don't care about its extension - sources.append(p) - elif s == "-": - sources.append(Path("-")) + sources.add(p) else: err(f"invalid path: {s}") - - if check and not diff: - write_back = WriteBack.NO - elif diff: - write_back = WriteBack.DIFF - else: - write_back = WriteBack.YES - mode = FileMode.AUTO_DETECT - if py36: - mode |= FileMode.PYTHON36 - if pyi: - mode |= FileMode.PYI - if skip_string_normalization: - mode |= FileMode.NO_STRING_NORMALIZATION - report = Report(check=check, quiet=quiet) if len(sources) == 0: - out("No paths given. Nothing to do 😴") + if verbose or not quiet: + out("No paths given. Nothing to do 😴") ctx.exit(0) - return - elif len(sources) == 1: + if len(sources) == 1: reformat_one( - src=sources[0], + src=sources.pop(), line_length=line_length, fast=fast, write_back=write_back, @@ -308,9 +375,10 @@ def main( ) finally: shutdown(loop) - if not quiet: - out("All done! ✨ 🍰 ✨") - click.echo(str(report)) + if verbose or not quiet: + bang = "💥 💔 💥" if report.return_code else "✨ 🍰 ✨" + out(f"All done! {bang}") + click.secho(str(report), err=True) ctx.exit(report.return_code) @@ -339,8 +407,8 @@ def reformat_one( cache: Cache = {} if write_back != WriteBack.DIFF: cache = read_cache(line_length, mode) - src = src.resolve() - if src in cache and cache[src] == get_cache_info(src): + res_src = src.resolve() + if res_src in cache and cache[res_src] == get_cache_info(res_src): changed = Changed.CACHED if changed is not Changed.CACHED and format_file_in_place( src, @@ -350,7 +418,9 @@ def reformat_one( mode=mode, ): changed = Changed.YES - if write_back == WriteBack.YES and changed is not Changed.NO: + if (write_back is WriteBack.YES and changed is not Changed.CACHED) or ( + write_back is WriteBack.CHECK and changed is Changed.NO + ): write_cache(cache, [src], line_length, mode) report.done(src, changed) except Exception as exc: @@ -358,7 +428,7 @@ def reformat_one( async def schedule_formatting( - sources: List[Path], + sources: Set[Path], line_length: int, fast: bool, write_back: WriteBack, @@ -378,52 +448,60 @@ async def schedule_formatting( if write_back != WriteBack.DIFF: cache = read_cache(line_length, mode) sources, cached = filter_cached(cache, sources) - for src in cached: + for src in sorted(cached): report.done(src, Changed.CACHED) + if not sources: + return + cancelled = [] - formatted = [] - if sources: - lock = None - if write_back == WriteBack.DIFF: - # For diff output, we need locks to ensure we don't interleave output - # from different processes. - manager = Manager() - lock = manager.Lock() - tasks = { - loop.run_in_executor( - executor, - format_file_in_place, - src, - line_length, - fast, - write_back, - mode, - lock, - ): src - for src in sorted(sources) - } - pending: Iterable[asyncio.Task] = tasks.keys() - try: - loop.add_signal_handler(signal.SIGINT, cancel, pending) - loop.add_signal_handler(signal.SIGTERM, cancel, pending) - except NotImplementedError: - # There are no good alternatives for these on Windows - pass - while pending: - done, _ = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED) - for task in done: - src = tasks.pop(task) - if task.cancelled(): - cancelled.append(task) - elif task.exception(): - report.failed(src, str(task.exception())) - else: - formatted.append(src) - report.done(src, Changed.YES if task.result() else Changed.NO) + sources_to_cache = [] + lock = None + if write_back == WriteBack.DIFF: + # For diff output, we need locks to ensure we don't interleave output + # from different processes. + manager = Manager() + lock = manager.Lock() + tasks = { + loop.run_in_executor( + executor, + format_file_in_place, + src, + line_length, + fast, + write_back, + mode, + lock, + ): src + for src in sorted(sources) + } + pending: Iterable[asyncio.Task] = tasks.keys() + try: + loop.add_signal_handler(signal.SIGINT, cancel, pending) + loop.add_signal_handler(signal.SIGTERM, cancel, pending) + except NotImplementedError: + # There are no good alternatives for these on Windows. + pass + while pending: + done, _ = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED) + for task in done: + src = tasks.pop(task) + if task.cancelled(): + cancelled.append(task) + elif task.exception(): + report.failed(src, str(task.exception())) + else: + changed = Changed.YES if task.result() else Changed.NO + # If the file was written back or was successfully checked as + # well-formatted, store this information in the cache. + if write_back is WriteBack.YES or ( + write_back is WriteBack.CHECK and changed is Changed.NO + ): + sources_to_cache.append(src) + report.done(src, changed) if cancelled: await asyncio.gather(*cancelled, loop=loop, return_exceptions=True) - if write_back == WriteBack.YES and formatted: - write_cache(cache, formatted, line_length, mode) + if sources_to_cache: + write_cache(cache, sources_to_cache, line_length, mode) def format_file_in_place( @@ -436,13 +514,16 @@ def format_file_in_place( ) -> bool: """Format file under `src` path. Return True if changed. - If `write_back` is True, write reformatted code back to stdout. + If `write_back` is DIFF, write a diff to stdout. If it is YES, write reformatted + code to the file. `line_length` and `fast` options are passed to :func:`format_file_contents`. """ if src.suffix == ".pyi": mode |= FileMode.PYI - with tokenize.open(src) as src_buffer: - src_contents = src_buffer.read() + + then = datetime.utcfromtimestamp(src.stat().st_mtime) + with open(src, "rb") as buf: + src_contents, encoding, newline = decode_bytes(buf.read()) try: dst_contents = format_file_contents( src_contents, line_length=line_length, fast=fast, mode=mode @@ -451,16 +532,24 @@ def format_file_in_place( return False if write_back == write_back.YES: - with open(src, "w", encoding=src_buffer.encoding) as f: + with open(src, "w", encoding=encoding, newline=newline) as f: f.write(dst_contents) elif write_back == write_back.DIFF: - src_name = f"{src} (original)" - dst_name = f"{src} (formatted)" + now = datetime.utcnow() + src_name = f"{src}\t{then} +0000" + dst_name = f"{src}\t{now} +0000" diff_contents = diff(src_contents, dst_contents, src_name, dst_name) if lock: lock.acquire() try: - sys.stdout.write(diff_contents) + f = io.TextIOWrapper( + sys.stdout.buffer, + encoding=encoding, + newline=newline, + write_through=True, + ) + f.write(diff_contents) + f.detach() finally: if lock: lock.release() @@ -475,11 +564,13 @@ def format_stdin_to_stdout( ) -> bool: """Format file on stdin. Return True if changed. - If `write_back` is True, write reformatted code back to stdout. + If `write_back` is YES, write reformatted code back to stdout. If it is DIFF, + write a diff to stdout. `line_length`, `fast`, `is_pyi`, and `force_py36` arguments are passed to :func:`format_file_contents`. """ - src = sys.stdin.read() + then = datetime.utcnow() + src, encoding, newline = decode_bytes(sys.stdin.buffer.read()) dst = src try: dst = format_file_contents(src, line_length=line_length, fast=fast, mode=mode) @@ -489,12 +580,17 @@ def format_stdin_to_stdout( return False finally: + f = io.TextIOWrapper( + sys.stdout.buffer, encoding=encoding, newline=newline, write_through=True + ) if write_back == WriteBack.YES: - sys.stdout.write(dst) + f.write(dst) elif write_back == WriteBack.DIFF: - src_name = " (original)" - dst_name = " (formatted)" - sys.stdout.write(diff(src, dst, src_name, dst_name)) + now = datetime.utcnow() + src_name = f"STDIN\t{then} +0000" + dst_name = f"STDOUT\t{now} +0000" + f.write(diff(src, dst, src_name, dst_name)) + f.detach() def format_file_contents( @@ -530,16 +626,19 @@ def format_str( `line_length` determines how many characters per line are allowed. """ - src_node = lib2to3_parse(src_contents) + src_node = lib2to3_parse(src_contents.lstrip()) dst_contents = "" future_imports = get_future_imports(src_node) is_pyi = bool(mode & FileMode.PYI) py36 = bool(mode & FileMode.PYTHON36) or is_python36(src_node) normalize_strings = not bool(mode & FileMode.NO_STRING_NORMALIZATION) + normalize_fmt_off(src_node) lines = LineGenerator( remove_u_prefix=py36 or "unicode_literals" in future_imports, is_pyi=is_pyi, normalize_strings=normalize_strings, + allow_underscores=py36 + and not bool(mode & FileMode.NO_NUMERIC_UNDERSCORE_NORMALIZATION), ) elt = EmptyLineTracker(is_pyi=is_pyi) empty_line = Line() @@ -555,6 +654,23 @@ def format_str( return dst_contents +def decode_bytes(src: bytes) -> Tuple[FileContent, Encoding, NewLine]: + """Return a tuple of (decoded_contents, encoding, newline). + + `newline` is either CRLF or LF but `decoded_contents` is decoded with + universal newlines (i.e. only contains LF). + """ + srcbuf = io.BytesIO(src) + encoding, lines = tokenize.detect_encoding(srcbuf.readline) + if not lines: + return "", encoding, "\n" + + newline = "\r\n" if b"\r\n" == lines[0][-2:] else "\n" + srcbuf.seek(0) + with io.TextIOWrapper(srcbuf, encoding) as tiow: + return tiow.read(), encoding, newline + + GRAMMARS = [ pygram.python_grammar_no_print_statement_no_exec_statement, pygram.python_grammar_no_print_statement, @@ -564,10 +680,8 @@ GRAMMARS = [ def lib2to3_parse(src_txt: str) -> Node: """Given a string with source, return the lib2to3 Node.""" - grammar = pygram.python_grammar_no_print_statement - if src_txt[-1] != "\n": - nl = "\r\n" if "\r\n" in src_txt[:1024] else "\n" - src_txt += nl + if src_txt[-1:] != "\n": + src_txt += "\n" for grammar in GRAMMARS: drv = driver.Driver(grammar, pytree.convert) try: @@ -581,7 +695,7 @@ def lib2to3_parse(src_txt: str) -> Node: faulty_line = lines[lineno - 1] except IndexError: faulty_line = "" - exc = ValueError(f"Cannot parse: {lineno}:{column}: {faulty_line}") + exc = InvalidInput(f"Cannot parse: {lineno}:{column}: {faulty_line}") else: raise exc from None @@ -650,13 +764,15 @@ class DebugVisitor(Visitor[T]): out(f" {node.value!r}", fg="blue", bold=False) @classmethod - def show(cls, code: str) -> None: + def show(cls, code: Union[str, Leaf, Node]) -> None: """Pretty-print the lib2to3 AST of a given string of `code`. Convenience method for debugging. """ v: DebugVisitor[None] = DebugVisitor() - list(v.visit(lib2to3_parse(code))) + if isinstance(code, str): + code = lib2to3_parse(code) + list(v.visit(code)) KEYWORDS = set(keyword.kwlist) @@ -673,6 +789,7 @@ STATEMENT = { syms.classdef, } STANDALONE_COMMENT = 153 +token.tok_name[STANDALONE_COMMENT] = "STANDALONE_COMMENT" LOGIC_OPERATORS = {"and", "or"} COMPARATORS = { token.LESS, @@ -711,6 +828,7 @@ UNPACKING_PARENTS = { syms.dictsetmaker, syms.listmaker, syms.testlist_gexp, + syms.testlist_star_expr, } TEST_DESCENDANTS = { syms.test, @@ -778,8 +896,8 @@ class BracketTracker: bracket_match: Dict[Tuple[Depth, NodeType], Leaf] = Factory(dict) delimiters: Dict[LeafID, Priority] = Factory(dict) previous: Optional[Leaf] = None - _for_loop_variable: int = 0 - _lambda_arguments: int = 0 + _for_loop_depths: List[int] = Factory(list) + _lambda_argument_depths: List[int] = Factory(list) def mark(self, leaf: Leaf) -> None: """Mark `leaf` with bracket-related metadata. Keep track of delimiters. @@ -852,16 +970,21 @@ class BracketTracker: """ if leaf.type == token.NAME and leaf.value == "for": self.depth += 1 - self._for_loop_variable += 1 + self._for_loop_depths.append(self.depth) return True return False def maybe_decrement_after_for_loop_variable(self, leaf: Leaf) -> bool: """See `maybe_increment_for_loop_variable` above for explanation.""" - if self._for_loop_variable and leaf.type == token.NAME and leaf.value == "in": + if ( + self._for_loop_depths + and self._for_loop_depths[-1] == self.depth + and leaf.type == token.NAME + and leaf.value == "in" + ): self.depth -= 1 - self._for_loop_variable -= 1 + self._for_loop_depths.pop() return True return False @@ -874,16 +997,20 @@ class BracketTracker: """ if leaf.type == token.NAME and leaf.value == "lambda": self.depth += 1 - self._lambda_arguments += 1 + self._lambda_argument_depths.append(self.depth) return True return False def maybe_decrement_after_lambda_arguments(self, leaf: Leaf) -> bool: """See `maybe_increment_lambda_arguments` above for explanation.""" - if self._lambda_arguments and leaf.type == token.COLON: + if ( + self._lambda_argument_depths + and self._lambda_argument_depths[-1] == self.depth + and leaf.type == token.COLON + ): self.depth -= 1 - self._lambda_arguments -= 1 + self._lambda_argument_depths.pop() return True return False @@ -899,7 +1026,9 @@ class Line: depth: int = 0 leaves: List[Leaf] = Factory(list) - comments: List[Tuple[Index, Leaf]] = Factory(list) + # The LeafID keys of comments must remain ordered by the corresponding leaf's index + # in leaves + comments: Dict[LeafID, List[Leaf]] = Factory(dict) bracket_tracker: BracketTracker = Factory(BracketTracker) inside_brackets: bool = False should_explode: bool = False @@ -1033,6 +1162,13 @@ class Line: return False + def contains_multiline_strings(self) -> bool: + for leaf in self.leaves: + if is_multiline_string(leaf): + return True + + return False + def maybe_remove_trailing_comma(self, closing: Leaf) -> bool: """Remove trailing comma if there is one and it's safe.""" if not ( @@ -1058,7 +1194,7 @@ class Line: self.remove_trailing_comma() return True - # Otheriwsse, if the trailing one is the only one, we might mistakenly + # Otherwise, if the trailing one is the only one, we might mistakenly # change a tuple into a different type by removing the comma. depth = closing.bracket_depth + 1 commas = 0 @@ -1099,56 +1235,51 @@ class Line: if comment.type != token.COMMENT: return False - after = len(self.leaves) - 1 - if after == -1: + if not self.leaves: comment.type = STANDALONE_COMMENT comment.prefix = "" return False else: - self.comments.append((after, comment)) - return True - - def comments_after(self, leaf: Leaf, _index: int = -1) -> Iterator[Leaf]: - """Generate comments that should appear directly after `leaf`. - - Provide a non-negative leaf `_index` to speed up the function. - """ - if _index == -1: - for _index, _leaf in enumerate(self.leaves): - if leaf is _leaf: - break - + leaf_id = id(self.leaves[-1]) + if leaf_id not in self.comments: + self.comments[leaf_id] = [comment] else: - return + self.comments[leaf_id].append(comment) + return True - for index, comment_after in self.comments: - if _index == index: - yield comment_after + def comments_after(self, leaf: Leaf) -> List[Leaf]: + """Generate comments that should appear directly after `leaf`.""" + return self.comments.get(id(leaf), []) def remove_trailing_comma(self) -> None: """Remove the trailing comma and moves the comments attached to it.""" - comma_index = len(self.leaves) - 1 - for i in range(len(self.comments)): - comment_index, comment = self.comments[i] - if comment_index == comma_index: - self.comments[i] = (comma_index - 1, comment) + # Remember, the LeafID keys of self.comments are ordered by the + # corresponding leaf's index in self.leaves + # If id(self.leaves[-2]) is in self.comments, the order doesn't change. + # Otherwise, we insert it into self.comments, and it becomes the last entry. + # However, since we delete id(self.leaves[-1]) from self.comments, the invariant + # is maintained + self.comments.setdefault(id(self.leaves[-2]), []).extend( + self.comments.get(id(self.leaves[-1]), []) + ) + self.comments.pop(id(self.leaves[-1]), None) self.leaves.pop() def is_complex_subscript(self, leaf: Leaf) -> bool: """Return True iff `leaf` is part of a slice with non-trivial exprs.""" - open_lsqb = ( - leaf if leaf.type == token.LSQB else self.bracket_tracker.get_open_lsqb() - ) + open_lsqb = self.bracket_tracker.get_open_lsqb() if open_lsqb is None: return False subscript_start = open_lsqb.next_sibling - if ( - isinstance(subscript_start, Node) - and subscript_start.type == syms.subscriptlist - ): - subscript_start = child_towards(subscript_start, leaf) + + if isinstance(subscript_start, Node): + if subscript_start.type == syms.listmaker: + return False + + if subscript_start.type == syms.subscriptlist: + subscript_start = child_towards(subscript_start, leaf) return subscript_start is not None and any( n.type in TEST_DESCENDANTS for n in subscript_start.pre_order() ) @@ -1164,7 +1295,7 @@ class Line: res = f"{first.prefix}{indent}{first.value}" for leaf in leaves: res += str(leaf) - for _, comment in self.comments: + for comment in itertools.chain.from_iterable(self.comments.values()): res += str(comment) return res + "\n" @@ -1173,55 +1304,6 @@ class Line: return bool(self.leaves or self.comments) -class UnformattedLines(Line): - """Just like :class:`Line` but stores lines which aren't reformatted.""" - - def append(self, leaf: Leaf, preformatted: bool = True) -> None: - """Just add a new `leaf` to the end of the lines. - - The `preformatted` argument is ignored. - - Keeps track of indentation `depth`, which is useful when the user - says `# fmt: on`. Otherwise, doesn't do anything with the `leaf`. - """ - try: - list(generate_comments(leaf)) - except FormatOn as f_on: - self.leaves.append(f_on.leaf_from_consumed(leaf)) - raise - - self.leaves.append(leaf) - if leaf.type == token.INDENT: - self.depth += 1 - elif leaf.type == token.DEDENT: - self.depth -= 1 - - def __str__(self) -> str: - """Render unformatted lines from leaves which were added with `append()`. - - `depth` is not used for indentation in this case. - """ - if not self: - return "\n" - - res = "" - for leaf in self.leaves: - res += str(leaf) - return res - - def append_comment(self, comment: Leaf) -> bool: - """Not implemented in this class. Raises `NotImplementedError`.""" - raise NotImplementedError("Unformatted lines don't store comments separately.") - - def maybe_remove_trailing_comma(self, closing: Leaf) -> bool: - """Does nothing and returns False.""" - return False - - def maybe_increment_for_loop_variable(self, leaf: Leaf) -> bool: - """Does nothing and returns False.""" - return False - - @dataclass class EmptyLineTracker: """Provides a stateful method that returns the number of potential extra @@ -1243,9 +1325,6 @@ class EmptyLineTracker: This is for separating `def`, `async def` and `class` with extra empty lines (two on module-level). """ - if isinstance(current_line, UnformattedLines): - return 0, 0 - before, after = self._maybe_empty_lines(current_line) before -= self.previous_after self.previous_after = after @@ -1271,44 +1350,8 @@ class EmptyLineTracker: before = 0 if depth else 1 else: before = 1 if depth else 2 - is_decorator = current_line.is_decorator - if is_decorator or current_line.is_def or current_line.is_class: - if not is_decorator: - self.previous_defs.append(depth) - if self.previous_line is None: - # Don't insert empty lines before the first line in the file. - return 0, 0 - - if self.previous_line.is_decorator: - return 0, 0 - - if self.previous_line.depth < current_line.depth and ( - self.previous_line.is_class or self.previous_line.is_def - ): - return 0, 0 - - if ( - self.previous_line.is_comment - and self.previous_line.depth == current_line.depth - and before == 0 - ): - return 0, 0 - - if self.is_pyi: - if self.previous_line.depth > current_line.depth: - newlines = 1 - elif current_line.is_class or self.previous_line.is_class: - if current_line.is_stub_class and self.previous_line.is_stub_class: - newlines = 0 - else: - newlines = 1 - else: - newlines = 0 - else: - newlines = 2 - if current_line.depth and newlines: - newlines -= 1 - return newlines, 0 + if current_line.is_decorator or current_line.is_def or current_line.is_class: + return self._maybe_empty_lines_for_class_or_def(current_line, before) if ( self.previous_line @@ -1327,6 +1370,50 @@ class EmptyLineTracker: return before, 0 + def _maybe_empty_lines_for_class_or_def( + self, current_line: Line, before: int + ) -> Tuple[int, int]: + if not current_line.is_decorator: + self.previous_defs.append(current_line.depth) + if self.previous_line is None: + # Don't insert empty lines before the first line in the file. + return 0, 0 + + if self.previous_line.is_decorator: + return 0, 0 + + if self.previous_line.depth < current_line.depth and ( + self.previous_line.is_class or self.previous_line.is_def + ): + return 0, 0 + + if ( + self.previous_line.is_comment + and self.previous_line.depth == current_line.depth + and before == 0 + ): + return 0, 0 + + if self.is_pyi: + if self.previous_line.depth > current_line.depth: + newlines = 1 + elif current_line.is_class or self.previous_line.is_class: + if current_line.is_stub_class and self.previous_line.is_stub_class: + # No blank line between classes with an empty body + newlines = 0 + else: + newlines = 1 + elif current_line.is_def and not self.previous_line.is_def: + # Blank line between a block of functions and a block of non-functions + newlines = 1 + else: + newlines = 0 + else: + newlines = 2 + if current_line.depth and newlines: + newlines -= 1 + return newlines, 0 + @dataclass class LineGenerator(Visitor[Line]): @@ -1340,8 +1427,9 @@ class LineGenerator(Visitor[Line]): normalize_strings: bool = True current_line: Line = Factory(Line) remove_u_prefix: bool = False + allow_underscores: bool = False - def line(self, indent: int = 0, type: Type[Line] = Line) -> Iterator[Line]: + def line(self, indent: int = 0) -> Iterator[Line]: """Generate a line. If the line is empty, only emit if it makes sense. @@ -1350,67 +1438,41 @@ class LineGenerator(Visitor[Line]): If any lines were generated, set up a new current_line. """ if not self.current_line: - if self.current_line.__class__ == type: - self.current_line.depth += indent - else: - self.current_line = type(depth=self.current_line.depth + indent) + self.current_line.depth += indent return # Line is empty, don't emit. Creating a new one unnecessary. complete_line = self.current_line - self.current_line = type(depth=complete_line.depth + indent) + self.current_line = Line(depth=complete_line.depth + indent) yield complete_line - def visit(self, node: LN) -> Iterator[Line]: - """Main method to visit `node` and its children. - - Yields :class:`Line` objects. - """ - if isinstance(self.current_line, UnformattedLines): - # File contained `# fmt: off` - yield from self.visit_unformatted(node) - - else: - yield from super().visit(node) - def visit_default(self, node: LN) -> Iterator[Line]: """Default `visit_*()` implementation. Recurses to children of `node`.""" if isinstance(node, Leaf): any_open_brackets = self.current_line.bracket_tracker.any_open_brackets() - try: - for comment in generate_comments(node): - if any_open_brackets: - # any comment within brackets is subject to splitting - self.current_line.append(comment) - elif comment.type == token.COMMENT: - # regular trailing comment - self.current_line.append(comment) - yield from self.line() - - else: - # regular standalone comment - yield from self.line() - - self.current_line.append(comment) - yield from self.line() - - except FormatOff as f_off: - f_off.trim_prefix(node) - yield from self.line(type=UnformattedLines) - yield from self.visit(node) - - except FormatOn as f_on: - # This only happens here if somebody says "fmt: on" multiple - # times in a row. - f_on.trim_prefix(node) - yield from self.visit_default(node) + for comment in generate_comments(node): + if any_open_brackets: + # any comment within brackets is subject to splitting + self.current_line.append(comment) + elif comment.type == token.COMMENT: + # regular trailing comment + self.current_line.append(comment) + yield from self.line() - else: - normalize_prefix(node, inside_brackets=any_open_brackets) - if self.normalize_strings and node.type == token.STRING: - normalize_string_prefix(node, remove_u_prefix=self.remove_u_prefix) - normalize_string_quotes(node) - if node.type not in WHITESPACE: - self.current_line.append(node) + else: + # regular standalone comment + yield from self.line() + + self.current_line.append(comment) + yield from self.line() + + normalize_prefix(node, inside_brackets=any_open_brackets) + if self.normalize_strings and node.type == token.STRING: + normalize_string_prefix(node, remove_u_prefix=self.remove_u_prefix) + normalize_string_quotes(node) + if node.type == token.NUMBER: + normalize_numeric_literal(node, self.allow_underscores) + if node.type not in WHITESPACE: + self.current_line.append(node) yield from super().visit_default(node) def visit_INDENT(self, node: Node) -> Iterator[Line]: @@ -1507,23 +1569,10 @@ class LineGenerator(Visitor[Line]): yield from self.visit_default(leaf) yield from self.line() - def visit_unformatted(self, node: LN) -> Iterator[Line]: - """Used when file contained a `# fmt: off`.""" - if isinstance(node, Node): - for child in node.children: - yield from self.visit(child) - - else: - try: - self.current_line.append(node) - except FormatOn as f_on: - f_on.trim_prefix(node) - yield from self.line() - yield from self.visit(node) - - if node.type == token.ENDMARKER: - # somebody decided not to put a final `# fmt: on` - yield from self.line() + def visit_STANDALONE_COMMENT(self, leaf: Leaf) -> Iterator[Line]: + if not self.current_line.bracket_tracker.any_open_brackets(): + yield from self.line() + yield from self.visit_default(leaf) def __attrs_post_init__(self) -> None: """You are in a twisty little maze of passages.""" @@ -1766,7 +1815,7 @@ def whitespace(leaf: Leaf, *, complex_subscript: bool) -> str: # noqa C901 elif prevp.type == token.EQUAL and prevp_parent.type == syms.argument: return NO - elif t == token.NAME or t == token.NUMBER: + elif t in {token.NAME, token.NUMBER, token.STRING}: return NO elif p.type == syms.import_from: @@ -1813,7 +1862,32 @@ def child_towards(ancestor: Node, descendant: LN) -> Optional[LN]: return node -def is_split_after_delimiter(leaf: Leaf, previous: Leaf = None) -> int: +def container_of(leaf: Leaf) -> LN: + """Return `leaf` or one of its ancestors that is the topmost container of it. + + By "container" we mean a node where `leaf` is the very first child. + """ + same_prefix = leaf.prefix + container: LN = leaf + while container: + parent = container.parent + if parent is None: + break + + if parent.children[0].prefix != same_prefix: + break + + if parent.type == syms.file_input: + break + + if parent.prev_sibling is not None and parent.prev_sibling.type in BRACKETS: + break + + container = parent + return container + + +def is_split_after_delimiter(leaf: Leaf, previous: Optional[Leaf] = None) -> int: """Return the priority of the `leaf` delimiter, given a line break after it. The delimiter priorities returned here are from those delimiters that would @@ -1827,8 +1901,8 @@ def is_split_after_delimiter(leaf: Leaf, previous: Leaf = None) -> int: return 0 -def is_split_before_delimiter(leaf: Leaf, previous: Leaf = None) -> int: - """Return the priority of the `leaf` delimiter, given a line before after it. +def is_split_before_delimiter(leaf: Leaf, previous: Optional[Leaf] = None) -> int: + """Return the priority of the `leaf` delimiter, given a line break before it. The delimiter priorities returned here are from those delimiters that would cause a line break before themselves. @@ -1865,15 +1939,20 @@ def is_split_before_delimiter(leaf: Leaf, previous: Leaf = None) -> int: ): return STRING_PRIORITY - if leaf.type != token.NAME: + if leaf.type not in {token.NAME, token.ASYNC}: return 0 if ( leaf.value == "for" and leaf.parent and leaf.parent.type in {syms.comp_for, syms.old_comp_for} + or leaf.type == token.ASYNC ): - return COMPREHENSION_PRIORITY + if ( + not isinstance(leaf.prev_sibling, Leaf) + or leaf.prev_sibling.value != "async" + ): + return COMPREHENSION_PRIORITY if ( leaf.value == "if" @@ -1918,6 +1997,10 @@ def is_split_before_delimiter(leaf: Leaf, previous: Leaf = None) -> int: return 0 +FMT_OFF = {"# fmt: off", "# fmt:off", "# yapf: disable"} +FMT_ON = {"# fmt: on", "# fmt:on", "# yapf: enable"} + + def generate_comments(leaf: LN) -> Iterator[Leaf]: """Clean the prefix of the `leaf` and generate comments from it, if any. @@ -1937,16 +2020,38 @@ def generate_comments(leaf: LN) -> Iterator[Leaf]: Inline comments are emitted as regular token.COMMENT leaves. Standalone are emitted with a fake STANDALONE_COMMENT token identifier. """ - p = leaf.prefix - if not p: - return + for pc in list_comments(leaf.prefix, is_endmarker=leaf.type == token.ENDMARKER): + yield Leaf(pc.type, pc.value, prefix="\n" * pc.newlines) + + +@dataclass +class ProtoComment: + """Describes a piece of syntax that is a comment. + + It's not a :class:`blib2to3.pytree.Leaf` so that: + + * it can be cached (`Leaf` objects should not be reused more than once as + they store their lineno, column, prefix, and parent information); + * `newlines` and `consumed` fields are kept separate from the `value`. This + simplifies handling of special marker comments like ``# fmt: off/on``. + """ + + type: int # token.COMMENT or STANDALONE_COMMENT + value: str # content of the comment + newlines: int # how many newlines before the comment + consumed: int # how many characters of the original leaf's prefix did we consume - if "#" not in p: - return + +@lru_cache(maxsize=4096) +def list_comments(prefix: str, *, is_endmarker: bool) -> List[ProtoComment]: + """Return a list of :class:`ProtoComment` objects parsed from the given `prefix`.""" + result: List[ProtoComment] = [] + if not prefix or "#" not in prefix: + return result consumed = 0 nlines = 0 - for index, line in enumerate(p.split("\n")): + for index, line in enumerate(prefix.split("\n")): consumed += len(line) + 1 # adding the length of the split '\n' line = line.lstrip() if not line: @@ -1954,32 +2059,25 @@ def generate_comments(leaf: LN) -> Iterator[Leaf]: if not line.startswith("#"): continue - if index == 0 and leaf.type != token.ENDMARKER: + if index == 0 and not is_endmarker: comment_type = token.COMMENT # simple trailing comment else: comment_type = STANDALONE_COMMENT comment = make_comment(line) - yield Leaf(comment_type, comment, prefix="\n" * nlines) - - if comment in {"# fmt: on", "# yapf: enable"}: - raise FormatOn(consumed) - - if comment in {"# fmt: off", "# yapf: disable"}: - if comment_type == STANDALONE_COMMENT: - raise FormatOff(consumed) - - prev = preceding_leaf(leaf) - if not prev or prev.type in WHITESPACE: # standalone comment in disguise - raise FormatOff(consumed) - + result.append( + ProtoComment( + type=comment_type, value=comment, newlines=nlines, consumed=consumed + ) + ) nlines = 0 + return result def make_comment(content: str) -> str: """Return a consistently formatted comment from the given `content` string. - All comments (except for "##", "#!", "#:") should have a single space between - the hash sign and the content. + All comments (except for "##", "#!", "#:", '#'", "#%%") should have a single + space between the hash sign and the content. If `content` didn't start with a hash sign, one is provided. """ @@ -1989,7 +2087,7 @@ def make_comment(content: str) -> str: if content[0] == "#": content = content[1:] - if content and content[0] not in " !:#": + if content and content[0] not in " !:#'%": content = " " + content return "#" + content @@ -2007,7 +2105,7 @@ def split_line( If `py36` is True, splitting may generate syntax that is only compatible with Python 3.6 and later. """ - if isinstance(line, UnformattedLines) or line.is_comment: + if line.is_comment: yield line return @@ -2052,7 +2150,7 @@ def split_line( result.extend( split_line(l, line_length=line_length, inner=True, py36=py36) ) - except CannotSplit as cs: + except CannotSplit: continue else: @@ -2070,9 +2168,6 @@ def left_hand_split(line: Line, py36: bool = False) -> Iterator[Line]: Prefer RHS otherwise. This is why this function is not symmetrical with :func:`right_hand_split` which also handles optional parentheses. """ - head = Line(depth=line.depth) - body = Line(depth=line.depth + 1, inside_brackets=True) - tail = Line(depth=line.depth) tail_leaves: List[Leaf] = [] body_leaves: List[Leaf] = [] head_leaves: List[Leaf] = [] @@ -2090,15 +2185,12 @@ def left_hand_split(line: Line, py36: bool = False) -> Iterator[Line]: if leaf.type in OPENING_BRACKETS: matching_bracket = leaf current_leaves = body_leaves - # Since body is a new indent level, remove spurious leading whitespace. - if body_leaves: - normalize_prefix(body_leaves[0], inside_brackets=True) - # Build the new lines. - for result, leaves in (head, head_leaves), (body, body_leaves), (tail, tail_leaves): - for leaf in leaves: - result.append(leaf, preformatted=True) - for comment_after in line.comments_after(leaf): - result.append(comment_after, preformatted=True) + if not matching_bracket: + raise CannotSplit("No brackets found") + + head = bracket_split_build_line(head_leaves, line, matching_bracket) + body = bracket_split_build_line(body_leaves, line, matching_bracket, is_body=True) + tail = bracket_split_build_line(tail_leaves, line, matching_bracket) bracket_split_succeeded_or_raise(head, body, tail) for result in (head, body, tail): if result: @@ -2116,9 +2208,6 @@ def right_hand_split( Note: running this function modifies `bracket_depth` on the leaves of `line`. """ - head = Line(depth=line.depth) - body = Line(depth=line.depth + 1, inside_brackets=True) - tail = Line(depth=line.depth) tail_leaves: List[Leaf] = [] body_leaves: List[Leaf] = [] head_leaves: List[Leaf] = [] @@ -2135,49 +2224,60 @@ def right_hand_split( opening_bracket = leaf.opening_bracket closing_bracket = leaf current_leaves = body_leaves - tail_leaves.reverse() - body_leaves.reverse() - head_leaves.reverse() - # Since body is a new indent level, remove spurious leading whitespace. - if body_leaves: - normalize_prefix(body_leaves[0], inside_brackets=True) - if not head_leaves: - # No `head` means the split failed. Either `tail` has all content or + if not (opening_bracket and closing_bracket and head_leaves): + # If there is no opening or closing_bracket that means the split failed and + # all content is in the tail. Otherwise, if `head_leaves` are empty, it means # the matching `opening_bracket` wasn't available on `line` anymore. raise CannotSplit("No brackets found") - # Build the new lines. - for result, leaves in (head, head_leaves), (body, body_leaves), (tail, tail_leaves): - for leaf in leaves: - result.append(leaf, preformatted=True) - for comment_after in line.comments_after(leaf): - result.append(comment_after, preformatted=True) + tail_leaves.reverse() + body_leaves.reverse() + head_leaves.reverse() + head = bracket_split_build_line(head_leaves, line, opening_bracket) + body = bracket_split_build_line(body_leaves, line, opening_bracket, is_body=True) + tail = bracket_split_build_line(tail_leaves, line, opening_bracket) bracket_split_succeeded_or_raise(head, body, tail) - assert opening_bracket and closing_bracket if ( + # the body shouldn't be exploded + not body.should_explode # the opening bracket is an optional paren - opening_bracket.type == token.LPAR + and opening_bracket.type == token.LPAR and not opening_bracket.value # the closing bracket is an optional paren and closing_bracket.type == token.RPAR and not closing_bracket.value - # there are no standalone comments in the body - and not line.contains_standalone_comments(0) - # and it's not an import (optional parens are the only thing we can split - # on in this case; attempting a split without them is a waste of time) + # it's not an import (optional parens are the only thing we can split on + # in this case; attempting a split without them is a waste of time) and not line.is_import + # there are no standalone comments in the body + and not body.contains_standalone_comments(0) + # and we can actually remove the parens + and can_omit_invisible_parens(body, line_length) ): omit = {id(closing_bracket), *omit} - if can_omit_invisible_parens(body, line_length): - try: - yield from right_hand_split(line, line_length, py36=py36, omit=omit) - return - except CannotSplit: - pass + try: + yield from right_hand_split(line, line_length, py36=py36, omit=omit) + return + + except CannotSplit: + if not ( + can_be_split(body) + or is_line_short_enough(body, line_length=line_length) + ): + raise CannotSplit( + "Splitting failed, body is still too long and can't be split." + ) + + elif head.contains_multiline_strings() or tail.contains_multiline_strings(): + raise CannotSplit( + "The current optional pair of parentheses is bound to fail to " + "satisfy the splitting algorithm because the head or the tail " + "contains multiline strings which by definition never fit one " + "line." + ) ensure_visible(opening_bracket) ensure_visible(closing_bracket) - body.should_explode = should_explode(body, opening_bracket) for result in (head, body, tail): if result: yield result @@ -2209,6 +2309,35 @@ def bracket_split_succeeded_or_raise(head: Line, body: Line, tail: Line) -> None ) +def bracket_split_build_line( + leaves: List[Leaf], original: Line, opening_bracket: Leaf, *, is_body: bool = False +) -> Line: + """Return a new line with given `leaves` and respective comments from `original`. + + If `is_body` is True, the result line is one-indented inside brackets and as such + has its first leaf's prefix normalized and a trailing comma added when expected. + """ + result = Line(depth=original.depth) + if is_body: + result.inside_brackets = True + result.depth += 1 + if leaves: + # Since body is a new indent level, remove spurious leading whitespace. + normalize_prefix(leaves[0], inside_brackets=True) + # Ensure a trailing comma when expected. + if original.is_import: + if leaves[-1].type != token.COMMA: + leaves.append(Leaf(token.COMMA, ",")) + # Populate the line + for leaf in leaves: + result.append(leaf, preformatted=True) + for comment_after in original.comments_after(leaf): + result.append(comment_after, preformatted=True) + if is_body: + result.should_explode = should_explode(result, opening_bracket) + return result + + def dont_increase_indentation(split_func: SplitFunc) -> SplitFunc: """Normalize prefix of the first leaf in every line returned by `split_func`. @@ -2255,16 +2384,16 @@ def delimiter_split(line: Line, py36: bool = False) -> Iterator[Line]: nonlocal current_line try: current_line.append_safe(leaf, preformatted=True) - except ValueError as ve: + except ValueError: yield current_line current_line = Line(depth=line.depth, inside_brackets=line.inside_brackets) current_line.append(leaf) - for index, leaf in enumerate(line.leaves): + for leaf in line.leaves: yield from append_to_line(leaf) - for comment_after in line.comments_after(leaf, index): + for comment_after in line.comments_after(leaf): yield from append_to_line(comment_after) lowest_depth = min(lowest_depth, leaf.bracket_depth) @@ -2301,16 +2430,16 @@ def standalone_comment_split(line: Line, py36: bool = False) -> Iterator[Line]: nonlocal current_line try: current_line.append_safe(leaf, preformatted=True) - except ValueError as ve: + except ValueError: yield current_line current_line = Line(depth=line.depth, inside_brackets=line.inside_brackets) current_line.append(leaf) - for index, leaf in enumerate(line.leaves): + for leaf in line.leaves: yield from append_to_line(leaf) - for comment_after in line.comments_after(leaf, index): + for comment_after in line.comments_after(leaf): yield from append_to_line(comment_after) if current_line: @@ -2392,8 +2521,8 @@ def normalize_string_quotes(leaf: Leaf) -> None: prefix = leaf.value[:first_quote_pos] unescaped_new_quote = re.compile(rf"(([^\\]|^)(\\\\)*){new_quote}") - escaped_new_quote = re.compile(rf"([^\\]|^)\\(\\\\)*{new_quote}") - escaped_orig_quote = re.compile(rf"([^\\]|^)\\(\\\\)*{orig_quote}") + escaped_new_quote = re.compile(rf"([^\\]|^)\\((?:\\\\)*){new_quote}") + escaped_orig_quote = re.compile(rf"([^\\]|^)\\((?:\\\\)*){orig_quote}") body = leaf.value[first_quote_pos + len(orig_quote) : -len(orig_quote)] if "r" in prefix.casefold(): if unescaped_new_quote.search(body): @@ -2404,15 +2533,21 @@ def normalize_string_quotes(leaf: Leaf) -> None: # Do not introduce or remove backslashes in raw strings new_body = body else: - # remove unnecessary quotes + # remove unnecessary escapes new_body = sub_twice(escaped_new_quote, rf"\1\2{new_quote}", body) if body != new_body: - # Consider the string without unnecessary quotes as the original + # Consider the string without unnecessary escapes as the original body = new_body leaf.value = f"{prefix}{orig_quote}{body}{orig_quote}" new_body = sub_twice(escaped_orig_quote, rf"\1\2{orig_quote}", new_body) new_body = sub_twice(unescaped_new_quote, rf"\1\\{new_quote}", new_body) - if new_quote == '"""' and new_body[-1] == '"': + if "f" in prefix.casefold(): + matches = re.findall(r"[^{]\{(.*?)\}[^}]", new_body) + for m in matches: + if "\\" in str(m): + # Do not introduce backslashes in interpolated expressions + return + if new_quote == '"""' and new_body[-1:] == '"': # edge case: new_body = new_body[:-1] + '\\"' orig_escape_count = body.count("\\") @@ -2426,6 +2561,83 @@ def normalize_string_quotes(leaf: Leaf) -> None: leaf.value = f"{prefix}{new_quote}{new_body}{new_quote}" +def normalize_numeric_literal(leaf: Leaf, allow_underscores: bool) -> None: + """Normalizes numeric (float, int, and complex) literals. + + All letters used in the representation are normalized to lowercase (except + in Python 2 long literals), and long number literals are split using underscores. + """ + text = leaf.value.lower() + if text.startswith(("0o", "0b")): + # Leave octal and binary literals alone. + pass + elif text.startswith("0x"): + # Change hex literals to upper case. + before, after = text[:2], text[2:] + text = f"{before}{after.upper()}" + elif "e" in text: + before, after = text.split("e") + sign = "" + if after.startswith("-"): + after = after[1:] + sign = "-" + elif after.startswith("+"): + after = after[1:] + before = format_float_or_int_string(before, allow_underscores) + after = format_int_string(after, allow_underscores) + text = f"{before}e{sign}{after}" + elif text.endswith(("j", "l")): + number = text[:-1] + suffix = text[-1] + # Capitalize in "2L" because "l" looks too similar to "1". + if suffix == "l": + suffix = "L" + text = f"{format_float_or_int_string(number, allow_underscores)}{suffix}" + else: + text = format_float_or_int_string(text, allow_underscores) + leaf.value = text + + +def format_float_or_int_string(text: str, allow_underscores: bool) -> str: + """Formats a float string like "1.0".""" + if "." not in text: + return format_int_string(text, allow_underscores) + + before, after = text.split(".") + before = format_int_string(before, allow_underscores) if before else "0" + if after: + after = format_int_string(after, allow_underscores, count_from_end=False) + else: + after = "0" + return f"{before}.{after}" + + +def format_int_string( + text: str, allow_underscores: bool, count_from_end: bool = True +) -> str: + """Normalizes underscores in a string to e.g. 1_000_000. + + Input must be a string of digits and optional underscores. + If count_from_end is False, we add underscores after groups of three digits + counting from the beginning instead of the end of the strings. This is used + for the fractional part of float literals. + """ + if not allow_underscores: + return text + + text = text.replace("_", "") + if len(text) <= 5: + # No underscores for numbers <= 5 digits long. + return text + + if count_from_end: + # Avoid removing leading zeros, which are important if we're formatting + # part of a number like "0.001". + return format(int("1" + text), "3_")[1:].lstrip("_") + else: + return "_".join(text[i : i + 3] for i in range(0, len(text), 3)) + + def normalize_invisible_parens(node: Node, parens_after: Set[str]) -> None: """Make existing optional parentheses invisible or create new ones. @@ -2435,16 +2647,20 @@ def normalize_invisible_parens(node: Node, parens_after: Set[str]) -> None: Standardizes on visible parentheses for single-element tuples, and keeps existing visible parentheses for other tuples and generator expressions. """ - try: - list(generate_comments(node)) - except FormatOff: - return # This `node` has a prefix with `# fmt: off`, don't mess with parens. + for pc in list_comments(node.prefix, is_endmarker=False): + if pc.value in FMT_OFF: + # This `node` has a prefix with `# fmt: off`, don't mess with parens. + return check_lpar = False for index, child in enumerate(list(node.children)): if check_lpar: if child.type == syms.atom: - maybe_make_parens_invisible_in_atom(child) + if maybe_make_parens_invisible_in_atom(child): + lpar = Leaf(token.LPAR, "") + rpar = Leaf(token.RPAR, "") + index = child.remove() or 0 + node.insert_child(index, Node(syms.atom, [lpar, child, rpar])) elif is_one_tuple(child): # wrap child in visible parentheses lpar = Leaf(token.LPAR, "(") @@ -2474,8 +2690,89 @@ def normalize_invisible_parens(node: Node, parens_after: Set[str]) -> None: check_lpar = isinstance(child, Leaf) and child.value in parens_after +def normalize_fmt_off(node: Node) -> None: + """Convert content between `# fmt: off`/`# fmt: on` into standalone comments.""" + try_again = True + while try_again: + try_again = convert_one_fmt_off_pair(node) + + +def convert_one_fmt_off_pair(node: Node) -> bool: + """Convert content of a single `# fmt: off`/`# fmt: on` into a standalone comment. + + Returns True if a pair was converted. + """ + for leaf in node.leaves(): + previous_consumed = 0 + for comment in list_comments(leaf.prefix, is_endmarker=False): + if comment.value in FMT_OFF: + # We only want standalone comments. If there's no previous leaf or + # the previous leaf is indentation, it's a standalone comment in + # disguise. + if comment.type != STANDALONE_COMMENT: + prev = preceding_leaf(leaf) + if prev and prev.type not in WHITESPACE: + continue + + ignored_nodes = list(generate_ignored_nodes(leaf)) + if not ignored_nodes: + continue + + first = ignored_nodes[0] # Can be a container node with the `leaf`. + parent = first.parent + prefix = first.prefix + first.prefix = prefix[comment.consumed :] + hidden_value = ( + comment.value + "\n" + "".join(str(n) for n in ignored_nodes) + ) + if hidden_value.endswith("\n"): + # That happens when one of the `ignored_nodes` ended with a NEWLINE + # leaf (possibly followed by a DEDENT). + hidden_value = hidden_value[:-1] + first_idx = None + for ignored in ignored_nodes: + index = ignored.remove() + if first_idx is None: + first_idx = index + assert parent is not None, "INTERNAL ERROR: fmt: on/off handling (1)" + assert first_idx is not None, "INTERNAL ERROR: fmt: on/off handling (2)" + parent.insert_child( + first_idx, + Leaf( + STANDALONE_COMMENT, + hidden_value, + prefix=prefix[:previous_consumed] + "\n" * comment.newlines, + ), + ) + return True + + previous_consumed = comment.consumed + + return False + + +def generate_ignored_nodes(leaf: Leaf) -> Iterator[LN]: + """Starting from the container of `leaf`, generate all leaves until `# fmt: on`. + + Stops at the end of the block. + """ + container: Optional[LN] = container_of(leaf) + while container is not None and container.type != token.ENDMARKER: + for comment in list_comments(container.prefix, is_endmarker=False): + if comment.value in FMT_ON: + return + + yield container + + container = container.next_sibling + + def maybe_make_parens_invisible_in_atom(node: LN) -> bool: - """If it's safe, make the parens in the atom `node` invisible, recusively.""" + """If it's safe, make the parens in the atom `node` invisible, recursively. + + Returns whether the node should itself be wrapped in invisible parentheses. + + """ if ( node.type != syms.atom or is_empty_tuple(node) @@ -2493,9 +2790,9 @@ def maybe_make_parens_invisible_in_atom(node: LN) -> bool: last.value = "" # type: ignore if len(node.children) > 1: maybe_make_parens_invisible_in_atom(node.children[1]) - return True + return False - return False + return True def is_empty_tuple(node: LN) -> bool: @@ -2673,7 +2970,8 @@ def is_python36(node: Node) -> bool: """Return True if the current file is using Python 3.6+ features. Currently looking for: - - f-strings; and + - f-strings; + - underscores in numeric literals; and - trailing commas after * or ** in function signatures and calls. """ for n in node.pre_order(): @@ -2682,6 +2980,10 @@ def is_python36(node: Node) -> bool: if value_head in {'f"', 'F"', "f'", "F'", "rf", "fr", "RF", "FR"}: return True + elif n.type == token.NUMBER: + if "_" in n.value: # type: ignore + return True + elif ( n.type in {syms.typedargslist, syms.arglist} and n.children @@ -2715,7 +3017,6 @@ def generate_trailers_to_omit(line: Line, line_length: int) -> Iterator[Set[Leaf length = 4 * line.depth opening_bracket = None closing_bracket = None - optional_brackets: Set[LeafID] = set() inner_brackets: Set[LeafID] = set() for index, leaf, leaf_length in enumerate_with_length(line, reversed=True): length += leaf_length @@ -2726,17 +3027,12 @@ def generate_trailers_to_omit(line: Line, line_length: int) -> Iterator[Set[Leaf if leaf.type == STANDALONE_COMMENT or has_inline_comment: break - optional_brackets.discard(id(leaf)) if opening_bracket: if leaf is opening_bracket: opening_bracket = None elif leaf.type in CLOSING_BRACKETS: inner_brackets.add(id(leaf)) elif leaf.type in CLOSING_BRACKETS: - if not leaf.value: - optional_brackets.add(id(opening_bracket)) - continue - if index > 0 and line.leaves[index - 1].type in OPENING_BRACKETS: # Empty brackets would fail a split so treat them as "inner" # brackets (e.g. only add them to the `omit` set if another @@ -2744,18 +3040,36 @@ def generate_trailers_to_omit(line: Line, line_length: int) -> Iterator[Set[Leaf inner_brackets.add(id(leaf)) continue - opening_bracket = leaf.opening_bracket if closing_bracket: omit.add(id(closing_bracket)) omit.update(inner_brackets) inner_brackets.clear() yield omit - closing_bracket = leaf + + if leaf.value: + opening_bracket = leaf.opening_bracket + closing_bracket = leaf def get_future_imports(node: Node) -> Set[str]: """Return a set of __future__ imports in the file.""" - imports = set() + imports: Set[str] = set() + + def get_imports_from_children(children: List[LN]) -> Generator[str, None, None]: + for child in children: + if isinstance(child, Leaf): + if child.type == token.NAME: + yield child.value + elif child.type == syms.import_as_name: + orig_name = child.children[0] + assert isinstance(orig_name, Leaf), "Invalid syntax parsing imports" + assert orig_name.type == token.NAME, "Invalid syntax parsing imports" + yield orig_name.value + elif child.type == syms.import_as_names: + yield from get_imports_from_children(child.children) + else: + assert False, "Invalid syntax parsing imports" + for child in node.children: if child.type != syms.simple_stmt: break @@ -2774,57 +3088,92 @@ def get_future_imports(node: Node) -> Set[str]: module_name = first_child.children[1] if not isinstance(module_name, Leaf) or module_name.value != "__future__": break - for import_from_child in first_child.children[3:]: - if isinstance(import_from_child, Leaf): - if import_from_child.type == token.NAME: - imports.add(import_from_child.value) - else: - assert import_from_child.type == syms.import_as_names - for leaf in import_from_child.children: - if isinstance(leaf, Leaf) and leaf.type == token.NAME: - imports.add(leaf.value) + imports |= set(get_imports_from_children(first_child.children[3:])) else: break return imports def gen_python_files_in_dir( - path: Path, include: Pattern[str], exclude: Pattern[str] + path: Path, + root: Path, + include: Pattern[str], + exclude: Pattern[str], + report: "Report", ) -> Iterator[Path]: """Generate all files under `path` whose paths are not excluded by the `exclude` regex, but are included by the `include` regex. - """ + Symbolic links pointing outside of the `root` directory are ignored. + + `report` is where output about exclusions goes. + """ + assert root.is_absolute(), f"INTERNAL ERROR: `root` must be absolute but is {root}" for child in path.iterdir(): - searchable_path = str(child.as_posix()) - if Path(child.parts[0]).is_dir(): - searchable_path = "/" + searchable_path - if child.is_dir(): - searchable_path = searchable_path + "/" - exclude_match = exclude.search(searchable_path) - if exclude_match and len(exclude_match.group()) > 0: + try: + normalized_path = "/" + child.resolve().relative_to(root).as_posix() + except ValueError: + if child.is_symlink(): + report.path_ignored( + child, f"is a symbolic link that points outside {root}" + ) continue - yield from gen_python_files_in_dir(child, include, exclude) + raise - else: - include_match = include.search(searchable_path) - exclude_match = exclude.search(searchable_path) - if ( - child.is_file() - and include_match - and len(include_match.group()) > 0 - and (not exclude_match or len(exclude_match.group()) == 0) - ): + if child.is_dir(): + normalized_path += "/" + exclude_match = exclude.search(normalized_path) + if exclude_match and exclude_match.group(0): + report.path_ignored(child, f"matches the --exclude regular expression") + continue + + if child.is_dir(): + yield from gen_python_files_in_dir(child, root, include, exclude, report) + + elif child.is_file(): + include_match = include.search(normalized_path) + if include_match: yield child +@lru_cache() +def find_project_root(srcs: Iterable[str]) -> Path: + """Return a directory containing .git, .hg, or pyproject.toml. + + That directory can be one of the directories passed in `srcs` or their + common parent. + + If no directory in the tree contains a marker that would specify it's the + project root, the root of the file system is returned. + """ + if not srcs: + return Path("/").resolve() + + common_base = min(Path(src).resolve() for src in srcs) + if common_base.is_dir(): + # Append a fake file so `parents` below returns `common_base_dir`, too. + common_base /= "fake-file" + for directory in common_base.parents: + if (directory / ".git").is_dir(): + return directory + + if (directory / ".hg").is_dir(): + return directory + + if (directory / "pyproject.toml").is_file(): + return directory + + return directory + + @dataclass class Report: """Provides a reformatting counter. Can be rendered with `str(report)`.""" check: bool = False quiet: bool = False + verbose: bool = False change_count: int = 0 same_count: int = 0 failure_count: int = 0 @@ -2833,11 +3182,11 @@ class Report: """Increment the counter for successful reformatting. Write out a message.""" if changed is Changed.YES: reformatted = "would reformat" if self.check else "reformatted" - if not self.quiet: + if self.verbose or not self.quiet: out(f"{reformatted} {src}") self.change_count += 1 else: - if not self.quiet: + if self.verbose: if changed is Changed.NO: msg = f"{src} already well formatted, good job." else: @@ -2850,6 +3199,10 @@ class Report: err(f"error: cannot format {src}: {message}") self.failure_count += 1 + def path_ignored(self, path: Path, message: str) -> None: + if self.verbose: + out(f"{path} ignored: {message}", bold=False) + @property def return_code(self) -> int: """Return the exit code that the app should use. @@ -2860,7 +3213,7 @@ class Report: - otherwise return 0. """ # According to http://tldp.org/LDP/abs/html/exitcodes.html starting with - # 126 we have special returncodes reserved by the shell. + # 126 we have special return codes reserved by the shell. if self.failure_count: return 123 @@ -3043,6 +3396,16 @@ def sub_twice(regex: Pattern[str], replacement: str, original: str) -> str: return regex.sub(replacement, regex.sub(replacement, original)) +def re_compile_maybe_verbose(regex: str) -> Pattern[str]: + """Compile a regular expression string in `regex`. + + If it contains newlines, use verbose mode. + """ + if "\n" in regex: + regex = "(?x)" + regex + return re.compile(regex) + + def enumerate_reversed(sequence: Sequence[T]) -> Iterator[Tuple[Index, T]]: """Like `reversed(enumerate(sequence))` if that were possible.""" index = len(sequence) - 1 @@ -3068,7 +3431,7 @@ def enumerate_with_length( return # Multiline strings, we can't continue. comment: Optional[Leaf] - for comment in line.comments_after(leaf, index): + for comment in line.comments_after(leaf): length += len(comment.value) yield index, leaf, length @@ -3088,6 +3451,42 @@ def is_line_short_enough(line: Line, *, line_length: int, line_str: str = "") -> ) +def can_be_split(line: Line) -> bool: + """Return False if the line cannot be split *for sure*. + + This is not an exhaustive search but a cheap heuristic that we can use to + avoid some unfortunate formattings (mostly around wrapping unsplittable code + in unnecessary parentheses). + """ + leaves = line.leaves + if len(leaves) < 2: + return False + + if leaves[0].type == token.STRING and leaves[1].type == token.DOT: + call_count = 0 + dot_count = 0 + next = leaves[-1] + for leaf in leaves[-2::-1]: + if leaf.type in OPENING_BRACKETS: + if next.type not in CLOSING_BRACKETS: + return False + + call_count += 1 + elif leaf.type == token.DOT: + dot_count += 1 + elif leaf.type == token.NAME: + if not (next.type == token.DOT or next.type in OPENING_BRACKETS): + return False + + elif leaf.type not in CLOSING_BRACKETS: + return False + + if dot_count > 1 and call_count > 1: + return False + + return True + + def can_omit_invisible_parens(line: Line, line_length: int) -> bool: """Does `line` have a shape safe to reformat without optional parens around it? @@ -3178,12 +3577,7 @@ def can_omit_invisible_parens(line: Line, line_length: int) -> bool: def get_cache_file(line_length: int, mode: FileMode) -> Path: - pyi = bool(mode & FileMode.PYI) - py36 = bool(mode & FileMode.PYTHON36) - return ( - CACHE_DIR - / f"cache.{line_length}{'.pyi' if pyi else ''}{'.py36' if py36 else ''}.pickle" - ) + return CACHE_DIR / f"cache.{line_length}.{mode.value}.pickle" def read_cache(line_length: int, mode: FileMode) -> Cache: @@ -3210,26 +3604,24 @@ def get_cache_info(path: Path) -> CacheInfo: return stat.st_mtime, stat.st_size -def filter_cached( - cache: Cache, sources: Iterable[Path] -) -> Tuple[List[Path], List[Path]]: - """Split a list of paths into two. +def filter_cached(cache: Cache, sources: Iterable[Path]) -> Tuple[Set[Path], Set[Path]]: + """Split an iterable of paths in `sources` into two sets. - The first list contains paths of files that modified on disk or are not in the - cache. The other list contains paths to non-modified files. + The first contains paths of files that modified on disk or are not in the + cache. The other contains paths to non-modified files. """ - todo, done = [], [] + todo, done = set(), set() for src in sources: src = src.resolve() if cache.get(src) != get_cache_info(src): - todo.append(src) + todo.add(src) else: - done.append(src) + done.add(src) return todo, done def write_cache( - cache: Cache, sources: List[Path], line_length: int, mode: FileMode + cache: Cache, sources: Iterable[Path], line_length: int, mode: FileMode ) -> None: """Update the cache file.""" cache_file = get_cache_file(line_length, mode) @@ -3243,5 +3635,32 @@ def write_cache( pass -if __name__ == "__main__": +def patch_click() -> None: + """Make Click not crash. + + On certain misconfigured environments, Python 3 selects the ASCII encoding as the + default which restricts paths that it can access during the lifetime of the + application. Click refuses to work in this scenario by raising a RuntimeError. + + In case of Black the likelihood that non-ASCII characters are going to be used in + file paths is minimal since it's Python source code. Moreover, this crash was + spurious on Python 3.7 thanks to PEP 538 and PEP 540. + """ + try: + from click import core + from click import _unicodefun # type: ignore + except ModuleNotFoundError: + return + + for module in (core, _unicodefun): + if hasattr(module, "_verify_python3_env"): + module._verify_python3_env = lambda: None + + +def patched_main() -> None: + patch_click() main() + + +if __name__ == "__main__": + patched_main()