Callable,
Collection,
Dict,
+ Generator,
Generic,
Iterable,
Iterator,
Sequence,
Set,
Tuple,
- Type,
TypeVar,
Union,
cast,
from blib2to3.pgen2.parse import ParseError
-__version__ = "18.6b2"
+__version__ = "18.6b4"
DEFAULT_LINE_LENGTH = 88
DEFAULT_EXCLUDES = (
r"/(\.git|\.hg|\.mypy_cache|\.tox|\.venv|_build|buck-out|build|dist)/"
"""
-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)
-
-
-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 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.NO
+ return cls.CHECK
return cls.DIFF if diff else cls.YES
mode=mode,
):
changed = Changed.YES
- if write_back == WriteBack.YES and changed is not Changed.NO:
+ if write_back is WriteBack.YES:
+ should_write = changed is not Changed.CACHED
+ elif write_back is WriteBack.CHECK:
+ should_write = changed is Changed.NO
+ else:
+ should_write = False
+
+ if should_write:
write_cache(cache, [src], line_length, mode)
report.done(src, changed)
except Exception as exc:
elif task.exception():
report.failed(src, str(task.exception()))
else:
- formatted.append(src)
- report.done(src, Changed.YES if task.result() else Changed.NO)
+ changed = Changed.YES if task.result() else Changed.NO
+ # In normal mode, write all files to the cache.
+ if write_back is WriteBack.YES:
+ formatted.append(src)
+ # In check mode, write only unchanged files to the cache.
+ elif write_back is WriteBack.CHECK and changed is Changed.NO:
+ formatted.append(src)
+ report.done(src, changed)
if cancelled:
await asyncio.gather(*cancelled, loop=loop, return_exceptions=True)
- if write_back == WriteBack.YES and formatted:
+ if write_back in (WriteBack.YES, WriteBack.CHECK) and formatted:
write_cache(cache, formatted, line_length, mode)
) -> 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":
) -> 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`.
"""
remove_u_prefix=py36 or "unicode_literals" in future_imports,
is_pyi=is_pyi,
normalize_strings=normalize_strings,
+ allow_underscores=py36,
)
elt = EmptyLineTracker(is_pyi=is_pyi)
empty_line = Line()
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)
syms.testlist_gexp,
syms.testlist_star_expr,
}
-SURROUNDED_BY_BRACKETS = {
- syms.typedargslist,
- syms.arglist,
- syms.subscriptlist,
- syms.vfplist,
- syms.import_as_names,
- syms.yield_expr,
- syms.testlist_gexp,
- syms.testlist_star_expr,
- syms.listmaker,
- syms.dictsetmaker,
-}
TEST_DESCENDANTS = {
syms.test,
syms.lambdef,
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
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
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.
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]:
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."""
if parent.children[0].prefix != same_prefix:
break
- if parent.type in SURROUNDED_BY_BRACKETS:
+ if parent.type == syms.file_input:
+ break
+
+ if parent.prev_sibling is not None and parent.prev_sibling.type in BRACKETS:
break
container = parent
"""
for pc in list_comments(leaf.prefix, is_endmarker=leaf.type == token.ENDMARKER):
yield Leaf(pc.type, pc.value, prefix="\n" * pc.newlines)
- if pc.value in FMT_ON:
- raise FormatOn(pc.consumed)
-
- if pc.value in FMT_OFF:
- if pc.type == STANDALONE_COMMENT:
- raise FormatOff(pc.consumed)
-
- prev = preceding_leaf(leaf)
- if not prev or prev.type in WHITESPACE: # standalone comment in disguise
- raise FormatOff(pc.consumed)
@dataclass
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
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."""
+ # We want all letters (e in exponents, j in complex literals, a-f
+ # in hex literals) to be lowercase.
+ text = leaf.value.lower()
+ if text.startswith(("0o", "0x", "0b")):
+ # Leave octal, hex, and binary literals alone for now.
+ pass
+ elif "e" in text:
+ before, after = text.split("e")
+ if after.startswith("-"):
+ after = after[1:]
+ sign = "-"
+ elif after.startswith("+"):
+ after = after[1:]
+ sign = ""
+ else:
+ sign = ""
+ before = format_float_or_int_string(before, allow_underscores)
+ after = format_int_string(after, allow_underscores)
+ text = f"{before}e{sign}{after}"
+ # Complex numbers and Python 2 longs
+ elif "j" in text or "l" in text:
+ number = text[:-1]
+ suffix = text[-1]
+ 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"
+ after = format_int_string(after, allow_underscores) if after else "0"
+ return f"{before}.{after}"
+
+
+def format_int_string(text: str, allow_underscores: bool) -> str:
+ """Normalizes underscores in a string to e.g. 1_000_000.
+
+ Input must be a string consisting only of digits and underscores.
+ """
+ if not allow_underscores:
+ return text
+ text = text.replace("_", "")
+ if len(text) <= 6:
+ # No underscores for numbers <= 6 digits long.
+ return text
+ return format(int(text), "3_")
+
+
def normalize_invisible_parens(node: Node, parens_after: Set[str]) -> None:
"""Make existing optional parentheses invisible or create new ones.
def normalize_fmt_off(node: Node) -> None:
- """Allow `# fmt: off`/`# fmt: on` within bracket pairs.
-
- Ignores `# fmt: off` and `# fmt: on` outside of brackets.
-
- Raises :exc:`SyntaxError` if no matching `# fmt: on` is found for a `# fmt: off`
- given inside brackets.
- """
+ """Convert content between `# fmt: off`/`# fmt: on` into standalone comments."""
try_again = True
while try_again:
- try_again = hide_fmt_off(node)
+ try_again = convert_one_fmt_off_pair(node)
-def hide_fmt_off(node: Node) -> bool:
- bt = BracketTracker()
- for leaf in node.leaves():
- bt.mark(leaf)
- if bt.depth == 0:
- continue
+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
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()
)
return True
- previous_consumed += comment.consumed
+ 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:
+ 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
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
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
"""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.
+ Symbolic links pointing outside of the `root` directory are ignored.
`report` is where output about exclusions goes.
"""
except ValueError:
if child.is_symlink():
report.path_ignored(
- child,
- "is a symbolic link that points outside of the root directory",
+ child, f"is a symbolic link that points outside {root}"
)
continue
pass
+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
+
+
if __name__ == "__main__":
+ patch_click()
main()