diff --git a/CHANGES.md b/CHANGES.md index 65443437926..08e5e9ad7cf 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,21 +5,36 @@ ### _Black_ - Fix an issue with indentation and `fmt: skip` (#2281) +- Correct max string length calculation when there are string operators (#2292) + +## 21.5b2 + +### _Black_ + - A space is no longer inserted into empty docstrings (#2249) - Fix handling of .gitignore files containing non-ASCII characters on Windows (#2229) - Respect `.gitignore` files in all levels, not only `root/.gitignore` file (apply `.gitignore` rules like `git` does) (#2225) - Restored compatibility with Click 8.0 on Python 3.6 when LANG=C used (#2227) - Add extra uvloop install + import support if in python env (#2258) +- Fix --experimental-string-processing crash when matching parens are not found (#2283) +- Make sure to split lines that start with a string operator (#2286) +- Fix regular expression that black uses to identify f-expressions (#2287) ### _Blackd_ - Add a lower bound for the `aiohttp-cors` dependency. Only 0.4.0 or higher is supported. (#2231) -### _Packaging_ +### Integrations + +- The official Black action now supports choosing what version to use, and supports the + major 3 OSes. (#1940) + +### Packaging -- Release self-contained macOS binaries as part of the GitHub release pipeline (#2198) +- Release self-contained x86_64 MacOS binaries as part of the GitHub release pipeline + (#2198) - Always build binaries with the latest available Python (#2260) ### Documentation diff --git a/action.yml b/action.yml index 827e971801b..ddf07933a3e 100644 --- a/action.yml +++ b/action.yml @@ -4,21 +4,56 @@ author: "Ɓukasz Langa and contributors to Black" inputs: options: description: - "Options passed to black. Use `black --help` to see available options. Default: + "Options passed to Black. Use `black --help` to see available options. Default: '--check'" required: false default: "--check --diff" src: - description: "Source to run black. Default: '.'" + description: "Source to run Black. Default: '.'" required: false default: "." black_args: description: "[DEPRECATED] Black input arguments." required: false default: "" + deprecationMessage: + "Input `with.black_args` is deprecated. Use `with.options` and `with.src` instead." + version: + description: 'Python Version specifier (PEP440) - e.g. "21.5b1"' + required: false + default: "" branding: color: "black" icon: "check-circle" runs: - using: "docker" - image: "action/Dockerfile" + using: composite + steps: + - run: | + # Exists since using github.action_path + path to main script doesn't work because bash + # interprets the backslashes in github.action_path (which are used when the runner OS + # is Windows) destroying the path to the target file. + # + # Also semicolons are necessary because I can't get the newlines to work + entrypoint="import sys; + import subprocess; + from pathlib import Path; + + MAIN_SCRIPT = Path(r'${{ github.action_path }}') / 'action' / 'main.py'; + + proc = subprocess.run([sys.executable, str(MAIN_SCRIPT)]); + sys.exit(proc.returncode) + " + + if [ "$RUNNER_OS" == "Windows" ]; then + echo $entrypoint | python + else + echo $entrypoint | python3 + fi + env: + # TODO: Remove once https://github.com/actions/runner/issues/665 is fixed. + INPUT_OPTIONS: ${{ inputs.options }} + INPUT_SRC: ${{ inputs.src }} + INPUT_BLACK_ARGS: ${{ inputs.black_args }} + INPUT_VERSION: ${{ inputs.version }} + pythonioencoding: utf-8 + shell: bash diff --git a/action/Dockerfile b/action/Dockerfile deleted file mode 100644 index eb2209940db..00000000000 --- a/action/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -FROM python:3 - -ENV PYTHONDONTWRITEBYTECODE 1 -ENV PYTHONUNBUFFERED 1 - -RUN pip install --upgrade --no-cache-dir black - -COPY entrypoint.sh /entrypoint.sh - -ENTRYPOINT ["/entrypoint.sh"] diff --git a/action/entrypoint.sh b/action/entrypoint.sh deleted file mode 100755 index 30bf4eb688f..00000000000 --- a/action/entrypoint.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash -e - -if [ -n "$INPUT_BLACK_ARGS" ]; then - echo '::warning::Input `with.black_args` is deprecated. Use `with.options` and `with.src` instead.' - black $INPUT_BLACK_ARGS - exit $? -fi - -black $INPUT_OPTIONS $INPUT_SRC diff --git a/action/main.py b/action/main.py new file mode 100644 index 00000000000..fde312553bf --- /dev/null +++ b/action/main.py @@ -0,0 +1,39 @@ +import os +import shlex +import sys +from pathlib import Path +from subprocess import run, PIPE, STDOUT + +ACTION_PATH = Path(os.environ["GITHUB_ACTION_PATH"]) +ENV_PATH = ACTION_PATH / ".black-env" +ENV_BIN = ENV_PATH / ("Scripts" if sys.platform == "win32" else "bin") +OPTIONS = os.getenv("INPUT_OPTIONS", default="") +SRC = os.getenv("INPUT_SRC", default="") +BLACK_ARGS = os.getenv("INPUT_BLACK_ARGS", default="") +VERSION = os.getenv("INPUT_VERSION", default="") + +run([sys.executable, "-m", "venv", str(ENV_PATH)], check=True) + +req = "black[colorama,python2]" +if VERSION: + req += f"=={VERSION}" +pip_proc = run( + [str(ENV_BIN / "python"), "-m", "pip", "install", req], + stdout=PIPE, + stderr=STDOUT, + encoding="utf-8", +) +if pip_proc.returncode: + print(pip_proc.stdout) + print("::error::Failed to install Black.", flush=True) + sys.exit(pip_proc.returncode) + + +base_cmd = [str(ENV_BIN / "black")] +if BLACK_ARGS: + # TODO: remove after a while since this is deprecated in favour of SRC + OPTIONS. + proc = run([*base_cmd, *shlex.split(BLACK_ARGS)]) +else: + proc = run([*base_cmd, *shlex.split(OPTIONS), *shlex.split(SRC)]) + +sys.exit(proc.returncode) diff --git a/docs/integrations/github_actions.md b/docs/integrations/github_actions.md index 9e8cf436453..d293c40dadf 100644 --- a/docs/integrations/github_actions.md +++ b/docs/integrations/github_actions.md @@ -3,6 +3,14 @@ You can use _Black_ within a GitHub Actions workflow without setting your own Python environment. Great for enforcing that your code matches the _Black_ code style. +## Compatiblity + +This action is known to support all GitHub-hosted runner OSes. In addition, only +published versions of _Black_ are supported (i.e. whatever is available on PyPI). + +Finally, this action installs _Black_ with both the `colorama` and `python2` extras so +the `--color` flag and formatting Python 2 code are supported. + ## Usage Create a file named `.github/workflows/black.yml` inside your repository with: @@ -17,19 +25,26 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - uses: psf/black@stable ``` We recommend the use of the `@stable` tag, but per version tags also exist if you prefer -that. +that. Note that the action's version you select is independent of the version of _Black_ +the action will use. + +The version of _Black_ the action will use can be configured via `version`. The action +defaults to the latest release available on PyPI. Only versions available from PyPI are +supported, so no commit SHAs or branch names. + +You can also configure the arguments passed to _Black_ via `options` (defaults to +`'--check --diff'`) and `src` (default is `'.'`) -You may use `options` (Default is `'--check --diff'`) and `src` (Default is `'.'`) as -follows: +Here's an example configuration: ```yaml - uses: psf/black@stable with: options: "--check --verbose" src: "./src" + version: "21.5b1" ``` diff --git a/src/black/trans.py b/src/black/trans.py index 7ecc31d6d31..bc6e93b01b4 100644 --- a/src/black/trans.py +++ b/src/black/trans.py @@ -25,7 +25,7 @@ from black.mode import Feature from black.nodes import syms, replace_child, parent_type from black.nodes import is_empty_par, is_empty_lpar, is_empty_rpar -from black.nodes import CLOSING_BRACKETS, STANDALONE_COMMENT +from black.nodes import OPENING_BRACKETS, CLOSING_BRACKETS, STANDALONE_COMMENT from black.lines import Line, append_leaves from black.brackets import BracketMatchError from black.comments import contains_pragma_comment @@ -738,6 +738,18 @@ class BaseStringSplitter(StringTransformer): * The target string is not a multiline (i.e. triple-quote) string. """ + STRING_OPERATORS = [ + token.EQEQUAL, + token.GREATER, + token.GREATEREQUAL, + token.LESS, + token.LESSEQUAL, + token.NOTEQUAL, + token.PERCENT, + token.PLUS, + token.STAR, + ] + @abstractmethod def do_splitter_match(self, line: Line) -> TMatchResult: """ @@ -847,9 +859,9 @@ def _get_max_string_length(self, line: Line, string_idx: int) -> int: p_idx -= 1 P = LL[p_idx] - if P.type == token.PLUS: - # WMA4 a space and a '+' character (e.g. `+ STRING`). - offset += 2 + if P.type in self.STRING_OPERATORS: + # WMA4 a space and a string operator (e.g. `+ STRING` or `== STRING`). + offset += len(str(P)) + 1 if P.type == token.COMMA: # WMA4 a space, a comma, and a closing bracket [e.g. `), STRING`]. @@ -920,9 +932,9 @@ class StringSplitter(CustomSplitMapMixin, BaseStringSplitter): lines by themselves). Requirements: - * The line consists ONLY of a single string (with the exception of a - '+' symbol which MAY exist at the start of the line), MAYBE a string - trailer, and MAYBE a trailing comma. + * The line consists ONLY of a single string (possibly prefixed by a + string operator [e.g. '+' or '==']), MAYBE a string trailer, and MAYBE + a trailing comma. AND * All of the requirements listed in BaseStringSplitter's docstring. @@ -961,8 +973,8 @@ class StringSplitter(CustomSplitMapMixin, BaseStringSplitter): | \{\{ | \}\} | (?R) - )+? - (? TMatchResult: @@ -972,8 +984,20 @@ def do_splitter_match(self, line: Line) -> TMatchResult: idx = 0 - # The first leaf MAY be a '+' symbol... - if is_valid_index(idx) and LL[idx].type == token.PLUS: + # The first two leaves MAY be the 'not in' keywords... + if ( + is_valid_index(idx) + and is_valid_index(idx + 1) + and [LL[idx].type, LL[idx + 1].type] == [token.NAME, token.NAME] + and str(LL[idx]) + str(LL[idx + 1]) == "not in" + ): + idx += 2 + # Else the first leaf MAY be a string operator symbol or the 'in' keyword... + elif is_valid_index(idx) and ( + LL[idx].type in self.STRING_OPERATORS + or LL[idx].type == token.NAME + and str(LL[idx]) == "in" + ): idx += 1 # The next/first leaf MAY be an empty LPAR... @@ -1023,23 +1047,26 @@ def do_transform(self, line: Line, string_idx: int) -> Iterator[TResult[Line]]: ) first_string_line = True - starts_with_plus = LL[0].type == token.PLUS - def line_needs_plus() -> bool: - return first_string_line and starts_with_plus + string_op_leaves = self._get_string_operator_leaves(LL) + string_op_leaves_length = ( + sum([len(str(prefix_leaf)) for prefix_leaf in string_op_leaves]) + 1 + if string_op_leaves + else 0 + ) - def maybe_append_plus(new_line: Line) -> None: + def maybe_append_string_operators(new_line: Line) -> None: """ Side Effects: - If @line starts with a plus and this is the first line we are - constructing, this function appends a PLUS leaf to @new_line - and replaces the old PLUS leaf in the node structure. Otherwise - this function does nothing. + If @line starts with a string operator and this is the first + line we are constructing, this function appends the string + operator to @new_line and replaces the old string operator leaf + in the node structure. Otherwise this function does nothing. """ - if line_needs_plus(): - plus_leaf = Leaf(token.PLUS, "+") - replace_child(LL[0], plus_leaf) - new_line.append(plus_leaf) + maybe_prefix_leaves = string_op_leaves if first_string_line else [] + for i, prefix_leaf in enumerate(maybe_prefix_leaves): + replace_child(LL[i], prefix_leaf) + new_line.append(prefix_leaf) ends_with_comma = ( is_valid_index(string_idx + 1) and LL[string_idx + 1].type == token.COMMA @@ -1054,7 +1081,7 @@ def max_last_string() -> int: result = self.line_length result -= line.depth * 4 result -= 1 if ends_with_comma else 0 - result -= 2 if line_needs_plus() else 0 + result -= string_op_leaves_length return result # --- Calculate Max Break Index (for string value) @@ -1103,7 +1130,7 @@ def more_splits_should_be_made() -> bool: break_idx = csplit.break_idx else: # Algorithmic Split (automatic) - max_bidx = max_break_idx - 2 if line_needs_plus() else max_break_idx + max_bidx = max_break_idx - string_op_leaves_length maybe_break_idx = self._get_break_idx(rest_value, max_bidx) if maybe_break_idx is None: # If we are unable to algorithmically determine a good split @@ -1148,7 +1175,7 @@ def more_splits_should_be_made() -> bool: # --- Construct `next_line` next_line = line.clone() - maybe_append_plus(next_line) + maybe_append_string_operators(next_line) next_line.append(next_leaf) string_line_results.append(Ok(next_line)) @@ -1169,7 +1196,7 @@ def more_splits_should_be_made() -> bool: self._maybe_normalize_string_quotes(rest_leaf) last_line = line.clone() - maybe_append_plus(last_line) + maybe_append_string_operators(last_line) # If there are any leaves to the right of the target string... if is_valid_index(string_idx + 1): @@ -1345,6 +1372,17 @@ def _normalize_f_string(self, string: str, prefix: str) -> str: else: return string + def _get_string_operator_leaves(self, leaves: Iterable[Leaf]) -> List[Leaf]: + LL = list(leaves) + + string_op_leaves = [] + i = 0 + while LL[i].type in self.STRING_OPERATORS + [token.NAME]: + prefix_leaf = Leaf(LL[i].type, str(LL[i]).strip()) + string_op_leaves.append(prefix_leaf) + i += 1 + return string_op_leaves + class StringParenWrapper(CustomSplitMapMixin, BaseStringSplitter): """ @@ -1398,6 +1436,11 @@ class StringParenWrapper(CustomSplitMapMixin, BaseStringSplitter): def do_splitter_match(self, line: Line) -> TMatchResult: LL = line.leaves + if line.leaves[-1].type in OPENING_BRACKETS: + return TErr( + "Cannot wrap parens around a line that ends in an opening bracket." + ) + string_idx = ( self._return_match(LL) or self._else_match(LL) @@ -1665,9 +1708,10 @@ def do_transform(self, line: Line, string_idx: int) -> Iterator[TResult[Line]]: right_leaves.pop() if old_parens_exist: - assert ( - right_leaves and right_leaves[-1].type == token.RPAR - ), "Apparently, old parentheses do NOT exist?!" + assert right_leaves and right_leaves[-1].type == token.RPAR, ( + "Apparently, old parentheses do NOT exist?!" + f" (left_leaves={left_leaves}, right_leaves={right_leaves})" + ) old_rpar_leaf = right_leaves.pop() append_leaves(string_line, line, right_leaves) diff --git a/tests/data/long_strings__edge_case.py b/tests/data/long_strings__edge_case.py index 6919db5a80b..07c27537191 100644 --- a/tests/data/long_strings__edge_case.py +++ b/tests/data/long_strings__edge_case.py @@ -29,6 +29,9 @@ ) return f'{x}/b/c/d/d/d/dadfjsadjsaidoaisjdsfjaofjdfijaidfjaodfjaoifjodjafojdoajaaaaaaaaaaa' return f'{x}/b/c/d/d/d/dadfjsadjsaidoaisjdsfjaofjdfijaidfjaodfjaoifjodjafojdoajaaaaaaaaaaaa' +assert str(result) == "This long string should be split at some point right close to or around hereeeeeee" +assert str(result) < "This long string should be split at some point right close to or around hereeeeee" +assert "A format string: %s" % "This long string should be split at some point right close to or around hereeeeeee" != result # output @@ -108,3 +111,19 @@ f"{x}/b/c/d/d/d/dadfjsadjsaidoaisjdsfjaofjdfijaidfjaodfjaoifjodjafojdoajaaaaaaaaaaa" ) return f"{x}/b/c/d/d/d/dadfjsadjsaidoaisjdsfjaofjdfijaidfjaodfjaoifjodjafojdoajaaaaaaaaaaaa" +assert ( + str(result) + == "This long string should be split at some point right close to or around" + " hereeeeeee" +) +assert ( + str(result) + < "This long string should be split at some point right close to or around" + " hereeeeee" +) +assert ( + "A format string: %s" + % "This long string should be split at some point right close to or around" + " hereeeeeee" + != result +) diff --git a/tests/data/long_strings__regression.py b/tests/data/long_strings__regression.py index 2e7f2483b63..cd8053f9eb5 100644 --- a/tests/data/long_strings__regression.py +++ b/tests/data/long_strings__regression.py @@ -396,6 +396,56 @@ def xxxxxxx_xxxxxx(xxxx): " it has now" ) + +def _legacy_listen_examples(): + text += ( + " \"listen for the '%(event_name)s' event\"\n" + "\n # ... (event logic logic logic) ...\n" + % { + "since": since, + } + ) + + +temp_msg = ( + f"{f'{humanize_number(pos)}.': <{pound_len+2}} " + f"{balance: <{bal_len + 5}} " + f"<<{author.display_name}>>\n" +) + +assert str(suffix_arr) == ( + "['$', 'angaroo$', 'angrykangaroo$', 'aroo$', 'garoo$', " + "'grykangaroo$', 'kangaroo$', 'ngaroo$', 'ngrykangaroo$', " + "'o$', 'oo$', 'roo$', 'rykangaroo$', 'ykangaroo$']" +) +assert str(suffix_arr) != ( + "['$', 'angaroo$', 'angrykangaroo$', 'aroo$', 'garoo$', " + "'grykangaroo$', 'kangaroo$', 'ngaroo$', 'ngrykangaroo$', " + "'o$', 'oo$', 'roo$', 'rykangaroo$', 'ykangaroo$']" +) +assert str(suffix_arr) <= ( + "['$', 'angaroo$', 'angrykangaroo$', 'aroo$', 'garoo$', " + "'grykangaroo$', 'kangaroo$', 'ngaroo$', 'ngrykangaroo$', " + "'o$', 'oo$', 'roo$', 'rykangaroo$', 'ykangaroo$']" +) +assert str(suffix_arr) >= ( + "['$', 'angaroo$', 'angrykangaroo$', 'aroo$', 'garoo$', " + "'grykangaroo$', 'kangaroo$', 'ngaroo$', 'ngrykangaroo$', " + "'o$', 'oo$', 'roo$', 'rykangaroo$', 'ykangaroo$']" +) +assert str(suffix_arr) < ( + "['$', 'angaroo$', 'angrykangaroo$', 'aroo$', 'garoo$', " + "'grykangaroo$', 'kangaroo$', 'ngaroo$', 'ngrykangaroo$', " + "'o$', 'oo$', 'roo$', 'rykangaroo$', 'ykangaroo$']" +) +assert str(suffix_arr) > ( + "['$', 'angaroo$', 'angrykangaroo$', 'aroo$', 'garoo$', " + "'grykangaroo$', 'kangaroo$', 'ngaroo$', 'ngrykangaroo$', " + "'o$', 'oo$', 'roo$', 'rykangaroo$', 'ykangaroo$']" +) +assert str(suffix_arr) in "['$', 'angaroo$', 'angrykangaroo$', 'aroo$', 'garoo$', 'grykangaroo$', 'kangaroo$', 'ngaroo$', 'ngrykangaroo$', 'o$', 'oo$', 'roo$', 'rykangaroo$', 'ykangaroo$']" +assert str(suffix_arr) not in "['$', 'angaroo$', 'angrykangaroo$', 'aroo$', 'garoo$', 'grykangaroo$', 'kangaroo$', 'ngaroo$', 'ngrykangaroo$', 'o$', 'oo$', 'roo$', 'rykangaroo$', 'ykangaroo$']" + # output @@ -886,3 +936,69 @@ def xxxxxxx_xxxxxx(xxxx): " it goes over 88 characters which" " it has now" ) + + +def _legacy_listen_examples(): + text += ( + " \"listen for the '%(event_name)s' event\"\n" + "\n # ... (event logic logic logic) ...\n" + % { + "since": since, + } + ) + + +temp_msg = ( + f"{f'{humanize_number(pos)}.': <{pound_len+2}} " + f"{balance: <{bal_len + 5}} " + f"<<{author.display_name}>>\n" +) + +assert ( + str(suffix_arr) + == "['$', 'angaroo$', 'angrykangaroo$', 'aroo$', 'garoo$', " + "'grykangaroo$', 'kangaroo$', 'ngaroo$', 'ngrykangaroo$', " + "'o$', 'oo$', 'roo$', 'rykangaroo$', 'ykangaroo$']" +) +assert ( + str(suffix_arr) + != "['$', 'angaroo$', 'angrykangaroo$', 'aroo$', 'garoo$', " + "'grykangaroo$', 'kangaroo$', 'ngaroo$', 'ngrykangaroo$', " + "'o$', 'oo$', 'roo$', 'rykangaroo$', 'ykangaroo$']" +) +assert ( + str(suffix_arr) + <= "['$', 'angaroo$', 'angrykangaroo$', 'aroo$', 'garoo$', " + "'grykangaroo$', 'kangaroo$', 'ngaroo$', 'ngrykangaroo$', " + "'o$', 'oo$', 'roo$', 'rykangaroo$', 'ykangaroo$']" +) +assert ( + str(suffix_arr) + >= "['$', 'angaroo$', 'angrykangaroo$', 'aroo$', 'garoo$', " + "'grykangaroo$', 'kangaroo$', 'ngaroo$', 'ngrykangaroo$', " + "'o$', 'oo$', 'roo$', 'rykangaroo$', 'ykangaroo$']" +) +assert ( + str(suffix_arr) + < "['$', 'angaroo$', 'angrykangaroo$', 'aroo$', 'garoo$', " + "'grykangaroo$', 'kangaroo$', 'ngaroo$', 'ngrykangaroo$', " + "'o$', 'oo$', 'roo$', 'rykangaroo$', 'ykangaroo$']" +) +assert ( + str(suffix_arr) + > "['$', 'angaroo$', 'angrykangaroo$', 'aroo$', 'garoo$', " + "'grykangaroo$', 'kangaroo$', 'ngaroo$', 'ngrykangaroo$', " + "'o$', 'oo$', 'roo$', 'rykangaroo$', 'ykangaroo$']" +) +assert ( + str(suffix_arr) + in "['$', 'angaroo$', 'angrykangaroo$', 'aroo$', 'garoo$', 'grykangaroo$'," + " 'kangaroo$', 'ngaroo$', 'ngrykangaroo$', 'o$', 'oo$', 'roo$', 'rykangaroo$'," + " 'ykangaroo$']" +) +assert ( + str(suffix_arr) + not in "['$', 'angaroo$', 'angrykangaroo$', 'aroo$', 'garoo$', 'grykangaroo$'," + " 'kangaroo$', 'ngaroo$', 'ngrykangaroo$', 'o$', 'oo$', 'roo$'," + " 'rykangaroo$', 'ykangaroo$']" +)