From 6407ebb870afe0062ee581abdea07c1ef5213d31 Mon Sep 17 00:00:00 2001 From: Evan Chen Date: Sat, 28 Jan 2023 16:12:11 -0800 Subject: [PATCH 1/6] Remove Python version in the_basics.md (#3528) --- docs/contributing/the_basics.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/contributing/the_basics.md b/docs/contributing/the_basics.md index 9325a9e44ed..5fdcdd802bd 100644 --- a/docs/contributing/the_basics.md +++ b/docs/contributing/the_basics.md @@ -4,8 +4,8 @@ An overview on contributing to the _Black_ project. ## Technicalities -Development on the latest version of Python is preferred. As of this writing it's 3.9. -You can use any operating system. +Development on the latest version of Python is preferred. You can use any operating +system. Install development dependencies inside a virtual environment of your choice, for example: From f4ebc683208d095b252b87147d002e925c9c1171 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Mon, 30 Jan 2023 18:45:12 -0800 Subject: [PATCH 2/6] Upgrade isort (#3534) See PyCQA/isort#2077. --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2d5ac26b629..576f6405d6c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,7 +24,7 @@ repos: additional_dependencies: *version_check_dependencies - repo: https://github.com/pycqa/isort - rev: 5.10.1 + rev: 5.12.0 hooks: - id: isort From 226cbf0226ee3bc26972357ba54c36409e9a84ae Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Mon, 30 Jan 2023 18:53:14 -0800 Subject: [PATCH 3/6] Fix unsafe cast in linegen.py w/ await yield handling (#3533) Fixes #3532. --- CHANGES.md | 1 + src/black/linegen.py | 25 ++++++++++++----------- tests/data/preview/remove_await_parens.py | 7 +++++++ 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 2acb31d6ac4..8c6a4f40166 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -42,6 +42,7 @@ - Fix an invalid quote escaping bug in f-string expressions where it produced invalid code. Implicitly concatenated f-strings with different quotes can now be merged or quote-normalized by changing the quotes used in expressions. (#3509) +- Fix crash on `await (yield)` when Black is compiled with mypyc (#3533) ### Configuration diff --git a/src/black/linegen.py b/src/black/linegen.py index bfc28ca006c..9894a39c95f 100644 --- a/src/black/linegen.py +++ b/src/black/linegen.py @@ -1223,18 +1223,19 @@ def remove_await_parens(node: Node) -> None: # N.B. We've still removed any redundant nested brackets though :) opening_bracket = cast(Leaf, node.children[1].children[0]) closing_bracket = cast(Leaf, node.children[1].children[-1]) - bracket_contents = cast(Node, node.children[1].children[1]) - if bracket_contents.type != syms.power: - ensure_visible(opening_bracket) - ensure_visible(closing_bracket) - elif ( - bracket_contents.type == syms.power - and bracket_contents.children[0].type == token.AWAIT - ): - ensure_visible(opening_bracket) - ensure_visible(closing_bracket) - # If we are in a nested await then recurse down. - remove_await_parens(bracket_contents) + bracket_contents = node.children[1].children[1] + if isinstance(bracket_contents, Node): + if bracket_contents.type != syms.power: + ensure_visible(opening_bracket) + ensure_visible(closing_bracket) + elif ( + bracket_contents.type == syms.power + and bracket_contents.children[0].type == token.AWAIT + ): + ensure_visible(opening_bracket) + ensure_visible(closing_bracket) + # If we are in a nested await then recurse down. + remove_await_parens(bracket_contents) def _maybe_wrap_cms_in_parens( diff --git a/tests/data/preview/remove_await_parens.py b/tests/data/preview/remove_await_parens.py index 571210a2d80..8c7223d2f39 100644 --- a/tests/data/preview/remove_await_parens.py +++ b/tests/data/preview/remove_await_parens.py @@ -77,6 +77,9 @@ async def main(): async def main(): await (await (await (await (await (asyncio.sleep(1)))))) +async def main(): + await (yield) + # output import asyncio @@ -167,3 +170,7 @@ async def main(): async def main(): await (await (await (await (await asyncio.sleep(1))))) + + +async def main(): + await (yield) From c4bd2e31ceeac84d68592986fe70920f3d3d0443 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 31 Jan 2023 15:39:56 -0800 Subject: [PATCH 4/6] Draft for Black 2023 stable style (#3418) --- CHANGES.md | 29 ++++++ docs/the_black_code_style/current_style.md | 40 +++++++- docs/the_black_code_style/future_style.md | 90 ----------------- src/black/__init__.py | 9 +- src/black/comments.py | 58 +++++------ src/black/linegen.py | 99 +++++++------------ src/black/lines.py | 38 +++---- src/black/mode.py | 8 -- .../expression_skip_magic_trailing_comma.diff | 3 +- .../return_annotation_brackets_string.py | 12 +++ .../py_310/parenthesized_context_managers.py | 24 +++++ .../remove_newline_after_match.py | 0 tests/data/py_311/pep_654_style.py | 2 +- tests/data/py_38/pep_572_remove_parens.py | 4 +- .../remove_with_brackets.py | 0 tests/data/simple_cases/comments2.py | 1 + tests/data/simple_cases/comments3.py | 5 +- tests/data/simple_cases/comments5.py | 2 + .../{preview => simple_cases}/comments8.py | 0 .../{preview => simple_cases}/comments9.py | 0 .../docstring_preview.py | 0 tests/data/simple_cases/empty_lines.py | 1 - tests/data/simple_cases/fmtonoff.py | 1 + .../simple_cases/function_trailing_comma.py | 16 ++- .../one_element_subscript.py | 0 .../prefer_rhs_split_reformatted.py | 0 .../remove_await_parens.py | 0 .../remove_except_parens.py | 0 .../remove_for_brackets.py | 0 .../remove_newline_after_code_block_open.py | 0 .../return_annotation_brackets.py | 12 --- .../skip_magic_trailing_comma.py | 0 .../trailing_commas_in_leading_parts.py | 0 .../{preview => simple_cases}/whitespace.py | 0 tests/test_black.py | 3 +- tests/test_format.py | 24 +---- 36 files changed, 207 insertions(+), 274 deletions(-) create mode 100644 tests/data/preview/return_annotation_brackets_string.py rename tests/data/{preview_310 => py_310}/remove_newline_after_match.py (100%) rename tests/data/{preview_39 => py_39}/remove_with_brackets.py (100%) rename tests/data/{preview => simple_cases}/comments8.py (100%) rename tests/data/{preview => simple_cases}/comments9.py (100%) rename tests/data/{preview => simple_cases}/docstring_preview.py (100%) rename tests/data/{preview => simple_cases}/one_element_subscript.py (100%) rename tests/data/{preview => simple_cases}/prefer_rhs_split_reformatted.py (100%) rename tests/data/{preview => simple_cases}/remove_await_parens.py (100%) rename tests/data/{preview => simple_cases}/remove_except_parens.py (100%) rename tests/data/{preview => simple_cases}/remove_for_brackets.py (100%) rename tests/data/{preview => simple_cases}/remove_newline_after_code_block_open.py (100%) rename tests/data/{preview => simple_cases}/return_annotation_brackets.py (93%) rename tests/data/{preview => simple_cases}/skip_magic_trailing_comma.py (100%) rename tests/data/{preview => simple_cases}/trailing_commas_in_leading_parts.py (100%) rename tests/data/{preview => simple_cases}/whitespace.py (100%) diff --git a/CHANGES.md b/CHANGES.md index 8c6a4f40166..ecc8a41f505 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,35 @@ +- Introduce the 2023 stable style, which incorporates most aspects of last year's + preview style (#3418). Specific changes: + - Enforce empty lines before classes and functions with sticky leading comments + (#3302) (22.12.0) + - Reformat empty and whitespace-only files as either an empty file (if no newline is + present) or as a single newline character (if a newline is present) (#3348) + (22.12.0) + - Implicitly concatenated strings used as function args are now wrapped inside + parentheses (#3307) (22.12.0) + - Correctly handle trailing commas that are inside a line's leading non-nested parens + (#3370) (22.12.0) + - `--skip-string-normalization` / `-S` now prevents docstring prefixes from being + normalized as expected (#3168) (since 22.8.0) + - When using `--skip-magic-trailing-comma` or `-C`, trailing commas are stripped from + subscript expressions with more than 1 element (#3209) (22.8.0) + - Implicitly concatenated strings inside a list, set, or tuple are now wrapped inside + parentheses (#3162) (22.8.0) + - Fix a string merging/split issue when a comment is present in the middle of + implicitly concatenated strings on its own line (#3227) (22.8.0) + - Docstring quotes are no longer moved if it would violate the line length limit + (#3044, #3430) (22.6.0) + - Parentheses around return annotations are now managed (#2990) (22.6.0) + - Remove unnecessary parentheses around awaited objects (#2991) (22.6.0) + - Remove unnecessary parentheses in `with` statements (#2926) (22.6.0) + - Remove trailing newlines after code block open (#3035) (22.6.0) + - Code cell separators `#%%` are now standardised to `# %%` (#2919) (22.3.0) + - Remove unnecessary parentheses from `except` statements (#2939) (22.3.0) + - Remove unnecessary parentheses from tuple unpacking in `for` loops (#2945) (22.3.0) + - Avoid magic-trailing-comma in single-element subscripts (#2942) (22.3.0) - Fix a crash when a colon line is marked between `# fmt: off` and `# fmt: on` (#3439) ### Preview style diff --git a/docs/the_black_code_style/current_style.md b/docs/the_black_code_style/current_style.md index 56b92529d70..83f8785cc55 100644 --- a/docs/the_black_code_style/current_style.md +++ b/docs/the_black_code_style/current_style.md @@ -194,7 +194,45 @@ that in-function vertical whitespace should only be used sparingly. _Black_ will allow single empty lines inside functions, and single and double empty lines on module level left by the original editors, except when they're within parenthesized expressions. Since such expressions are always reformatted to fit minimal -space, this whitespace is lost. +space, this whitespace is lost. The other exception is that it will remove any empty +lines immediately following a statement that introduces a new indentation level. + +```python +# in: + +def foo(): + + print("All the newlines above me should be deleted!") + + +if condition: + + print("No newline above me!") + + print("There is a newline above me, and that's OK!") + + +class Point: + + x: int + y: int + +# out: + +def foo(): + print("All the newlines above me should be deleted!") + + +if condition: + print("No newline above me!") + + print("There is a newline above me, and that's OK!") + + +class Point: + x: int + y: int +``` It will also insert proper spacing before and after function definitions. It's one line before and after inner functions and two lines before and after module-level functions diff --git a/docs/the_black_code_style/future_style.md b/docs/the_black_code_style/future_style.md index 7a0b2d8f07a..6d289d460a7 100644 --- a/docs/the_black_code_style/future_style.md +++ b/docs/the_black_code_style/future_style.md @@ -62,93 +62,3 @@ plain strings. User-made splits are respected when they do not exceed the line l limit. Line continuation backslashes are converted into parenthesized strings. Unnecessary parentheses are stripped. The stability and status of this feature is tracked in [this issue](https://github.com/psf/black/issues/2188). - -### Improved empty line management - -1. _Black_ will remove newlines in the beginning of new code blocks, i.e. when the - indentation level is increased. For example: - - ```python - def my_func(): - - print("The line above me will be deleted!") - ``` - - will be changed to: - - ```python - def my_func(): - print("The line above me will be deleted!") - ``` - - This new feature will be applied to **all code blocks**: `def`, `class`, `if`, - `for`, `while`, `with`, `case` and `match`. - -2. _Black_ will enforce empty lines before classes and functions with leading comments. - For example: - - ```python - some_var = 1 - # Leading sticky comment - def my_func(): - ... - ``` - - will be changed to: - - ```python - some_var = 1 - - - # Leading sticky comment - def my_func(): - ... - ``` - -### Improved parentheses management - -_Black_ will format parentheses around return annotations similarly to other sets of -parentheses. For example: - -```python -def foo() -> (int): - ... - -def foo() -> looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong: - ... -``` - -will be changed to: - -```python -def foo() -> int: - ... - - -def foo() -> ( - looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong -): - ... -``` - -And, extra parentheses in `await` expressions and `with` statements are removed. For -example: - -```python -with ((open("bla.txt")) as f, open("x")): - ... - -async def main(): - await (asyncio.sleep(1)) -``` - -will be changed to: - -```python -with open("bla.txt") as f, open("x"): - ... - - -async def main(): - await asyncio.sleep(1) -``` diff --git a/src/black/__init__.py b/src/black/__init__.py index f24487fd398..42bdfc1a5dd 100644 --- a/src/black/__init__.py +++ b/src/black/__init__.py @@ -925,9 +925,6 @@ def format_file_contents(src_contents: str, *, fast: bool, mode: Mode) -> FileCo valid by calling :func:`assert_equivalent` and :func:`assert_stable` on it. `mode` is passed to :func:`format_str`. """ - if not mode.preview and not src_contents.strip(): - raise NothingChanged - if mode.is_ipynb: dst_contents = format_ipynb_string(src_contents, fast=fast, mode=mode) else: @@ -1022,7 +1019,7 @@ def format_ipynb_string(src_contents: str, *, fast: bool, mode: Mode) -> FileCon Operate cell-by-cell, only on code cells, only for Python notebooks. If the ``.ipynb`` originally had a trailing newline, it'll be preserved. """ - if mode.preview and not src_contents: + if not src_contents: raise NothingChanged trailing_newline = src_contents[-1] == "\n" @@ -1101,7 +1098,7 @@ def _format_str_once(src_contents: str, *, mode: Mode) -> str: for feature in {Feature.PARENTHESIZED_CONTEXT_MANAGERS} if supports_feature(versions, feature) } - normalize_fmt_off(src_node, preview=mode.preview) + normalize_fmt_off(src_node) lines = LineGenerator(mode=mode, features=context_manager_features) elt = EmptyLineTracker(mode=mode) split_line_features = { @@ -1122,7 +1119,7 @@ def _format_str_once(src_contents: str, *, mode: Mode) -> str: dst_contents = [] for block in dst_blocks: dst_contents.extend(block.all_lines()) - if mode.preview and not dst_contents: + if not dst_contents: # Use decode_bytes to retrieve the correct source newline (CRLF or LF), # and check if normalized_content has more than one line normalized_content, _, newline = decode_bytes(src_contents.encode("utf-8")) diff --git a/src/black/comments.py b/src/black/comments.py index e733dccd844..7cf15bf67b3 100644 --- a/src/black/comments.py +++ b/src/black/comments.py @@ -29,7 +29,7 @@ FMT_PASS: Final = {*FMT_OFF, *FMT_SKIP} FMT_ON: Final = {"# fmt: on", "# fmt:on", "# yapf: enable"} -COMMENT_EXCEPTIONS = {True: " !:#'", False: " !:#'%"} +COMMENT_EXCEPTIONS = " !:#'" @dataclass @@ -50,7 +50,7 @@ class ProtoComment: consumed: int # how many characters of the original leaf's prefix did we consume -def generate_comments(leaf: LN, *, preview: bool) -> 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 @@ -69,16 +69,12 @@ def generate_comments(leaf: LN, *, preview: bool) -> Iterator[Leaf]: Inline comments are emitted as regular token.COMMENT leaves. Standalone are emitted with a fake STANDALONE_COMMENT token identifier. """ - for pc in list_comments( - leaf.prefix, is_endmarker=leaf.type == token.ENDMARKER, preview=preview - ): + for pc in list_comments(leaf.prefix, is_endmarker=leaf.type == token.ENDMARKER): yield Leaf(pc.type, pc.value, prefix="\n" * pc.newlines) @lru_cache(maxsize=4096) -def list_comments( - prefix: str, *, is_endmarker: bool, preview: bool -) -> List[ProtoComment]: +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: @@ -104,7 +100,7 @@ def list_comments( comment_type = token.COMMENT # simple trailing comment else: comment_type = STANDALONE_COMMENT - comment = make_comment(line, preview=preview) + comment = make_comment(line) result.append( ProtoComment( type=comment_type, value=comment, newlines=nlines, consumed=consumed @@ -114,7 +110,7 @@ def list_comments( return result -def make_comment(content: str, *, preview: bool) -> str: +def make_comment(content: str) -> str: """Return a consistently formatted comment from the given `content` string. All comments (except for "##", "#!", "#:", '#'") should have a single @@ -135,26 +131,26 @@ def make_comment(content: str, *, preview: bool) -> str: and not content.lstrip().startswith("type:") ): content = " " + content[1:] # Replace NBSP by a simple space - if content and content[0] not in COMMENT_EXCEPTIONS[preview]: + if content and content[0] not in COMMENT_EXCEPTIONS: content = " " + content return "#" + content -def normalize_fmt_off(node: Node, *, preview: bool) -> None: +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, preview=preview) + try_again = convert_one_fmt_off_pair(node) -def convert_one_fmt_off_pair(node: Node, *, preview: bool) -> bool: +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, preview=preview): + for comment in list_comments(leaf.prefix, is_endmarker=False): if comment.value not in FMT_PASS: previous_consumed = comment.consumed continue @@ -169,7 +165,7 @@ def convert_one_fmt_off_pair(node: Node, *, preview: bool) -> bool: if comment.value in FMT_SKIP and prev.type in WHITESPACE: continue - ignored_nodes = list(generate_ignored_nodes(leaf, comment, preview=preview)) + ignored_nodes = list(generate_ignored_nodes(leaf, comment)) if not ignored_nodes: continue @@ -214,26 +210,24 @@ def convert_one_fmt_off_pair(node: Node, *, preview: bool) -> bool: return False -def generate_ignored_nodes( - leaf: Leaf, comment: ProtoComment, *, preview: bool -) -> Iterator[LN]: +def generate_ignored_nodes(leaf: Leaf, comment: ProtoComment) -> Iterator[LN]: """Starting from the container of `leaf`, generate all leaves until `# fmt: on`. If comment is skip, returns leaf only. Stops at the end of the block. """ if comment.value in FMT_SKIP: - yield from _generate_ignored_nodes_from_fmt_skip(leaf, comment, preview=preview) + yield from _generate_ignored_nodes_from_fmt_skip(leaf, comment) return container: Optional[LN] = container_of(leaf) while container is not None and container.type != token.ENDMARKER: - if is_fmt_on(container, preview=preview): + if is_fmt_on(container): return # fix for fmt: on in children - if children_contains_fmt_on(container, preview=preview): + if children_contains_fmt_on(container): for index, child in enumerate(container.children): - if isinstance(child, Leaf) and is_fmt_on(child, preview=preview): + if isinstance(child, Leaf) and is_fmt_on(child): if child.type in CLOSING_BRACKETS: # This means `# fmt: on` is placed at a different bracket level # than `# fmt: off`. This is an invalid use, but as a courtesy, @@ -244,14 +238,12 @@ def generate_ignored_nodes( if ( child.type == token.INDENT and index < len(container.children) - 1 - and children_contains_fmt_on( - container.children[index + 1], preview=preview - ) + and children_contains_fmt_on(container.children[index + 1]) ): # This means `# fmt: on` is placed right after an indentation # level, and we shouldn't swallow the previous INDENT token. return - if children_contains_fmt_on(child, preview=preview): + if children_contains_fmt_on(child): return yield child else: @@ -264,14 +256,14 @@ def generate_ignored_nodes( def _generate_ignored_nodes_from_fmt_skip( - leaf: Leaf, comment: ProtoComment, *, preview: bool + leaf: Leaf, comment: ProtoComment ) -> Iterator[LN]: """Generate all leaves that should be ignored by the `# fmt: skip` from `leaf`.""" prev_sibling = leaf.prev_sibling parent = leaf.parent # Need to properly format the leaf prefix to compare it to comment.value, # which is also formatted - comments = list_comments(leaf.prefix, is_endmarker=False, preview=preview) + comments = list_comments(leaf.prefix, is_endmarker=False) if not comments or comment.value != comments[0].value: return if prev_sibling is not None: @@ -305,12 +297,12 @@ def _generate_ignored_nodes_from_fmt_skip( yield from iter(ignored_nodes) -def is_fmt_on(container: LN, preview: bool) -> bool: +def is_fmt_on(container: LN) -> bool: """Determine whether formatting is switched on within a container. Determined by whether the last `# fmt:` comment is `on` or `off`. """ fmt_on = False - for comment in list_comments(container.prefix, is_endmarker=False, preview=preview): + for comment in list_comments(container.prefix, is_endmarker=False): if comment.value in FMT_ON: fmt_on = True elif comment.value in FMT_OFF: @@ -318,11 +310,11 @@ def is_fmt_on(container: LN, preview: bool) -> bool: return fmt_on -def children_contains_fmt_on(container: LN, *, preview: bool) -> bool: +def children_contains_fmt_on(container: LN) -> bool: """Determine if children have formatting switched on.""" for child in container.children: leaf = first_leaf_of(child) - if leaf is not None and is_fmt_on(leaf, preview=preview): + if leaf is not None and is_fmt_on(leaf): return True return False diff --git a/src/black/linegen.py b/src/black/linegen.py index 9894a39c95f..7afb1733939 100644 --- a/src/black/linegen.py +++ b/src/black/linegen.py @@ -117,7 +117,7 @@ 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() - for comment in generate_comments(node, preview=self.mode.preview): + for comment in generate_comments(node): if any_open_brackets: # any comment within brackets is subject to splitting self.current_line.append(comment) @@ -221,30 +221,27 @@ def visit_dictsetmaker(self, node: Node) -> Iterator[Line]: def visit_funcdef(self, node: Node) -> Iterator[Line]: """Visit function definition.""" - if Preview.annotation_parens not in self.mode: - yield from self.visit_stmt(node, keywords={"def"}, parens=set()) - else: - yield from self.line() + yield from self.line() - # Remove redundant brackets around return type annotation. - is_return_annotation = False - for child in node.children: - if child.type == token.RARROW: - is_return_annotation = True - elif is_return_annotation: - if child.type == syms.atom and child.children[0].type == token.LPAR: - if maybe_make_parens_invisible_in_atom( - child, - parent=node, - remove_brackets_around_comma=False, - ): - wrap_in_parentheses(node, child, visible=False) - else: + # Remove redundant brackets around return type annotation. + is_return_annotation = False + for child in node.children: + if child.type == token.RARROW: + is_return_annotation = True + elif is_return_annotation: + if child.type == syms.atom and child.children[0].type == token.LPAR: + if maybe_make_parens_invisible_in_atom( + child, + parent=node, + remove_brackets_around_comma=False, + ): wrap_in_parentheses(node, child, visible=False) - is_return_annotation = False + else: + wrap_in_parentheses(node, child, visible=False) + is_return_annotation = False - for child in node.children: - yield from self.visit(child) + for child in node.children: + yield from self.visit(child) def visit_match_case(self, node: Node) -> Iterator[Line]: """Visit either a match or case statement.""" @@ -332,8 +329,7 @@ def visit_power(self, node: Node) -> Iterator[Line]: ): wrap_in_parentheses(node, leaf) - if Preview.remove_redundant_parens in self.mode: - remove_await_parens(node) + remove_await_parens(node) yield from self.visit_default(node) @@ -375,24 +371,17 @@ def visit_STRING(self, leaf: Leaf) -> Iterator[Line]: if is_docstring(leaf) and "\\\n" not in leaf.value: # We're ignoring docstrings with backslash newline escapes because changing # indentation of those changes the AST representation of the code. - if Preview.normalize_docstring_quotes_and_prefixes_properly in self.mode: - # There was a bug where --skip-string-normalization wouldn't stop us - # from normalizing docstring prefixes. To maintain stability, we can - # only address this buggy behaviour while the preview style is enabled. - if self.mode.string_normalization: - docstring = normalize_string_prefix(leaf.value) - # visit_default() does handle string normalization for us, but - # since this method acts differently depending on quote style (ex. - # see padding logic below), there's a possibility for unstable - # formatting as visit_default() is called *after*. To avoid a - # situation where this function formats a docstring differently on - # the second pass, normalize it early. - docstring = normalize_string_quotes(docstring) - else: - docstring = leaf.value - else: - # ... otherwise, we'll keep the buggy behaviour >.< + if self.mode.string_normalization: docstring = normalize_string_prefix(leaf.value) + # visit_default() does handle string normalization for us, but + # since this method acts differently depending on quote style (ex. + # see padding logic below), there's a possibility for unstable + # formatting as visit_default() is called *after*. To avoid a + # situation where this function formats a docstring differently on + # the second pass, normalize it early. + docstring = normalize_string_quotes(docstring) + else: + docstring = leaf.value prefix = get_string_prefix(docstring) docstring = docstring[len(prefix) :] # Remove the prefix quote_char = docstring[0] @@ -432,7 +421,7 @@ def visit_STRING(self, leaf: Leaf) -> Iterator[Line]: quote = quote_char * quote_len # It's invalid to put closing single-character quotes on a new line. - if Preview.long_docstring_quotes_on_newline in self.mode and quote_len == 3: + if self.mode and quote_len == 3: # We need to find the length of the last line of the docstring # to find if we can add the closing quotes to the line without # exceeding the maximum line length. @@ -473,14 +462,8 @@ def __post_init__(self) -> None: self.visit_try_stmt = partial( v, keywords={"try", "except", "else", "finally"}, parens=Ø ) - if self.mode.preview: - self.visit_except_clause = partial( - v, keywords={"except"}, parens={"except"} - ) - self.visit_with_stmt = partial(v, keywords={"with"}, parens={"with"}) - else: - self.visit_except_clause = partial(v, keywords={"except"}, parens=Ø) - self.visit_with_stmt = partial(v, keywords={"with"}, parens=Ø) + self.visit_except_clause = partial(v, keywords={"except"}, parens={"except"}) + self.visit_with_stmt = partial(v, keywords={"with"}, parens={"with"}) 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"}) @@ -932,10 +915,7 @@ def bracket_split_build_line( break leaves_to_track: Set[LeafID] = set() - if ( - Preview.handle_trailing_commas_in_head in original.mode - and component is _BracketSplitComponent.head - ): + if component is _BracketSplitComponent.head: leaves_to_track = get_leaves_inside_matching_brackets(leaves) # Populate the line for leaf in leaves: @@ -1109,7 +1089,7 @@ def normalize_invisible_parens( Standardizes on visible parentheses for single-element tuples, and keeps existing visible parentheses for other tuples and generator expressions. """ - for pc in list_comments(node.prefix, is_endmarker=False, preview=mode.preview): + 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 @@ -1139,8 +1119,7 @@ def normalize_invisible_parens( if check_lpar: if ( - mode.preview - and child.type == syms.atom + child.type == syms.atom and node.type == syms.for_stmt and isinstance(child.prev_sibling, Leaf) and child.prev_sibling.type == token.NAME @@ -1152,9 +1131,7 @@ def normalize_invisible_parens( remove_brackets_around_comma=True, ): wrap_in_parentheses(node, child, visible=False) - elif ( - mode.preview and isinstance(child, Node) and node.type == syms.with_stmt - ): + elif isinstance(child, Node) and node.type == syms.with_stmt: remove_with_parens(child, node) elif child.type == syms.atom: if maybe_make_parens_invisible_in_atom( @@ -1180,7 +1157,7 @@ def normalize_invisible_parens( elif not (isinstance(child, Leaf) and is_multiline_string(child)): wrap_in_parentheses(node, child, visible=False) - comma_check = child.type == token.COMMA if mode.preview else False + comma_check = child.type == token.COMMA check_lpar = isinstance(child, Leaf) and ( child.value in parens_after or comma_check diff --git a/src/black/lines.py b/src/black/lines.py index 2aa675c3b31..ec6ef5d9522 100644 --- a/src/black/lines.py +++ b/src/black/lines.py @@ -14,7 +14,7 @@ ) from black.brackets import DOT_PRIORITY, BracketTracker -from black.mode import Mode, Preview +from black.mode import Mode from black.nodes import ( BRACKETS, CLOSING_BRACKETS, @@ -275,8 +275,7 @@ def has_magic_trailing_comma( - it's not a single-element subscript Additionally, if ensure_removable: - it's not from square bracket indexing - (specifically, single-element square bracket indexing with - Preview.skip_magic_trailing_comma_in_subscript) + (specifically, single-element square bracket indexing) """ if not ( closing.type in CLOSING_BRACKETS @@ -290,8 +289,7 @@ def has_magic_trailing_comma( if closing.type == token.RSQB: if ( - Preview.one_element_subscript in self.mode - and closing.parent + closing.parent and closing.parent.type == syms.trailer and closing.opening_bracket and is_one_sequence_between( @@ -309,18 +307,16 @@ def has_magic_trailing_comma( comma = self.leaves[-1] if comma.parent is None: return False - if Preview.skip_magic_trailing_comma_in_subscript in self.mode: - return ( - comma.parent.type != syms.subscriptlist - or closing.opening_bracket is None - or not is_one_sequence_between( - closing.opening_bracket, - closing, - self.leaves, - brackets=(token.LSQB, token.RSQB), - ) + return ( + comma.parent.type != syms.subscriptlist + or closing.opening_bracket is None + or not is_one_sequence_between( + closing.opening_bracket, + closing, + self.leaves, + brackets=(token.LSQB, token.RSQB), ) - return comma.parent.type == syms.listmaker + ) if self.is_import: return True @@ -592,11 +588,7 @@ def _maybe_empty_lines(self, current_line: Line) -> Tuple[int, int]: ): return before, 1 - if ( - Preview.remove_block_trailing_newline in current_line.mode - and self.previous_line - and self.previous_line.opens_block - ): + if self.previous_line and self.previous_line.opens_block: return 0, 0 return before, 0 @@ -629,9 +621,7 @@ def _maybe_empty_lines_for_class_or_def( ): slc = self.semantic_leading_comment if ( - Preview.empty_lines_before_class_or_def_with_leading_comments - in current_line.mode - and slc is not None + slc is not None and slc.previous_block is not None and not slc.previous_block.original_line.is_class and not slc.previous_block.original_line.opens_block diff --git a/src/black/mode.py b/src/black/mode.py index 4309d4fa635..1af16070073 100644 --- a/src/black/mode.py +++ b/src/black/mode.py @@ -154,15 +154,7 @@ class Preview(Enum): """Individual preview style features.""" hex_codes_in_unicode_sequences = auto() - annotation_parens = auto() - empty_lines_before_class_or_def_with_leading_comments = auto() - handle_trailing_commas_in_head = auto() - long_docstring_quotes_on_newline = auto() - normalize_docstring_quotes_and_prefixes_properly = auto() - one_element_subscript = auto() prefer_splitting_right_hand_side_of_assignments = auto() - remove_block_trailing_newline = auto() - remove_redundant_parens = auto() # NOTE: string_processing requires wrap_long_dict_values_in_parens # for https://github.com/psf/black/issues/3117 to be fixed. string_processing = auto() diff --git a/tests/data/miscellaneous/expression_skip_magic_trailing_comma.diff b/tests/data/miscellaneous/expression_skip_magic_trailing_comma.diff index eba3fd2da7d..d17467b15c7 100644 --- a/tests/data/miscellaneous/expression_skip_magic_trailing_comma.diff +++ b/tests/data/miscellaneous/expression_skip_magic_trailing_comma.diff @@ -144,8 +144,9 @@ -tuple[ - str, int, float, dict[str, int] -] +-tuple[str, int, float, dict[str, int],] ++tuple[str, int, float, dict[str, int]] +tuple[str, int, float, dict[str, int]] - tuple[str, int, float, dict[str, int],] very_long_variable_name_filters: t.List[ t.Tuple[str, t.Union[str, t.List[t.Optional[str]]]], ] diff --git a/tests/data/preview/return_annotation_brackets_string.py b/tests/data/preview/return_annotation_brackets_string.py new file mode 100644 index 00000000000..6978829fd5c --- /dev/null +++ b/tests/data/preview/return_annotation_brackets_string.py @@ -0,0 +1,12 @@ +# Long string example +def frobnicate() -> "ThisIsTrulyUnreasonablyExtremelyLongClassName | list[ThisIsTrulyUnreasonablyExtremelyLongClassName]": + pass + +# output + +# Long string example +def frobnicate() -> ( + "ThisIsTrulyUnreasonablyExtremelyLongClassName |" + " list[ThisIsTrulyUnreasonablyExtremelyLongClassName]" +): + pass diff --git a/tests/data/py_310/parenthesized_context_managers.py b/tests/data/py_310/parenthesized_context_managers.py index ccf1f94883e..1ef09a1bd34 100644 --- a/tests/data/py_310/parenthesized_context_managers.py +++ b/tests/data/py_310/parenthesized_context_managers.py @@ -19,3 +19,27 @@ CtxManager3() as example3, ): ... + +# output + +with CtxManager() as example: + ... + +with CtxManager1(), CtxManager2(): + ... + +with CtxManager1() as example, CtxManager2(): + ... + +with CtxManager1(), CtxManager2() as example: + ... + +with CtxManager1() as example1, CtxManager2() as example2: + ... + +with ( + CtxManager1() as example1, + CtxManager2() as example2, + CtxManager3() as example3, +): + ... diff --git a/tests/data/preview_310/remove_newline_after_match.py b/tests/data/py_310/remove_newline_after_match.py similarity index 100% rename from tests/data/preview_310/remove_newline_after_match.py rename to tests/data/py_310/remove_newline_after_match.py diff --git a/tests/data/py_311/pep_654_style.py b/tests/data/py_311/pep_654_style.py index 568e5e3efa4..9fc7c0c84db 100644 --- a/tests/data/py_311/pep_654_style.py +++ b/tests/data/py_311/pep_654_style.py @@ -76,7 +76,7 @@ except: try: raise TypeError(int) - except* (Exception): + except* Exception: pass 1 / 0 except Exception as e: diff --git a/tests/data/py_38/pep_572_remove_parens.py b/tests/data/py_38/pep_572_remove_parens.py index 4e95fb07f3a..b952b2940c5 100644 --- a/tests/data/py_38/pep_572_remove_parens.py +++ b/tests/data/py_38/pep_572_remove_parens.py @@ -62,7 +62,6 @@ async def await_the_walrus(): with (x := await a, y := await b): pass - # Ideally we should remove one set of parentheses with ((x := await a, y := await b)): pass @@ -137,8 +136,7 @@ async def await_the_walrus(): with (x := await a, y := await b): pass - # Ideally we should remove one set of parentheses - with ((x := await a, y := await b)): + with (x := await a, y := await b): pass with (x := await a), (y := await b): diff --git a/tests/data/preview_39/remove_with_brackets.py b/tests/data/py_39/remove_with_brackets.py similarity index 100% rename from tests/data/preview_39/remove_with_brackets.py rename to tests/data/py_39/remove_with_brackets.py diff --git a/tests/data/simple_cases/comments2.py b/tests/data/simple_cases/comments2.py index 4eea013151a..37e185abf4f 100644 --- a/tests/data/simple_cases/comments2.py +++ b/tests/data/simple_cases/comments2.py @@ -226,6 +226,7 @@ def _init_host(self, parsed) -> None: add_compiler(compilers[(7.0, 32)]) # add_compiler(compilers[(7.1, 64)]) + # Comment before function. def inline_comments_in_brackets_ruin_everything(): if typedargslist: diff --git a/tests/data/simple_cases/comments3.py b/tests/data/simple_cases/comments3.py index fbbef6dcc6b..f964bee6651 100644 --- a/tests/data/simple_cases/comments3.py +++ b/tests/data/simple_cases/comments3.py @@ -1,6 +1,7 @@ # The percent-percent comments are Spyder IDE cells. -#%% + +# %% def func(): x = """ a really long string @@ -44,4 +45,4 @@ def func(): ) -#%% \ No newline at end of file +# %% \ No newline at end of file diff --git a/tests/data/simple_cases/comments5.py b/tests/data/simple_cases/comments5.py index c8c38813d55..bda40619f62 100644 --- a/tests/data/simple_cases/comments5.py +++ b/tests/data/simple_cases/comments5.py @@ -62,6 +62,8 @@ def decorated1(): # Preview.empty_lines_before_class_or_def_with_leading_comments. # In the current style, the user will have to split those lines by hand. some_instruction + + # This comment should be split from `some_instruction` by two lines but isn't. def g(): ... diff --git a/tests/data/preview/comments8.py b/tests/data/simple_cases/comments8.py similarity index 100% rename from tests/data/preview/comments8.py rename to tests/data/simple_cases/comments8.py diff --git a/tests/data/preview/comments9.py b/tests/data/simple_cases/comments9.py similarity index 100% rename from tests/data/preview/comments9.py rename to tests/data/simple_cases/comments9.py diff --git a/tests/data/preview/docstring_preview.py b/tests/data/simple_cases/docstring_preview.py similarity index 100% rename from tests/data/preview/docstring_preview.py rename to tests/data/simple_cases/docstring_preview.py diff --git a/tests/data/simple_cases/empty_lines.py b/tests/data/simple_cases/empty_lines.py index 4c03e432383..4fd47b93dca 100644 --- a/tests/data/simple_cases/empty_lines.py +++ b/tests/data/simple_cases/empty_lines.py @@ -119,7 +119,6 @@ def f(): if not prev: prevp = preceding_leaf(p) if not prevp or prevp.type in OPENING_BRACKETS: - return NO if prevp.type == token.EQUAL: diff --git a/tests/data/simple_cases/fmtonoff.py b/tests/data/simple_cases/fmtonoff.py index 5a50eb12ed3..e40ea2c8d21 100644 --- a/tests/data/simple_cases/fmtonoff.py +++ b/tests/data/simple_cases/fmtonoff.py @@ -205,6 +205,7 @@ def single_literal_yapf_disable(): # Comment 2 + # fmt: off def func_no_args(): a; b; c diff --git a/tests/data/simple_cases/function_trailing_comma.py b/tests/data/simple_cases/function_trailing_comma.py index abe9617c0e9..92f46e27516 100644 --- a/tests/data/simple_cases/function_trailing_comma.py +++ b/tests/data/simple_cases/function_trailing_comma.py @@ -116,9 +116,9 @@ def f( pass -def xxxxxxxxxxxxxxxxxxxxxxxxxxxx() -> Set[ - "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" -]: +def xxxxxxxxxxxxxxxxxxxxxxxxxxxx() -> ( + Set["xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"] +): json = { "k": { "k2": { @@ -140,9 +140,7 @@ def some_function_with_a_really_long_name() -> ( def some_method_with_a_really_long_name( very_long_parameter_so_yeah: str, another_long_parameter: int -) -> ( - another_case_of_returning_a_deeply_nested_import_of_a_type_i_suppose_cause_why_not -): +) -> another_case_of_returning_a_deeply_nested_import_of_a_type_i_suppose_cause_why_not: pass @@ -155,10 +153,8 @@ def func() -> ( def func() -> ( - ( - also_super_long_type_annotation_that_may_cause_an_AST_related_crash_in_black( - this_shouldn_t_get_a_trailing_comma_too - ) + also_super_long_type_annotation_that_may_cause_an_AST_related_crash_in_black( + this_shouldn_t_get_a_trailing_comma_too ) ): pass diff --git a/tests/data/preview/one_element_subscript.py b/tests/data/simple_cases/one_element_subscript.py similarity index 100% rename from tests/data/preview/one_element_subscript.py rename to tests/data/simple_cases/one_element_subscript.py diff --git a/tests/data/preview/prefer_rhs_split_reformatted.py b/tests/data/simple_cases/prefer_rhs_split_reformatted.py similarity index 100% rename from tests/data/preview/prefer_rhs_split_reformatted.py rename to tests/data/simple_cases/prefer_rhs_split_reformatted.py diff --git a/tests/data/preview/remove_await_parens.py b/tests/data/simple_cases/remove_await_parens.py similarity index 100% rename from tests/data/preview/remove_await_parens.py rename to tests/data/simple_cases/remove_await_parens.py diff --git a/tests/data/preview/remove_except_parens.py b/tests/data/simple_cases/remove_except_parens.py similarity index 100% rename from tests/data/preview/remove_except_parens.py rename to tests/data/simple_cases/remove_except_parens.py diff --git a/tests/data/preview/remove_for_brackets.py b/tests/data/simple_cases/remove_for_brackets.py similarity index 100% rename from tests/data/preview/remove_for_brackets.py rename to tests/data/simple_cases/remove_for_brackets.py diff --git a/tests/data/preview/remove_newline_after_code_block_open.py b/tests/data/simple_cases/remove_newline_after_code_block_open.py similarity index 100% rename from tests/data/preview/remove_newline_after_code_block_open.py rename to tests/data/simple_cases/remove_newline_after_code_block_open.py diff --git a/tests/data/preview/return_annotation_brackets.py b/tests/data/simple_cases/return_annotation_brackets.py similarity index 93% rename from tests/data/preview/return_annotation_brackets.py rename to tests/data/simple_cases/return_annotation_brackets.py index 27760bd51d7..265c30220d8 100644 --- a/tests/data/preview/return_annotation_brackets.py +++ b/tests/data/simple_cases/return_annotation_brackets.py @@ -87,10 +87,6 @@ def foo() -> tuple[loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo def foo() -> tuple[int, int, int,]: return 2 -# Long string example -def frobnicate() -> "ThisIsTrulyUnreasonablyExtremelyLongClassName | list[ThisIsTrulyUnreasonablyExtremelyLongClassName]": - pass - # output # Control def double(a: int) -> int: @@ -212,11 +208,3 @@ def foo() -> ( ] ): return 2 - - -# Long string example -def frobnicate() -> ( - "ThisIsTrulyUnreasonablyExtremelyLongClassName |" - " list[ThisIsTrulyUnreasonablyExtremelyLongClassName]" -): - pass diff --git a/tests/data/preview/skip_magic_trailing_comma.py b/tests/data/simple_cases/skip_magic_trailing_comma.py similarity index 100% rename from tests/data/preview/skip_magic_trailing_comma.py rename to tests/data/simple_cases/skip_magic_trailing_comma.py diff --git a/tests/data/preview/trailing_commas_in_leading_parts.py b/tests/data/simple_cases/trailing_commas_in_leading_parts.py similarity index 100% rename from tests/data/preview/trailing_commas_in_leading_parts.py rename to tests/data/simple_cases/trailing_commas_in_leading_parts.py diff --git a/tests/data/preview/whitespace.py b/tests/data/simple_cases/whitespace.py similarity index 100% rename from tests/data/preview/whitespace.py rename to tests/data/simple_cases/whitespace.py diff --git a/tests/test_black.py b/tests/test_black.py index 44d617244f1..d0e78b7dd92 100644 --- a/tests/test_black.py +++ b/tests/test_black.py @@ -419,7 +419,8 @@ def test_skip_magic_trailing_comma(self) -> None: msg = ( "Expected diff isn't equal to the actual. If you made changes to" " expression.py and this is an anticipated difference, overwrite" - f" tests/data/expression_skip_magic_trailing_comma.diff with {dump}" + " tests/data/miscellaneous/expression_skip_magic_trailing_comma.diff" + f" with {dump}" ) self.assertEqual(expected, actual, msg) diff --git a/tests/test_format.py b/tests/test_format.py index adcbc02468d..ab849aac9a3 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -32,31 +32,15 @@ def check_file( @pytest.mark.filterwarnings("ignore:invalid escape sequence.*:DeprecationWarning") @pytest.mark.parametrize("filename", all_data_cases("simple_cases")) def test_simple_format(filename: str) -> None: - check_file("simple_cases", filename, DEFAULT_MODE) - - -@pytest.mark.parametrize("filename", all_data_cases("preview")) -def test_preview_format(filename: str) -> None: magic_trailing_comma = filename != "skip_magic_trailing_comma" check_file( - "preview", - filename, - black.Mode(preview=True, magic_trailing_comma=magic_trailing_comma), + "simple_cases", filename, black.Mode(magic_trailing_comma=magic_trailing_comma) ) -@pytest.mark.parametrize("filename", all_data_cases("preview_39")) -def test_preview_minimum_python_39_format(filename: str) -> None: - source, expected = read_data("preview_39", filename) - mode = black.Mode(preview=True) - assert_format(source, expected, mode, minimum_version=(3, 9)) - - -@pytest.mark.parametrize("filename", all_data_cases("preview_310")) -def test_preview_minimum_python_310_format(filename: str) -> None: - source, expected = read_data("preview_310", filename) - mode = black.Mode(preview=True) - assert_format(source, expected, mode, minimum_version=(3, 10)) +@pytest.mark.parametrize("filename", all_data_cases("preview")) +def test_preview_format(filename: str) -> None: + check_file("preview", filename, black.Mode(preview=True)) def test_preview_context_managers_targeting_py38() -> None: From 69ca0a4c7a365c5f5eea519a90980bab72cab764 Mon Sep 17 00:00:00 2001 From: Stijn de Gooijer Date: Wed, 1 Feb 2023 03:00:17 +0100 Subject: [PATCH 5/6] Infer target version based on project metadata (#3219) Co-authored-by: Richard Si --- .pre-commit-config.yaml | 1 + CHANGES.md | 5 + pyproject.toml | 1 + src/black/__init__.py | 5 +- src/black/files.py | 100 +++++++++++++++++- src/black/parsing.py | 6 +- .../data/project_metadata/both_pyproject.toml | 8 ++ .../project_metadata/neither_pyproject.toml | 6 ++ .../only_black_pyproject.toml | 7 ++ .../only_metadata_pyproject.toml | 7 ++ tests/test_black.py | 66 ++++++++++++ 11 files changed, 203 insertions(+), 9 deletions(-) create mode 100644 tests/data/project_metadata/both_pyproject.toml create mode 100644 tests/data/project_metadata/neither_pyproject.toml create mode 100644 tests/data/project_metadata/only_black_pyproject.toml create mode 100644 tests/data/project_metadata/only_metadata_pyproject.toml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 576f6405d6c..a69fb645238 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -48,6 +48,7 @@ repos: - tomli >= 0.2.6, < 2.0.0 - types-typed-ast >= 1.4.1 - click >= 8.1.0 + - packaging >= 22.0 - platformdirs >= 2.1.0 - pytest - hypothesis diff --git a/CHANGES.md b/CHANGES.md index ecc8a41f505..471567509d3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -77,6 +77,9 @@ +- Black now tries to infer its `--target-version` from the project metadata specified in + `pyproject.toml` (#3219) + ### Packaging @@ -86,6 +89,8 @@ - Drop specific support for the `tomli` requirement on 3.11 alpha releases, working around a bug that would cause the requirement not to be installed on any non-final Python releases (#3448) +- Black now depends on `packaging` version `22.0` or later. This is required for new + functionality that needs to parse part of the project metadata (#3219) ### Parser diff --git a/pyproject.toml b/pyproject.toml index ab38908ba15..435626ac8f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,7 @@ classifiers = [ dependencies = [ "click>=8.0.0", "mypy_extensions>=0.4.3", + "packaging>=22.0", "pathspec>=0.9.0", "platformdirs>=2", "tomli>=1.1.0; python_version < '3.11'", diff --git a/src/black/__init__.py b/src/black/__init__.py index 42bdfc1a5dd..4ebf28821c3 100644 --- a/src/black/__init__.py +++ b/src/black/__init__.py @@ -219,8 +219,9 @@ def validate_regex( callback=target_version_option_callback, multiple=True, help=( - "Python versions that should be supported by Black's output. [default: per-file" - " auto-detection]" + "Python versions that should be supported by Black's output. By default, Black" + " will try to infer this from the project metadata in pyproject.toml. If this" + " does not yield conclusive results, Black will use per-file auto-detection." ), ) @click.option( diff --git a/src/black/files.py b/src/black/files.py index ea517f4ece9..8c0131126b7 100644 --- a/src/black/files.py +++ b/src/black/files.py @@ -18,6 +18,8 @@ ) from mypy_extensions import mypyc_attr +from packaging.specifiers import InvalidSpecifier, Specifier, SpecifierSet +from packaging.version import InvalidVersion, Version from pathspec import PathSpec from pathspec.patterns.gitwildmatch import GitWildMatchPatternError @@ -32,6 +34,7 @@ import tomli as tomllib from black.handle_ipynb_magics import jupyter_dependencies_are_installed +from black.mode import TargetVersion from black.output import err from black.report import Report @@ -108,14 +111,103 @@ def find_pyproject_toml(path_search_start: Tuple[str, ...]) -> Optional[str]: @mypyc_attr(patchable=True) def parse_pyproject_toml(path_config: str) -> Dict[str, Any]: - """Parse a pyproject toml file, pulling out relevant parts for Black + """Parse a pyproject toml file, pulling out relevant parts for Black. - If parsing fails, will raise a tomllib.TOMLDecodeError + If parsing fails, will raise a tomllib.TOMLDecodeError. """ with open(path_config, "rb") as f: pyproject_toml = tomllib.load(f) - config = pyproject_toml.get("tool", {}).get("black", {}) - return {k.replace("--", "").replace("-", "_"): v for k, v in config.items()} + config: Dict[str, Any] = pyproject_toml.get("tool", {}).get("black", {}) + config = {k.replace("--", "").replace("-", "_"): v for k, v in config.items()} + + if "target_version" not in config: + inferred_target_version = infer_target_version(pyproject_toml) + if inferred_target_version is not None: + config["target_version"] = [v.name.lower() for v in inferred_target_version] + + return config + + +def infer_target_version( + pyproject_toml: Dict[str, Any] +) -> Optional[List[TargetVersion]]: + """Infer Black's target version from the project metadata in pyproject.toml. + + Supports the PyPA standard format (PEP 621): + https://packaging.python.org/en/latest/specifications/declaring-project-metadata/#requires-python + + If the target version cannot be inferred, returns None. + """ + project_metadata = pyproject_toml.get("project", {}) + requires_python = project_metadata.get("requires-python", None) + if requires_python is not None: + try: + return parse_req_python_version(requires_python) + except InvalidVersion: + pass + try: + return parse_req_python_specifier(requires_python) + except (InvalidSpecifier, InvalidVersion): + pass + + return None + + +def parse_req_python_version(requires_python: str) -> Optional[List[TargetVersion]]: + """Parse a version string (i.e. ``"3.7"``) to a list of TargetVersion. + + If parsing fails, will raise a packaging.version.InvalidVersion error. + If the parsed version cannot be mapped to a valid TargetVersion, returns None. + """ + version = Version(requires_python) + if version.release[0] != 3: + return None + try: + return [TargetVersion(version.release[1])] + except (IndexError, ValueError): + return None + + +def parse_req_python_specifier(requires_python: str) -> Optional[List[TargetVersion]]: + """Parse a specifier string (i.e. ``">=3.7,<3.10"``) to a list of TargetVersion. + + If parsing fails, will raise a packaging.specifiers.InvalidSpecifier error. + If the parsed specifier cannot be mapped to a valid TargetVersion, returns None. + """ + specifier_set = strip_specifier_set(SpecifierSet(requires_python)) + if not specifier_set: + return None + + target_version_map = {f"3.{v.value}": v for v in TargetVersion} + compatible_versions: List[str] = list(specifier_set.filter(target_version_map)) + if compatible_versions: + return [target_version_map[v] for v in compatible_versions] + return None + + +def strip_specifier_set(specifier_set: SpecifierSet) -> SpecifierSet: + """Strip minor versions for some specifiers in the specifier set. + + For background on version specifiers, see PEP 440: + https://peps.python.org/pep-0440/#version-specifiers + """ + specifiers = [] + for s in specifier_set: + if "*" in str(s): + specifiers.append(s) + elif s.operator in ["~=", "==", ">=", "==="]: + version = Version(s.version) + stripped = Specifier(f"{s.operator}{version.major}.{version.minor}") + specifiers.append(stripped) + elif s.operator == ">": + version = Version(s.version) + if len(version.release) > 2: + s = Specifier(f">={version.major}.{version.minor}") + specifiers.append(s) + else: + specifiers.append(s) + + return SpecifierSet(",".join(str(s) for s in specifiers)) @lru_cache() diff --git a/src/black/parsing.py b/src/black/parsing.py index c37c12b868d..ba474c5e047 100644 --- a/src/black/parsing.py +++ b/src/black/parsing.py @@ -11,7 +11,7 @@ else: from typing import Final -from black.mode import Feature, TargetVersion, supports_feature +from black.mode import VERSION_TO_FEATURES, Feature, TargetVersion, supports_feature from black.nodes import syms from blib2to3 import pygram from blib2to3.pgen2 import driver @@ -52,7 +52,7 @@ def get_grammars(target_versions: Set[TargetVersion]) -> List[Grammar]: if not target_versions: # No target_version specified, so try all grammars. return [ - # Python 3.7+ + # Python 3.7-3.9 pygram.python_grammar_no_print_statement_no_exec_statement_async_keywords, # Python 3.0-3.6 pygram.python_grammar_no_print_statement_no_exec_statement, @@ -72,7 +72,7 @@ def get_grammars(target_versions: Set[TargetVersion]) -> List[Grammar]: if not supports_feature(target_versions, Feature.ASYNC_KEYWORDS): # Python 3.0-3.6 grammars.append(pygram.python_grammar_no_print_statement_no_exec_statement) - if supports_feature(target_versions, Feature.PATTERN_MATCHING): + if any(Feature.PATTERN_MATCHING in VERSION_TO_FEATURES[v] for v in target_versions): # Python 3.10+ grammars.append(pygram.python_grammar_soft_keywords) diff --git a/tests/data/project_metadata/both_pyproject.toml b/tests/data/project_metadata/both_pyproject.toml new file mode 100644 index 00000000000..cf8f148f856 --- /dev/null +++ b/tests/data/project_metadata/both_pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "test" +version = "1.0.0" +requires-python = ">=3.7,<3.11" + +[tool.black] +line-length = 79 +target-version = ["py310"] diff --git a/tests/data/project_metadata/neither_pyproject.toml b/tests/data/project_metadata/neither_pyproject.toml new file mode 100644 index 00000000000..67623d2279b --- /dev/null +++ b/tests/data/project_metadata/neither_pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "test" +version = "1.0.0" + +[tool.black] +line-length = 79 diff --git a/tests/data/project_metadata/only_black_pyproject.toml b/tests/data/project_metadata/only_black_pyproject.toml new file mode 100644 index 00000000000..94058bb3b1e --- /dev/null +++ b/tests/data/project_metadata/only_black_pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "test" +version = "1.0.0" + +[tool.black] +line-length = 79 +target-version = ["py310"] diff --git a/tests/data/project_metadata/only_metadata_pyproject.toml b/tests/data/project_metadata/only_metadata_pyproject.toml new file mode 100644 index 00000000000..1c8cdbb31ad --- /dev/null +++ b/tests/data/project_metadata/only_metadata_pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "test" +version = "1.0.0" +requires-python = ">=3.7,<3.11" + +[tool.black] +line-length = 79 diff --git a/tests/test_black.py b/tests/test_black.py index d0e78b7dd92..e5e17777715 100644 --- a/tests/test_black.py +++ b/tests/test_black.py @@ -1560,6 +1560,72 @@ def test_parse_pyproject_toml(self) -> None: self.assertEqual(config["exclude"], r"\.pyi?$") self.assertEqual(config["include"], r"\.py?$") + def test_parse_pyproject_toml_project_metadata(self) -> None: + for test_toml, expected in [ + ("only_black_pyproject.toml", ["py310"]), + ("only_metadata_pyproject.toml", ["py37", "py38", "py39", "py310"]), + ("neither_pyproject.toml", None), + ("both_pyproject.toml", ["py310"]), + ]: + test_toml_file = THIS_DIR / "data" / "project_metadata" / test_toml + config = black.parse_pyproject_toml(str(test_toml_file)) + self.assertEqual(config.get("target_version"), expected) + + def test_infer_target_version(self) -> None: + for version, expected in [ + ("3.6", [TargetVersion.PY36]), + ("3.11.0rc1", [TargetVersion.PY311]), + (">=3.10", [TargetVersion.PY310, TargetVersion.PY311]), + (">=3.10.6", [TargetVersion.PY310, TargetVersion.PY311]), + ("<3.6", [TargetVersion.PY33, TargetVersion.PY34, TargetVersion.PY35]), + (">3.7,<3.10", [TargetVersion.PY38, TargetVersion.PY39]), + (">3.7,!=3.8,!=3.9", [TargetVersion.PY310, TargetVersion.PY311]), + ( + "> 3.9.4, != 3.10.3", + [TargetVersion.PY39, TargetVersion.PY310, TargetVersion.PY311], + ), + ( + "!=3.3,!=3.4", + [ + TargetVersion.PY35, + TargetVersion.PY36, + TargetVersion.PY37, + TargetVersion.PY38, + TargetVersion.PY39, + TargetVersion.PY310, + TargetVersion.PY311, + ], + ), + ( + "==3.*", + [ + TargetVersion.PY33, + TargetVersion.PY34, + TargetVersion.PY35, + TargetVersion.PY36, + TargetVersion.PY37, + TargetVersion.PY38, + TargetVersion.PY39, + TargetVersion.PY310, + TargetVersion.PY311, + ], + ), + ("==3.8.*", [TargetVersion.PY38]), + (None, None), + ("", None), + ("invalid", None), + ("==invalid", None), + (">3.9,!=invalid", None), + ("3", None), + ("3.2", None), + ("2.7.18", None), + ("==2.7", None), + (">3.10,<3.11", None), + ]: + test_toml = {"project": {"requires-python": version}} + result = black.files.infer_target_version(test_toml) + self.assertEqual(result, expected) + def test_read_pyproject_toml(self) -> None: test_toml_file = THIS_DIR / "test.toml" fake_ctx = FakeContext() From b0d1fba7ac3be53c71fb0d3211d911e629f8aecb Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 31 Jan 2023 18:47:11 -0800 Subject: [PATCH 6/6] Prepare release 23.1.0 (#3536) Co-authored-by: Richard Si --- CHANGES.md | 78 +++++++++++++++++---- docs/integrations/source_version_control.md | 2 +- docs/usage_and_configuration/the_basics.md | 2 +- 3 files changed, 65 insertions(+), 17 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 471567509d3..2071eb3f800 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,64 @@ +### Preview style + + + +### Configuration + + + +### Packaging + + + +### Parser + + + +### Performance + + + +### Output + + + +### _Blackd_ + + + +### Integrations + + + +### Documentation + + + +## 23.1.0 + +### Highlights + +This is the first release of 2023, and following our +[stability policy](https://black.readthedocs.io/en/stable/the_black_code_style/index.html#stability-policy), +it comes with a number of improvements to our stable style, including improvements to +empty line handling, removal of redundant parentheses in several contexts, and output +that highlights implicitly concatenated strings better. + +There are also many changes to the preview style; try out `black --preview` and give us +feedback to help us set the stable style for next year. + +In addition to style changes, Black now automatically infers the supported Python +versions from your `pyproject.toml` file, removing the need to set Black's target +versions separately. + +### Stable style + + + - Introduce the 2023 stable style, which incorporates most aspects of last year's preview style (#3418). Specific changes: - Enforce empty lines before classes and functions with sticky leading comments @@ -45,9 +103,9 @@ -- Format hex code in unicode escape sequences in string literals (#2916) +- Format hex codes in unicode escape sequences in string literals (#2916) - Add parentheses around `if`-`else` expressions (#2278) -- Improve the performance on large expressions that contain many strings (#3467) +- Improve performance on large expressions that contain many strings (#3467) - Fix a crash in preview style with assert + parenthesized string (#3415) - Fix crashes in preview style with walrus operators used in function return annotations and except clauses (#3423) @@ -86,20 +144,14 @@ - Upgrade mypyc from `0.971` to `0.991` so mypycified _Black_ can be built on armv7 (#3380) + - This also fixes some crashes while using compiled Black with a debug build of + CPython - Drop specific support for the `tomli` requirement on 3.11 alpha releases, working around a bug that would cause the requirement not to be installed on any non-final Python releases (#3448) - Black now depends on `packaging` version `22.0` or later. This is required for new functionality that needs to parse part of the project metadata (#3219) -### Parser - - - -### Performance - - - ### Output @@ -111,15 +163,11 @@ - Fix false symlink detection messages in verbose output due to using an incorrect relative path to the project root (#3385) -### _Blackd_ - - - ### Integrations -- Move 3.11 CI to normal flow now all dependencies support 3.11 (#3446) +- Move 3.11 CI to normal flow now that all dependencies support 3.11 (#3446) - Docker: Add new `latest_prerelease` tag automation to follow latest black alpha release on docker images (#3465) diff --git a/docs/integrations/source_version_control.md b/docs/integrations/source_version_control.md index 712b9a688d1..d462e2cc18a 100644 --- a/docs/integrations/source_version_control.md +++ b/docs/integrations/source_version_control.md @@ -7,7 +7,7 @@ Use [pre-commit](https://pre-commit.com/). Once you ```yaml repos: - repo: https://github.com/psf/black - rev: 22.12.0 + rev: 23.1.0 hooks: - id: black # It is recommended to specify the latest version of Python diff --git a/docs/usage_and_configuration/the_basics.md b/docs/usage_and_configuration/the_basics.md index 9dc5277c61e..2b41c187766 100644 --- a/docs/usage_and_configuration/the_basics.md +++ b/docs/usage_and_configuration/the_basics.md @@ -178,7 +178,7 @@ You can check the version of _Black_ you have installed using the `--version` fl ```console $ black --version -black, version 22.12.0 +black, version 23.1.0 ``` An option to require a specific version to be running is also provided.