X-Git-Url: https://git.madduck.net/etc/vim.git/blobdiff_plain/96e68f034197d6060aad876216a0e40e0eac0987..75d2af2e3a0d0cf85b1e0c510c626d1ee1938074:/black.py diff --git a/black.py b/black.py index f389176..e59a1e5 100644 --- a/black.py +++ b/black.py @@ -2,7 +2,7 @@ import asyncio import pickle from asyncio.base_events import BaseEventLoop from concurrent.futures import Executor, ProcessPoolExecutor -from enum import Enum +from enum import Enum, Flag from functools import partial, wraps import keyword import logging @@ -30,6 +30,7 @@ from typing import ( Type, TypeVar, Union, + cast, ) from appdirs import user_cache_dir @@ -43,11 +44,16 @@ from blib2to3.pgen2 import driver, token from blib2to3.pgen2.parse import ParseError -__version__ = "18.4a6" +__version__ = "18.5b1" DEFAULT_LINE_LENGTH = 88 +DEFAULT_EXCLUDES = ( + r"/(\.git|\.hg|\.mypy_cache|\.tox|\.venv|_build|buck-out|build|dist)/" +) +DEFAULT_INCLUDES = r"\.pyi?$" +CACHE_DIR = Path(user_cache_dir("black", version=__version__)) + # types -syms = pygram.python_symbols FileContent = str Encoding = str Depth = int @@ -64,6 +70,9 @@ Cache = Dict[Path, CacheInfo] out = partial(click.secho, bold=True, err=True) err = partial(click.secho, fg="red", err=True) +pygram.initialize(CACHE_DIR) +syms = pygram.python_symbols + class NothingChanged(UserWarning): """Raised by :func:`format_file` when reformatted code is the same as source.""" @@ -110,6 +119,13 @@ class WriteBack(Enum): YES = 1 DIFF = 2 + @classmethod + def from_configuration(cls, *, check: bool, diff: bool) -> "WriteBack": + if check and not diff: + return cls.NO + + return cls.DIFF if diff else cls.YES + class Changed(Enum): NO = 0 @@ -117,6 +133,26 @@ class Changed(Enum): YES = 2 +class FileMode(Flag): + AUTO_DETECT = 0 + PYTHON36 = 1 + PYI = 2 + NO_STRING_NORMALIZATION = 4 + + @classmethod + def from_configuration( + cls, *, py36: bool, pyi: bool, skip_string_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 + return mode + + @click.command() @click.option( "-l", @@ -126,6 +162,29 @@ class Changed(Enum): help="How many character per line to allow.", show_default=True, ) +@click.option( + "--py36", + 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]" + ), +) +@click.option( + "--pyi", + is_flag=True, + help=( + "Format all input files like typing stubs regardless of file extension " + "(useful when piping source on standard input)." + ), +) +@click.option( + "-S", + "--skip-string-normalization", + is_flag=True, + help="Don't normalize string quotes or prefixes.", +) @click.option( "--check", is_flag=True, @@ -145,6 +204,31 @@ class Changed(Enum): is_flag=True, help="If --fast given, skip temporary sanity checks. [default: --safe]", ) +@click.option( + "--include", + type=str, + default=DEFAULT_INCLUDES, + help=( + "A regular expression that matches files and directories that should be " + "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, +) +@click.option( + "--exclude", + type=str, + default=DEFAULT_EXCLUDES, + help=( + "A regular expression that matches files and directories that should be " + "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", @@ -154,6 +238,15 @@ class Changed(Enum): "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", @@ -169,44 +262,72 @@ def main( check: bool, diff: bool, fast: bool, + pyi: bool, + py36: bool, + skip_string_normalization: bool, quiet: bool, + verbose: bool, + include: str, + exclude: str, src: List[str], ) -> None: """The uncompromising code formatter.""" + write_back = WriteBack.from_configuration(check=check, diff=diff) + mode = FileMode.from_configuration( + py36=py36, pyi=pyi, skip_string_normalization=skip_string_normalization + ) + report = Report(check=check, quiet=quiet, verbose=verbose) sources: List[Path] = [] + try: + include_regex = re.compile(include) + except re.error: + err(f"Invalid regular expression for include given: {include!r}") + ctx.exit(2) + try: + exclude_regex = re.compile(exclude) + except re.error: + err(f"Invalid regular expression for exclude given: {exclude!r}") + ctx.exit(2) + root = find_project_root(src) for s in src: p = Path(s) if p.is_dir(): - sources.extend(gen_python_files_in_dir(p)) - elif p.is_file(): + sources.extend( + 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("-")) 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 - report = Report(check=check, quiet=quiet) if len(sources) == 0: out("No paths given. Nothing to do 😴") ctx.exit(0) return elif len(sources) == 1: - reformat_one(sources[0], line_length, fast, write_back, report) + reformat_one( + src=sources[0], + line_length=line_length, + fast=fast, + write_back=write_back, + mode=mode, + report=report, + ) else: loop = asyncio.get_event_loop() executor = ProcessPoolExecutor(max_workers=os.cpu_count()) try: loop.run_until_complete( schedule_formatting( - sources, line_length, fast, write_back, report, loop, executor + sources=sources, + line_length=line_length, + fast=fast, + write_back=write_back, + mode=mode, + report=report, + loop=loop, + executor=executor, ) ) finally: @@ -218,33 +339,43 @@ def main( def reformat_one( - src: Path, line_length: int, fast: bool, write_back: WriteBack, report: "Report" + src: Path, + line_length: int, + fast: bool, + write_back: WriteBack, + mode: FileMode, + report: "Report", ) -> None: """Reformat a single file under `src` without spawning child processes. If `quiet` is True, non-error messages are not output. `line_length`, - `write_back`, and `fast` options are passed to :func:`format_file_in_place`. + `write_back`, `fast` and `pyi` options are passed to + :func:`format_file_in_place` or :func:`format_stdin_to_stdout`. """ try: changed = Changed.NO if not src.is_file() and str(src) == "-": if format_stdin_to_stdout( - line_length=line_length, fast=fast, write_back=write_back + line_length=line_length, fast=fast, write_back=write_back, mode=mode ): changed = Changed.YES else: cache: Cache = {} if write_back != WriteBack.DIFF: - cache = read_cache(line_length) - src = src.resolve() - if src in cache and cache[src] == get_cache_info(src): + cache = read_cache(line_length, mode) + 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, line_length=line_length, fast=fast, write_back=write_back + src, + line_length=line_length, + fast=fast, + write_back=write_back, + mode=mode, ): changed = Changed.YES if write_back == WriteBack.YES and changed is not Changed.NO: - write_cache(cache, [src], line_length) + write_cache(cache, [src], line_length, mode) report.done(src, changed) except Exception as exc: report.failed(src, str(exc)) @@ -255,6 +386,7 @@ async def schedule_formatting( line_length: int, fast: bool, write_back: WriteBack, + mode: FileMode, report: "Report", loop: BaseEventLoop, executor: Executor, @@ -263,12 +395,12 @@ async def schedule_formatting( (Use ProcessPoolExecutors for actual parallelism.) - `line_length`, `write_back`, and `fast` options are passed to + `line_length`, `write_back`, `fast`, and `pyi` options are passed to :func:`format_file_in_place`. """ cache: Cache = {} if write_back != WriteBack.DIFF: - cache = read_cache(line_length) + cache = read_cache(line_length, mode) sources, cached = filter_cached(cache, sources) for src in cached: report.done(src, Changed.CACHED) @@ -283,7 +415,14 @@ async def schedule_formatting( lock = manager.Lock() tasks = { loop.run_in_executor( - executor, format_file_in_place, src, line_length, fast, write_back, lock + executor, + format_file_in_place, + src, + line_length, + fast, + write_back, + mode, + lock, ): src for src in sorted(sources) } @@ -308,7 +447,7 @@ async def schedule_formatting( if cancelled: await asyncio.gather(*cancelled, loop=loop, return_exceptions=True) if write_back == WriteBack.YES and formatted: - write_cache(cache, formatted, line_length) + write_cache(cache, formatted, line_length, mode) def format_file_in_place( @@ -316,6 +455,7 @@ def format_file_in_place( line_length: int, fast: bool, write_back: WriteBack = WriteBack.NO, + mode: FileMode = FileMode.AUTO_DETECT, lock: Any = None, # multiprocessing.Manager().Lock() is some crazy proxy ) -> bool: """Format file under `src` path. Return True if changed. @@ -323,13 +463,13 @@ def format_file_in_place( If `write_back` is True, write reformatted code back to stdout. `line_length` and `fast` options are passed to :func:`format_file_contents`. """ - is_pyi = src.suffix == ".pyi" - + if src.suffix == ".pyi": + mode |= FileMode.PYI with tokenize.open(src) as src_buffer: src_contents = src_buffer.read() try: dst_contents = format_file_contents( - src_contents, line_length=line_length, fast=fast, is_pyi=is_pyi + src_contents, line_length=line_length, fast=fast, mode=mode ) except NothingChanged: return False @@ -352,17 +492,21 @@ def format_file_in_place( def format_stdin_to_stdout( - line_length: int, fast: bool, write_back: WriteBack = WriteBack.NO + line_length: int, + fast: bool, + write_back: WriteBack = WriteBack.NO, + mode: FileMode = FileMode.AUTO_DETECT, ) -> bool: """Format file on stdin. Return True if changed. If `write_back` is True, write reformatted code back to stdout. - `line_length` and `fast` arguments are passed to :func:`format_file_contents`. + `line_length`, `fast`, `is_pyi`, and `force_py36` arguments are passed to + :func:`format_file_contents`. """ src = sys.stdin.read() dst = src try: - dst = format_file_contents(src, line_length=line_length, fast=fast) + dst = format_file_contents(src, line_length=line_length, fast=fast, mode=mode) return True except NothingChanged: @@ -378,7 +522,11 @@ def format_stdin_to_stdout( def format_file_contents( - src_contents: str, *, line_length: int, fast: bool, is_pyi: bool = False + src_contents: str, + *, + line_length: int, + fast: bool, + mode: FileMode = FileMode.AUTO_DETECT, ) -> FileContent: """Reformat contents a file and return new contents. @@ -389,20 +537,18 @@ def format_file_contents( if src_contents.strip() == "": raise NothingChanged - dst_contents = format_str(src_contents, line_length=line_length, is_pyi=is_pyi) + dst_contents = format_str(src_contents, line_length=line_length, mode=mode) if src_contents == dst_contents: raise NothingChanged if not fast: assert_equivalent(src_contents, dst_contents) - assert_stable( - src_contents, dst_contents, line_length=line_length, is_pyi=is_pyi - ) + assert_stable(src_contents, dst_contents, line_length=line_length, mode=mode) return dst_contents def format_str( - src_contents: str, line_length: int, *, is_pyi: bool = False + src_contents: str, line_length: int, *, mode: FileMode = FileMode.AUTO_DETECT ) -> FileContent: """Reformat a string and return new contents. @@ -411,11 +557,15 @@ def format_str( src_node = lib2to3_parse(src_contents) dst_contents = "" future_imports = get_future_imports(src_node) - elt = EmptyLineTracker(is_pyi=is_pyi) - py36 = is_python36(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) lines = LineGenerator( - remove_u_prefix=py36 or "unicode_literals" in future_imports, is_pyi=is_pyi + remove_u_prefix=py36 or "unicode_literals" in future_imports, + is_pyi=is_pyi, + normalize_strings=normalize_strings, ) + elt = EmptyLineTracker(is_pyi=is_pyi) empty_line = Line() after = 0 for current_line in lines.visit(src_node): @@ -626,21 +776,22 @@ LOGIC_PRIORITY = 14 STRING_PRIORITY = 12 COMPARATOR_PRIORITY = 10 MATH_PRIORITIES = { - token.VBAR: 8, - token.CIRCUMFLEX: 7, - token.AMPER: 6, - token.LEFTSHIFT: 5, - token.RIGHTSHIFT: 5, - token.PLUS: 4, - token.MINUS: 4, - token.STAR: 3, - token.SLASH: 3, - token.DOUBLESLASH: 3, - token.PERCENT: 3, - token.AT: 3, - token.TILDE: 2, - token.DOUBLESTAR: 1, + token.VBAR: 9, + token.CIRCUMFLEX: 8, + token.AMPER: 7, + token.LEFTSHIFT: 6, + token.RIGHTSHIFT: 6, + token.PLUS: 5, + token.MINUS: 5, + token.STAR: 4, + token.SLASH: 4, + token.DOUBLESLASH: 4, + token.PERCENT: 4, + token.AT: 4, + token.TILDE: 3, + token.DOUBLESTAR: 2, } +DOT_PRIORITY = 1 @dataclass @@ -706,6 +857,17 @@ class BracketTracker: """ return max(v for k, v in self.delimiters.items() if k not in exclude) + def delimiter_count_with_priority(self, priority: int = 0) -> int: + """Return the number of delimiters with the given `priority`. + + If no `priority` is passed, defaults to max priority on the line. + """ + if not self.delimiters: + return 0 + + priority = priority or self.max_delimiter_priority() + return sum(1 for p in self.delimiters.values() if p == priority) + def maybe_increment_for_loop_variable(self, leaf: Leaf) -> bool: """In a for loop, or comprehension, the variables are often unpacks. @@ -764,6 +926,7 @@ class Line: comments: List[Tuple[Index, Leaf]] = Factory(list) bracket_tracker: BracketTracker = Factory(BracketTracker) inside_brackets: bool = False + should_explode: bool = False def append(self, leaf: Leaf, preformatted: bool = False) -> None: """Add a new `leaf` to the end of the line. @@ -837,10 +1000,9 @@ class Line: @property def is_stub_class(self) -> bool: """Is this line a class definition with a body consisting only of "..."?""" - return ( - self.is_class - and self.leaves[-3:] == [Leaf(token.DOT, ".") for _ in range(3)] - ) + return self.is_class and self.leaves[-3:] == [ + Leaf(token.DOT, ".") for _ in range(3) + ] @property def is_def(self) -> bool: @@ -861,27 +1023,6 @@ class Line: and second_leaf.value == "def" ) - @property - def is_flow_control(self) -> bool: - """Is this line a flow control statement? - - Those are `return`, `raise`, `break`, and `continue`. - """ - return ( - bool(self) - and self.leaves[0].type == token.NAME - and self.leaves[0].value in FLOW_CONTROL - ) - - @property - def is_yield(self) -> bool: - """Is this line a yield statement?""" - return ( - bool(self) - and self.leaves[0].type == token.NAME - and self.leaves[0].value == "yield" - ) - @property def is_class_paren_empty(self) -> bool: """Is this a class with no base classes but using parentheses? @@ -898,6 +1039,15 @@ class Line: and self.leaves[3].value == ")" ) + @property + def is_triple_quoted_string(self) -> bool: + """Is the line a triple quoted string?""" + return ( + bool(self) + and self.leaves[0].type == token.STRING + and self.leaves[0].value.startswith(('"""', "'''")) + ) + def contains_standalone_comments(self, depth_limit: int = sys.maxsize) -> bool: """If so, needs to be split before emitting.""" for leaf in self.leaves: @@ -1105,6 +1255,7 @@ class EmptyLineTracker: the prefix of the first leaf consists of optional newlines. Those newlines are consumed by `maybe_empty_lines()` and included in the computation. """ + is_pyi: bool = False previous_line: Optional[Line] = None previous_after: int = 0 @@ -1114,8 +1265,7 @@ class EmptyLineTracker: """Return the number of extra empty lines before and after the `current_line`. This is for separating `def`, `async def` and `class` with extra empty - lines (two on module-level), as well as providing an extra empty line - after flow control keywords to make them more prominent. + lines (two on module-level). """ if isinstance(current_line, UnformattedLines): return 0, 0 @@ -1156,6 +1306,11 @@ class EmptyLineTracker: 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 @@ -1187,6 +1342,13 @@ class EmptyLineTracker: ): return (before or 1), 0 + if ( + self.previous_line + and self.previous_line.is_class + and current_line.is_triple_quoted_string + ): + return before, 1 + return before, 0 @@ -1197,7 +1359,9 @@ class LineGenerator(Visitor[Line]): Note: destroys the tree it's visiting by mutating prefixes of its leaves in ways that will no longer stringify to valid Python code on the tree. """ + is_pyi: bool = False + normalize_strings: bool = True current_line: Line = Factory(Line) remove_u_prefix: bool = False @@ -1266,7 +1430,7 @@ class LineGenerator(Visitor[Line]): else: normalize_prefix(node, inside_brackets=any_open_brackets) - if node.type == token.STRING: + 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: @@ -1304,7 +1468,7 @@ class LineGenerator(Visitor[Line]): The relevant Python language `keywords` for a given statement will be NAME leaves within it. This methods puts those on a separate line. - `parens` holds a set of string leaf values immeditely after which + `parens` holds a set of string leaf values immediately after which invisible parens should be put. """ normalize_invisible_parens(node, parens_after=parens) @@ -1358,32 +1522,6 @@ class LineGenerator(Visitor[Line]): yield from self.line() yield from self.visit(child) - def visit_import_from(self, node: Node) -> Iterator[Line]: - """Visit import_from and maybe put invisible parentheses. - - This is separate from `visit_stmt` because import statements don't - support arbitrary atoms and thus handling of parentheses is custom. - """ - check_lpar = False - for index, child in enumerate(node.children): - if check_lpar: - if child.type == token.LPAR: - # make parentheses invisible - child.value = "" # type: ignore - node.children[-1].value = "" # type: ignore - else: - # insert invisible parentheses - node.insert_child(index, Leaf(token.LPAR, "")) - node.append_child(Leaf(token.RPAR, "")) - break - - check_lpar = ( - child.type == token.NAME and child.value == "import" # type: ignore - ) - - for child in node.children: - yield from self.visit(child) - def visit_SEMI(self, leaf: Leaf) -> Iterator[Line]: """Remove a semicolon and put the other statement on a separate line.""" yield from self.line() @@ -1430,6 +1568,7 @@ class LineGenerator(Visitor[Line]): self.visit_classdef = partial(v, keywords={"class"}, parens=Ø) self.visit_expr_stmt = partial(v, keywords=Ø, parens=ASSIGNMENTS) self.visit_return_stmt = partial(v, keywords={"return"}, parens={"return"}) + self.visit_import_from = partial(v, keywords=Ø, parens={"import"}) self.visit_async_funcdef = self.visit_async_stmt self.visit_decorated = self.visit_decorators @@ -1461,10 +1600,11 @@ def whitespace(leaf: Leaf, *, complex_subscript: bool) -> str: # noqa C901 return DOUBLESPACE assert p is not None, f"INTERNAL ERROR: hand-made leaf without parent: {leaf!r}" - if ( - t == token.COLON - and p.type not in {syms.subscript, syms.subscriptlist, syms.sliceop} - ): + if t == token.COLON and p.type not in { + syms.subscript, + syms.subscriptlist, + syms.sliceop, + }: return NO prev = leaf.prev_sibling @@ -1485,7 +1625,10 @@ def whitespace(leaf: Leaf, *, complex_subscript: bool) -> str: # noqa C901 if prevp.type == token.EQUAL: if prevp.parent: if prevp.parent.type in { - syms.arglist, syms.argument, syms.parameters, syms.varargslist + syms.arglist, + syms.argument, + syms.parameters, + syms.varargslist, }: return NO @@ -1638,10 +1781,10 @@ def whitespace(leaf: Leaf, *, complex_subscript: bool) -> str: # noqa C901 prevp_parent = prevp.parent assert prevp_parent is not None - if ( - prevp.type == token.COLON - and prevp_parent.type in {syms.subscript, syms.sliceop} - ): + if prevp.type == token.COLON and prevp_parent.type in { + syms.subscript, + syms.sliceop, + }: return NO elif prevp.type == token.EQUAL and prevp_parent.type == syms.argument: @@ -1721,6 +1864,14 @@ def is_split_before_delimiter(leaf: Leaf, previous: Leaf = None) -> int: # Don't treat them as a delimiter. return 0 + if ( + leaf.type == token.DOT + and leaf.parent + and leaf.parent.type not in {syms.import_from, syms.dotted_name} + and (previous is None or previous.type in CLOSING_BRACKETS) + ): + return DOT_PRIORITY + if ( leaf.type in MATH_OPERATORS and leaf.parent @@ -1738,37 +1889,60 @@ def is_split_before_delimiter(leaf: Leaf, previous: Leaf = None) -> int: ): return STRING_PRIORITY + if leaf.type != token.NAME: + return 0 + if ( - leaf.type == token.NAME - and leaf.value == "for" + leaf.value == "for" and leaf.parent and leaf.parent.type in {syms.comp_for, syms.old_comp_for} ): return COMPREHENSION_PRIORITY if ( - leaf.type == token.NAME - and leaf.value == "if" + leaf.value == "if" and leaf.parent and leaf.parent.type in {syms.comp_if, syms.old_comp_if} ): return COMPREHENSION_PRIORITY + if leaf.value in {"if", "else"} and leaf.parent and leaf.parent.type == syms.test: + return TERNARY_PRIORITY + + if leaf.value == "is": + return COMPARATOR_PRIORITY + if ( - leaf.type == token.NAME - and leaf.value in {"if", "else"} + leaf.value == "in" and leaf.parent - and leaf.parent.type == syms.test + and leaf.parent.type in {syms.comp_op, syms.comparison} + and not ( + previous is not None + and previous.type == token.NAME + and previous.value == "not" + ) ): - return TERNARY_PRIORITY + return COMPARATOR_PRIORITY + + if ( + leaf.value == "not" + and leaf.parent + and leaf.parent.type == syms.comp_op + and not ( + previous is not None + and previous.type == token.NAME + and previous.value == "is" + ) + ): + return COMPARATOR_PRIORITY - if leaf.type == token.NAME and leaf.value in LOGIC_OPERATORS and leaf.parent: + if leaf.value in LOGIC_OPERATORS and leaf.parent: return LOGIC_PRIORITY return 0 -def generate_comments(leaf: Leaf) -> Iterator[Leaf]: +def generate_comments(leaf: LN) -> Iterator[Leaf]: """Clean the prefix of the `leaf` and generate comments from it, if any. Comments in lib2to3 are shoved into the whitespace prefix. This happens @@ -1862,25 +2036,27 @@ def split_line( return line_str = str(line).strip("\n") - if is_line_short_enough(line, line_length=line_length, line_str=line_str): + if not line.should_explode and is_line_short_enough( + line, line_length=line_length, line_str=line_str + ): yield line return split_funcs: List[SplitFunc] if line.is_def: split_funcs = [left_hand_split] - elif line.is_import: - split_funcs = [explode_split] else: def rhs(line: Line, py36: bool = False) -> Iterator[Line]: for omit in generate_trailers_to_omit(line, line_length): - lines = list(right_hand_split(line, py36, omit=omit)) + lines = list(right_hand_split(line, line_length, py36, omit=omit)) if is_line_short_enough(lines[0], line_length=line_length): yield from lines return # All splits failed, best effort split with no omits. + # This mostly happens to multiline strings that are by definition + # reported as not fitting a single line. yield from right_hand_split(line, py36) if line.inside_brackets: @@ -1954,7 +2130,7 @@ def left_hand_split(line: Line, py36: bool = False) -> Iterator[Line]: def right_hand_split( - line: Line, py36: bool = False, omit: Collection[LeafID] = () + line: Line, line_length: int, py36: bool = False, omit: Collection[LeafID] = () ) -> Iterator[Line]: """Split line into many lines, starting with the last matching bracket pair. @@ -2016,23 +2192,16 @@ def right_hand_split( and not line.is_import ): omit = {id(closing_bracket), *omit} - delimiter_count = len(body.bracket_tracker.delimiters) - if ( - delimiter_count == 0 - or delimiter_count == 1 - and ( - body.leaves[0].type in OPENING_BRACKETS - or body.leaves[-1].type in CLOSING_BRACKETS - ) - ): + if can_omit_invisible_parens(body, line_length): try: - yield from right_hand_split(line, py36=py36, omit=omit) + yield from right_hand_split(line, line_length, py36=py36, omit=omit) return except CannotSplit: pass 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 @@ -2091,14 +2260,16 @@ def delimiter_split(line: Line, py36: bool = False) -> Iterator[Line]: except IndexError: raise CannotSplit("Line empty") - delimiters = line.bracket_tracker.delimiters + bt = line.bracket_tracker try: - delimiter_priority = line.bracket_tracker.max_delimiter_priority( - exclude={id(last_leaf)} - ) + delimiter_priority = bt.max_delimiter_priority(exclude={id(last_leaf)}) except ValueError: raise CannotSplit("No delimiters found") + if delimiter_priority == DOT_PRIORITY: + if bt.delimiter_count_with_priority(delimiter_priority) == 1: + raise CannotSplit("Splitting a single attribute from its owner looks wrong") + current_line = Line(depth=line.depth, inside_brackets=line.inside_brackets) lowest_depth = sys.maxsize trailing_comma_safe = True @@ -2121,12 +2292,11 @@ def delimiter_split(line: Line, py36: bool = False) -> Iterator[Line]: yield from append_to_line(comment_after) lowest_depth = min(lowest_depth, leaf.bracket_depth) - if ( - leaf.bracket_depth == lowest_depth - and is_vararg(leaf, within=VARARGS_PARENTS) + if leaf.bracket_depth == lowest_depth and is_vararg( + leaf, within=VARARGS_PARENTS ): trailing_comma_safe = trailing_comma_safe and py36 - leaf_priority = delimiters.get(id(leaf)) + leaf_priority = bt.delimiters.get(id(leaf)) if leaf_priority == delimiter_priority: yield current_line @@ -2171,26 +2341,6 @@ def standalone_comment_split(line: Line, py36: bool = False) -> Iterator[Line]: yield current_line -def explode_split( - line: Line, py36: bool = False, omit: Collection[LeafID] = () -) -> Iterator[Line]: - """Split by rightmost bracket and immediately split contents by a delimiter.""" - new_lines = list(right_hand_split(line, py36, omit)) - if len(new_lines) != 3: - yield from new_lines - return - - yield new_lines[0] - - try: - yield from delimiter_split(new_lines[1], py36) - - except CannotSplit: - yield new_lines[1] - - yield new_lines[2] - - def is_import(leaf: Leaf) -> bool: """Return True if the given leaf starts an import statement.""" p = leaf.parent @@ -2309,8 +2459,13 @@ 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. + check_lpar = False - for child in list(node.children): + for index, child in enumerate(list(node.children)): if check_lpar: if child.type == syms.atom: maybe_make_parens_invisible_in_atom(child) @@ -2318,9 +2473,22 @@ def normalize_invisible_parens(node: Node, parens_after: Set[str]) -> None: # wrap child in visible parentheses lpar = Leaf(token.LPAR, "(") rpar = Leaf(token.RPAR, ")") - index = child.remove() or 0 + child.remove() node.insert_child(index, Node(syms.atom, [lpar, child, rpar])) - else: + elif node.type == syms.import_from: + # "import from" nodes store parentheses directly as part of + # the statement + if child.type == token.LPAR: + # make parentheses invisible + child.value = "" # type: ignore + node.children[-1].value = "" # type: ignore + elif child.type != token.STAR: + # insert invisible parentheses + node.insert_child(index, Leaf(token.LPAR, "")) + node.append_child(Leaf(token.RPAR, "")) + break + + elif not (isinstance(child, Leaf) and is_multiline_string(child)): # wrap child in invisible parentheses lpar = Leaf(token.LPAR, "") rpar = Leaf(token.RPAR, "") @@ -2431,6 +2599,12 @@ def is_vararg(leaf: Leaf, within: Set[NodeType]) -> bool: return p.type in within +def is_multiline_string(leaf: Leaf) -> bool: + """Return True if `leaf` is a multiline string that actually spans many lines.""" + value = leaf.value.lstrip("furbFURB") + return value[:3] in {'"""', "'''"} and "\n" in value + + def is_stub_suite(node: Node) -> bool: """Return True if `node` is a suite with a stub body.""" if ( @@ -2500,6 +2674,25 @@ def ensure_visible(leaf: Leaf) -> None: leaf.value = ")" +def should_explode(line: Line, opening_bracket: Leaf) -> bool: + """Should `line` immediately be split with `delimiter_split()` after RHS?""" + if not ( + opening_bracket.parent + and opening_bracket.parent.type in {syms.atom, syms.import_from} + and opening_bracket.value in "[{(" + ): + return False + + try: + last_leaf = line.leaves[-1] + exclude = {id(last_leaf)} if last_leaf.type == token.COMMA else set() + max_priority = line.bracket_tracker.max_delimiter_priority(exclude=exclude) + except (IndexError, ValueError): + return False + + return max_priority == COMMA_PRIORITY + + def is_python36(node: Node) -> bool: """Return True if the current file is using Python 3.6+ features. @@ -2548,21 +2741,14 @@ def generate_trailers_to_omit(line: Line, line_length: int) -> Iterator[Set[Leaf closing_bracket = None optional_brackets: Set[LeafID] = set() inner_brackets: Set[LeafID] = set() - for index, leaf in enumerate_reversed(line.leaves): - length += len(leaf.prefix) + len(leaf.value) + for index, leaf, leaf_length in enumerate_with_length(line, reversed=True): + length += leaf_length if length > line_length: break - comment: Optional[Leaf] - for comment in line.comments_after(leaf, index): - if "\n" in comment.prefix: - break # Oops, standalone comment! - - length += len(comment.value) - else: - comment = None - if comment is not None: - break # There was a standalone comment, we can't continue. + has_inline_comment = leaf_length > len(leaf.value) + len(leaf.prefix) + if leaf.type == STANDALONE_COMMENT or has_inline_comment: + break optional_brackets.discard(id(leaf)) if opening_bracket: @@ -2626,32 +2812,73 @@ def get_future_imports(node: Node) -> Set[str]: return imports -PYTHON_EXTENSIONS = {".py", ".pyi"} -BLACKLISTED_DIRECTORIES = { - "build", "buck-out", "dist", "_build", ".git", ".hg", ".mypy_cache", ".tox", ".venv" -} - +def gen_python_files_in_dir( + 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. -def gen_python_files_in_dir(path: Path) -> Iterator[Path]: - """Generate all files under `path` which aren't under BLACKLISTED_DIRECTORIES - and have one of the PYTHON_EXTENSIONS. + `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(): + normalized_path = "/" + child.resolve().relative_to(root).as_posix() if child.is_dir(): - if child.name in BLACKLISTED_DIRECTORIES: - continue + normalized_path += "/" + exclude_match = exclude.search(normalized_path) + if exclude_match and exclude_match.group(0): + report.path_ignored(child, f"matches --exclude={exclude.pattern}") + 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 + + +def find_project_root(srcs: List[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() - yield from gen_python_files_in_dir(child) + 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 - elif child.is_file() and child.suffix in PYTHON_EXTENSIONS: - yield child + 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 @@ -2660,11 +2887,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: @@ -2677,6 +2904,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. @@ -2789,9 +3020,11 @@ def assert_equivalent(src: str, dst: str) -> None: ) from None -def assert_stable(src: str, dst: str, line_length: int, is_pyi: bool = False) -> None: +def assert_stable( + src: str, dst: str, line_length: int, mode: FileMode = FileMode.AUTO_DETECT +) -> None: """Raise AssertionError if `dst` reformats differently the second time.""" - newdst = format_str(dst, line_length=line_length, is_pyi=is_pyi) + newdst = format_str(dst, line_length=line_length, mode=mode) if dst != newdst: log = dump_to_file( diff(src, dst, "source", "first pass"), @@ -2876,6 +3109,29 @@ def enumerate_reversed(sequence: Sequence[T]) -> Iterator[Tuple[Index, T]]: index -= 1 +def enumerate_with_length( + line: Line, reversed: bool = False +) -> Iterator[Tuple[Index, Leaf, int]]: + """Return an enumeration of leaves with their length. + + Stops prematurely on multiline strings and standalone comments. + """ + op = cast( + Callable[[Sequence[Leaf]], Iterator[Tuple[Index, Leaf]]], + enumerate_reversed if reversed else enumerate, + ) + for index, leaf in op(line.leaves): + length = len(leaf.prefix) + len(leaf.value) + if "\n" in leaf.value: + return # Multiline strings, we can't continue. + + comment: Optional[Leaf] + for comment in line.comments_after(leaf, index): + length += len(comment.value) + + yield index, leaf, length + + def is_line_short_enough(line: Line, *, line_length: int, line_str: str = "") -> bool: """Return True if `line` is no longer than `line_length`. @@ -2890,19 +3146,110 @@ def is_line_short_enough(line: Line, *, line_length: int, line_str: str = "") -> ) -CACHE_DIR = Path(user_cache_dir("black", version=__version__)) +def can_omit_invisible_parens(line: Line, line_length: int) -> bool: + """Does `line` have a shape safe to reformat without optional parens around it? + + Returns True for only a subset of potentially nice looking formattings but + the point is to not return false positives that end up producing lines that + are too long. + """ + bt = line.bracket_tracker + if not bt.delimiters: + # Without delimiters the optional parentheses are useless. + return True + + max_priority = bt.max_delimiter_priority() + if bt.delimiter_count_with_priority(max_priority) > 1: + # With more than one delimiter of a kind the optional parentheses read better. + return False + + if max_priority == DOT_PRIORITY: + # A single stranded method call doesn't require optional parentheses. + return True + + assert len(line.leaves) >= 2, "Stranded delimiter" + + first = line.leaves[0] + second = line.leaves[1] + penultimate = line.leaves[-2] + last = line.leaves[-1] + + # With a single delimiter, omit if the expression starts or ends with + # a bracket. + if first.type in OPENING_BRACKETS and second.type not in CLOSING_BRACKETS: + remainder = False + length = 4 * line.depth + for _index, leaf, leaf_length in enumerate_with_length(line): + if leaf.type in CLOSING_BRACKETS and leaf.opening_bracket is first: + remainder = True + if remainder: + length += leaf_length + if length > line_length: + break + + if leaf.type in OPENING_BRACKETS: + # There are brackets we can further split on. + remainder = False + + else: + # checked the entire string and line length wasn't exceeded + if len(line.leaves) == _index + 1: + return True + + # Note: we are not returning False here because a line might have *both* + # a leading opening bracket and a trailing closing bracket. If the + # opening bracket doesn't match our rule, maybe the closing will. + if ( + last.type == token.RPAR + or last.type == token.RBRACE + or ( + # don't use indexing for omitting optional parentheses; + # it looks weird + last.type == token.RSQB + and last.parent + and last.parent.type != syms.trailer + ) + ): + if penultimate.type in OPENING_BRACKETS: + # Empty brackets don't help. + return False -def get_cache_file(line_length: int) -> Path: - return CACHE_DIR / f"cache.{line_length}.pickle" + if is_multiline_string(first): + # Additional wrapping of a multiline string in this situation is + # unnecessary. + return True + + length = 4 * line.depth + seen_other_brackets = False + for _index, leaf, leaf_length in enumerate_with_length(line): + length += leaf_length + if leaf is last.opening_bracket: + if seen_other_brackets or length <= line_length: + return True + elif leaf.type in OPENING_BRACKETS: + # There are brackets we can further split on. + seen_other_brackets = True -def read_cache(line_length: int) -> Cache: + return False + + +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" + ) + + +def read_cache(line_length: int, mode: FileMode) -> Cache: """Read the cache if it exists and is well formed. If it is not well formed, the call to write_cache later should resolve the issue. """ - cache_file = get_cache_file(line_length) + cache_file = get_cache_file(line_length, mode) if not cache_file.exists(): return {} @@ -2939,9 +3286,11 @@ def filter_cached( return todo, done -def write_cache(cache: Cache, sources: List[Path], line_length: int) -> None: +def write_cache( + cache: Cache, sources: List[Path], line_length: int, mode: FileMode +) -> None: """Update the cache file.""" - cache_file = get_cache_file(line_length) + cache_file = get_cache_file(line_length, mode) try: if not CACHE_DIR.exists(): CACHE_DIR.mkdir(parents=True)