Skip to content

Don't wrap part of an unmerged implicit string concatenation in parens - #5434

Merged
cobaltt7 merged 3 commits into
psf:mainfrom
ethanstoner:fix/raw-string-concat-3855
Oct 3, 2026
Merged

cobaltt7 merged 3 commits into
psf:mainfrom
ethanstoner:fix/raw-string-concat-3855

Conversation

@ethanstoner

@ethanstoner ethanstoner commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Description

Fixes #3855

With string processing enabled (--unstable), Black produces code it can't parse when the value of a keyword argument, a lambda body or an else branch is an implicit concatenation that StringMerger won't merge, e.g. raw strings:

$ printf "dict(a=r' ' r'')\n" > foo.py
$ black -l 1 --unstable foo.py
error: cannot parse: foo.py:5:4
        r""
       ^
ParseError: bad input

The same happens at the default line length when the first string is too long, e.g. some_function_name(argument=r"<long string>" r" more").

StringMerger refuses raw strings (StringMerger does NOT merge raw strings.), so the concatenation stays split and the earlier delimiter split puts a=r" " and r"" on separate lines. StringParenWrapper then gets the line a=r" " on its own. _assign_match (and _dict_or_lambda_match / _else_match) only look at the leaves in the current line, so they match, and the wrapper turns it into:

dict(
    a=(
        r" "
    )
    r""
)

This PR wraps the whole concatenation in parens instead:

dict(
    a=(
        r" "
        r""
    )
)

That is also what --unstable already does for concatenations it can merge (argument=( with the merged string split inside).

  • linegen.py: for a line inside brackets whose only delimiters are between implicitly concatenated strings and which doesn't start with a string (a=STRING STRING, lambda: STRING STRING, else STRING STRING), string_paren_wrap is tried before delimiter_split. If it doesn't match, delimiter_split runs next as before.
  • trans.py: _assign_match and _dict_or_lambda_match skip strings concatenated with the matched one (_else_match already accepted anything after the string), so the wrapper puts the whole concatenation inside the parens. do_splitter_match declines when the first string already fits on the line or the line has comments, so those lines are still split at the concatenation as before. It also still refuses a string whose next sibling is a STRING on another line, as a fallback for the cases that don't take this path (e.g. a comment between the strings).

I added three cases to preview_long_strings__regression.py (keyword argument, lambda, else branch) at the default line length, plus one where the first string fits exactly on the line, which main already formats without parens and which must stay that way. With upstream main's trans.py and linegen.py the test fails with black.parsing.InvalidInput: cannot parse: 702:4. With them, it passes.

To check that output which didn't crash before is unchanged, I formatted the inputs in tests/data/cases/, the files in src/black/, and 6636 generated implicit concatenations (raw, bytes, f-string and plain prefixes; keyword arguments, dict values, lambdas, else, return, assignments, call arguments, with and without comments) in stable, --preview and --unstable mode at line lengths 88, 40, 20 and 1, with main and with this branch. None of the 83268 runs changed output. 4479 runs that crash on main now format, all under --unstable.

Validation (Windows, Python 3.12):

  • pytest -n auto tests: 501 passed, 3 skipped
  • pre-commit run on the changed files: isort, flake8, mypy, prettier and the other hooks pass

Checklist - did you ...

  • Implement any code style changes under the --preview style, following the stability policy? (Only affects string_processing, and only input that used to crash)
  • Add an entry in CHANGES.md if necessary?
  • Add / update tests if necessary?
  • Add new / update outdated documentation? (N/A)

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

diff-shades results comparing this PR (2a25418) to main (8d5a2d9):

--preview style: no changes

--stable style: no changes


What is this? | Workflow run | diff-shades documentation

@cobaltt7

Copy link
Copy Markdown
Collaborator

I think I would prefer both strings to be parenthesized, such as:

dict(
    a=(
        r" "
        r""
    )
)

Your proposed formatting doesn't clearly show that the strings are part of the same value.

StringParenWrapper could match a line holding only the first string of an
implicit concatenation that StringMerger refused to merge (for example raw
strings), because an earlier split had already moved the other strings to
their own lines. Wrapping that one string in parens left the rest of the
concatenation dangling and produced unparseable output.

Fixes psf#3855.
When a line inside brackets is something like `a=r"..." r"..."` and the
first string does not fit, try StringParenWrapper before delimiter_split,
so the concatenation is wrapped as a whole instead of being split apart
first. Lines where the first string fits, or that have comments, are
still split at the concatenation as before.
Pins the fits-on-the-line guard in StringParenWrapper.do_splitter_match.
Without it, a keyword argument whose first string ends exactly at the
line limit is wrapped in parens, changing output that main already
formats correctly.
@ethanstoner
ethanstoner force-pushed the fix/raw-string-concat-3855 branch from fd0faf4 to 2a25418 Compare September 25, 2026 00:24
@ethanstoner

Copy link
Copy Markdown
Contributor Author

Done in c9e79d2: dict(a=r' ' r'') at -l 1 --unstable now gives exactly that form, and the lambda body and else branch get the same treatment (lambda: ( and else ( with both strings inside). This only kicks in when the first string doesn't fit on the line. When it does fit, the concatenation is still split without parens as before, same as stable output, so formatting that didn't crash is unchanged (2a25418 adds a test case for that).

@cobaltt7

cobaltt7 commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator

Thanks!

@cobaltt7
cobaltt7 merged commit 1e6d48e into psf:main Oct 3, 2026
57 checks passed
luketainton pushed a commit to luketainton/luke_instant-msg-api that referenced this pull request Oct 5, 2026
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [black](https://github.com/psf/black) ([changelog](https://github.com/psf/black/blob/main/CHANGES.md)) | `<26.5.2,>=26.5.1` β†’ `<26.10.1,>=26.10.0` | ![age](https://developer.mend.io/api/mc/badges/age/pypi/black/26.10.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/black/26.5.1/26.10.0?slim=true) |

---

### Release Notes

<details>
<summary>psf/black (black)</summary>

### [`v26.10.0`](https://github.com/psf/black/blob/HEAD/CHANGES.md#Version-26100)

[Compare Source](psf/black@26.5.1...26.10.0)

##### Stable style

- `--line-ranges` no longer inserts an empty line after a docstring when the range
  covers only the docstring itself ([#&#8203;5312](psf/black#5312))
- Fix `# fmt: skip` on a bracketed ternary turning the surrounding tuple into a call
  ([#&#8203;5464](psf/black#5464))
- Fix crash when `# fmt: skip` is placed on a one-line function or class with PEP 695
  type parameters ([#&#8203;5429](psf/black#5429))
- Fix an inline comment after the closing bracket of optional parentheses being moved
  inside the parentheses when the parenthesized expression contains own-line comments
  ([#&#8203;5395](psf/black#5395))
- Fix unparseable output when `# fmt: skip` is placed on a bracket of an `if`, `while`,
  `for`, or `with` header ([#&#8203;5401](psf/black#5401), [#&#8203;5405](psf/black#5405))
- Fix crash when formatting parenthesized expressions with multiple inline comments and
  `# fmt: skip` ([#&#8203;5414](psf/black#5414))
- Fix parsing Jupyter notebook assignment magics when non-ASCII characters appear
  earlier on the line ([#&#8203;5381](psf/black#5381))
- Preserve blank lines that come immediately before a `# fmt: on` comment ([#&#8203;5300](psf/black#5300))
- Keep the parentheses around the target of an annotated assignment (e.g.
  `(x): int = 5`), which prevent CPython from including the name in `__annotations__`
  ([#&#8203;5321](psf/black#5321))
- Stop treating a t-string in docstring position as a docstring (e.g. `t"  spam  "` as
  the first statement of a module, class or function) ([#&#8203;5287](psf/black#5287))
- Fix unparseable output for a t-string whose replacement field contains a quote (e.g.
  `t'\'{a["b"]}\''`) ([#&#8203;5265](psf/black#5265))
- Fix unparseable output for a triple-quoted string whose body ends in an
  already-escaped double quote (e.g. `'''\'''\"'''`) ([#&#8203;5262](psf/black#5262))
- Fix `--skip-magic-trailing-comma` dropping the trailing comma from a split
  single-element tuple used as a lambda parameter default ([#&#8203;5246](psf/black#5246))
- Fix unstable formatting when an inline comment sits on optional parentheses (e.g. a
  parenthesized assert message) ([#&#8203;5241](psf/black#5241))
- Fix `--skip-magic-trailing-comma` dropping the trailing comma of a one-element
  subscript (`a[x,]`) when the line is long enough to be split and contains a power
  operator ([#&#8203;5272](psf/black#5272))
- Fix crash when a standalone comment sits between tokens of a comprehension or lambda
  ([#&#8203;5144](psf/black#5144))
- Fix inline comments on a bracket inside a comprehension being dropped ([#&#8203;5330](psf/black#5330))
- Respect the magic trailing comma in a PEP 695 type parameter list containing a
  `*TypeVarTuple` or `**ParamSpec` ([#&#8203;5244](psf/black#5244))
- Fix crash when a comment-only `# fmt: off`/`# fmt: on` block is followed by a `with`
  statement after another standalone comment ([#&#8203;5189](psf/black#5189))
- Fix a crash when splitting `case case if ...` match patterns at very small line
  lengths ([#&#8203;5147](psf/black#5147))
- Fix multiline docstring indentation when leading tabs are used inside indented
  docstrings ([#&#8203;5148](psf/black#5148))
- Respect `# fmt: skip` on a line that opens a bracket (e.g.
  `from x import (  # fmt: skip`) when a standalone comment is among the bracket's
  contents ([#&#8203;5161](psf/black#5161))
- Fix an AST safety error when separate `# type: ignore` comments in a parenthesized
  attribute chain were merged onto one physical line ([#&#8203;5297](psf/black#5297))
- Preserve comments and blank lines outside requested ranges when formatting with
  `--line-ranges` ([#&#8203;5175](psf/black#5175))
- Fix crash when `# fmt: skip` is used on one-line `async def`, `async with`, and
  `async for` statements containing a semicolon ([#&#8203;5311](psf/black#5311))
- Stop converting form feeds or other similar characters in docstrings into newlines
  ([#&#8203;5461](psf/black#5461))
- Fix `--skip-source-first-line` turning the skipped line's CRLF ending into `\r\r\n`
  when reformatting a file with CRLF line endings ([#&#8203;5438](psf/black#5438))

##### Preview style

##### New preview features

- Add missing blank lines after classes whose last method has an ellipsis body ([#&#8203;5439](psf/black#5439))
- Split only the brackets holding a magic trailing comma when more trailers follow them
  (e.g. stop splitting inside the `[2]` of `[1,][2](3)`) ([#&#8203;5448](psf/black#5448))
- Keep dictionary keys containing operators together on one line when the value can be
  wrapped onto a new line instead ([#&#8203;5435](psf/black#5435))
- Remove redundant parentheses around individual variables in unpacking targets (e.g.
  `for (x), (y) in points:` becomes `for x, y in points:`) ([#&#8203;5416](psf/black#5416))
- Normalize uppercase `T` prefixes on t-strings to lowercase ([#&#8203;5425](psf/black#5425))
- Remove redundant parentheses around generator expressions ([#&#8203;5304](psf/black#5304), [#&#8203;5369](psf/black#5369))
- Preserve two blank lines before a top-level class starting inside a `# fmt: off` block
  after an import ([#&#8203;5238](psf/black#5238))
- Fix unnecessary parentheses around short right-hand expressions in indexed assignments
  (e.g. `x[key] = expr`) ([#&#8203;5095](psf/black#5095))
- Parenthesize tuple expressions in `yield` statements for consistency with function
  calls and returns ([#&#8203;5170](psf/black#5170))
- Stop splitting between a variable and its operator (`not in`, `==`, `is`, ...) when
  the right-hand side is a bracketed expression, and instead split inside the brackets
  ([#&#8203;5135](psf/black#5135))
- In `.pyi` stub files, enforce a blank line after a function or method that has a
  docstring-only body when another comment or statement follows it ([#&#8203;5158](psf/black#5158))

##### Updates to existing preview features

- Fix crash in stub files when `# fmt: skip` is placed on a function in a group of
  same-name decorated functions (e.g. `@overload`s or a property setter) ([#&#8203;5430](psf/black#5430))
- Keep the parentheses around a lambda used as the iterable of a comprehension (e.g.
  `[x for x in (lambda: 0) if x]`) ([#&#8203;5176](psf/black#5176), [#&#8203;5200](psf/black#5200))

##### Updates to existing unstable features

- Do not treat multi-line expressions with merged strings and trailing `# type: ignore`
  comments as single-line unsplittable expressions ([#&#8203;5466](psf/black#5466))
- Fix duplicated inline comment when stripping the parentheses around a string or
  merging a backslash-continued string on the same line ([#&#8203;5449](psf/black#5449))
- Split long stringified return annotations even when the function has parameters
  ([#&#8203;5427](psf/black#5427))
- Don't hug brackets when doing so would join two `# type: ignore` comments onto one
  line ([#&#8203;5271](psf/black#5271))
- Fix a crash when `# type: ignore` is lost during formatting of a long parenthesized
  string ([#&#8203;5329](psf/black#5329))
- Fix only the first part of an implicitly concatenated unmergeable string (e.g.
  `r"..." r"..."`) being wrapped in parentheses ([#&#8203;5434](psf/black#5434))

##### Configuration

- Add support for `NO_COLOR` environment variable to disable ANSI output ([#&#8203;5129](psf/black#5129))
- Remove spurious target version warning when runtime version is included in a
  `--target-version` flag ([#&#8203;5167](psf/black#5167))
- Fix `--force-exclude` not excluding files whose path contains `..` (e.g.
  `black ../generated/file.py` run from a subdirectory) ([#&#8203;5471](psf/black#5471))
- Fall back to the default configuration, with a warning, when the given sources share
  no common project root (e.g. they are on different drives on Windows) instead of
  crashing ([#&#8203;5386](psf/black#5386))
- Fix loading a stale cached `pyproject.toml` path when `--code` is used from different
  working directories in the same process ([#&#8203;5152](psf/black#5152))
- Add validation for `--line-ranges` values ([#&#8203;5107](psf/black#5107))
- Ignore empty cache files instead of raising an `EOFError` ([#&#8203;5192](psf/black#5192))
- Reject non-string `include` and `force-exclude` values in `pyproject.toml` ([#&#8203;5193](psf/black#5193))
- Validate `BLACK_NUM_WORKERS` values and report invalid values as usage errors instead
  of crashing ([#&#8203;5211](psf/black#5211))
- Ignore permission errors when reading cache ([#&#8203;5258](psf/black#5258))

##### Packaging

- Reduce the size of Linux standalone binaries by stripping debug symbols during the
  PyInstaller release build ([#&#8203;5223](psf/black#5223))
- Black is now released using GitHub
  [Immutable Releases](https://docs.github.com/code-security/concepts/supply-chain-security/immutable-releases)
  ([#&#8203;5296](psf/black#5296))

##### Performance

- Fix superlinear runtime growth with the number of input files ([#&#8203;5450](psf/black#5450))
- Improve performance on strings containing many consecutive backslashes ([#&#8203;5163](psf/black#5163))
- Improve performance on files with many `# fmt: skip`/`# fmt: off`/`# fmt: on` comments
  ([#&#8203;5169](psf/black#5169), [#&#8203;5190](psf/black#5190), [#&#8203;5232](psf/black#5232))
- Improve performance on long calls and collections ([#&#8203;5177](psf/black#5177))
- Improve performance on multiline strings inside large collections (e.g. a dict literal
  with triple-quoted strings as values) ([#&#8203;5188](psf/black#5188))
- Improve performance on files with many soft keywords (e.g. `match`/`case` blocks)
  ([#&#8203;5186](psf/black#5186))
- Improve performance on long semicolon-separated statements (in the stable style) and
  large dict literals (in `--preview`) ([#&#8203;5184](psf/black#5184))
- Improve performance of `--line-ranges` on files with many sibling blocks (a long
  `if`/`elif` chain, a `match` with many cases, or many top-level definitions) ([#&#8203;5213](psf/black#5213))
- Improve performance on deeply nested bracketed expressions ([#&#8203;5171](psf/black#5171), [#&#8203;5242](psf/black#5242))
- Improve performance on lists and subscripts holding one long expression without
  operators (e.g. a long run of implicitly concatenated strings inside `[]`) ([#&#8203;5239](psf/black#5239))
- Improve performance on deeply chained operations (e.g. a long `a ** b ** c ** ...`
  chain) ([#&#8203;5235](psf/black#5235))
- Improve performance on long `if`/`elif` chains and other compound statements with many
  clauses ([#&#8203;5322](psf/black#5322))
- Improve performance of `string_processing`:
  - when merging implicitly concatenated f-strings containing long string literals
    ([#&#8203;5165](psf/black#5165))
  - when merging long implicitly concatenated strings ([#&#8203;5173](psf/black#5173), [#&#8203;5194](psf/black#5194))
  - when rewriting large nodes (e.g. `"%s ..." % (a, b, c, ...)`) ([#&#8203;5178](psf/black#5178), [#&#8203;5199](psf/black#5199), [#&#8203;5220](psf/black#5220))
  - when splitting long string literals ([#&#8203;5183](psf/black#5183))

##### Output

- Report parser failures using editor-friendly `path:line:column` locations ([#&#8203;5237](psf/black#5237))
- Fix crash when writing formatted code or diffs to a `sys.stdout` that has no `buffer`
  attribute (e.g. in Jupyter notebooks) ([#&#8203;5411](psf/black#5411))
- Report parse failures on Black's own output as internal errors ([#&#8203;5383](psf/black#5383))

##### *Blackd*

- Return HTTP 400 instead of 500 when the `X-Python-Variant` header is empty or has an
  empty entry (e.g. a trailing comma) ([#&#8203;5428](psf/black#5428))
- Allow optional whitespace around comma-separated versions and `pyi` in the
  `X-Python-Variant` header ([#&#8203;5441](psf/black#5441))

##### Integrations

- Remove unused `migrate-black` script ([#&#8203;5319](psf/black#5319))
- Support PEP 440 version specifiers in `tool.black.required-version` for the GitHub
  Action ([#&#8203;5399](psf/black#5399))
- Add outputs (`is_formatted`, `change_count`, `same_count`, `failure_count`) to GitHub
  Action runs ([#&#8203;5408](psf/black#5408))

##### Documentation

- Document `vim-python-pep8-indent`, which provides an `indentexpr` for Black-style
  insert-mode indentation ([#&#8203;5288](psf/black#5288))

</details>

---

### Configuration

πŸ“… **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

β™» **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

πŸ”• **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xMzIuNSIsInVwZGF0ZWRJblZlciI6IjQ0LjEzMi41IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJ0eXBlL2RlcGVuZGVuY2llcyJdfQ==-->

Reviewed-on: https://git.tainton.uk/luke/instant-msg-api/pulls/290
Co-authored-by: renovate[bot] <renovate-bot@git.tainton.uk>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

string_processing: Error when re-flowing implicitly concatenated r-strings

2 participants