While working on documentation for d-strings, I have been reconsidering how blank lines (lines consisting only of whitespace) are handled.
The current PEP specifies the following behavior:
- If a blank line is a prefix of the longest common indent, it is normalized to an empty line.
- If it starts with the longest common indent and is longer than it, the longest common indent is stripped from it, just like from any other line.
- Otherwise—for example, when the longest common indent is 8 spaces and the blank line is
<TAB><newline>—an IndentationError is raised.
Although this specification is theoretically consistent, it makes the conditions for IndentationError hard to explain.
If a TAB character is unintentionally mixed into a text block indented with spaces, and the line is not a blank line, no error occurs; the common indent to be removed just becomes shorter than intended. This is because it is hard to tell whether the TAB character is intentional or not.
On the other hand, when an unintended TAB is in a blank line, we can detect the mismatch with the common indent and raise an error. However, explaining to beginners why the behavior differs between blank lines and other lines is difficult.
To make this easier to explain, I am considering a spec change that adds normalization of blank lines to empty lines to the d-string behavior, just like dedent() does.
With this change, a d-string never raises IndentationError, so there is no need to teach the conditions for it, and since the difference from dedent() becomes smaller, teaching that difference also becomes easier.
For example, in the following code, the blank line contains only a TAB character (---> represents a TAB). Under the current spec this raises IndentationError, but under the proposed spec the blank line is normalized to an empty line, so no error occurs.
s = d"""
foo
--->
bar
"""
# Current spec: IndentationError
# Proposed spec: "foo\n\nbar\n"
Meanwhile, I would like to keep the ability to preserve some indentation using the closing quote, because I found several places where it is useful, both in CPython’s own code and elsewhere. That is, the line containing the closing quote is treated as a non-blank line when computing the longest common indent.
Accordingly, I would like to exclude the line containing the closing quote from the blank-line normalization as well.
This is a difference from dedent(), and it may look strange at first glance that a line consisting only of spaces remains in the result.
However, if we define the line containing the closing quote as a non-blank line, the rules stay simple.
s1 = d"""
foo
bar"""
assert s1 == "foo\nbar"
s2 = d"""
foo
bar
"""
assert s2 == "foo\nbar\n "
The rules for indent removal and blank-line normalization are as follows.
These steps are applied to the physical lines of the source code, before escape sequences are processed. Therefore, escape sequences such as \x20 and line-continuation backslashes are not treated as whitespace.
- A line consisting only of TABs, spaces, and a newline is defined as a blank line. However, the line containing the closing quote is never considered a blank line, even if its content within the string consists only of whitespace.
- A blank line is converted to a single newline character.
- The longest prefix consisting only of spaces and TABs that is common to all non-blank lines is defined as the longest common indent. Since the line containing the closing quote is always a non-blank line, there is always at least one non-blank line.
- The longest common indent is stripped from each non-blank line. Since it is a prefix common to all non-blank lines, this step always succeeds. Therefore, a d-string never raises IndentationError.
Do you have any feedback on this spec change?
P.S. I used Opus 4.8 and Fable 5 on Cursor to develop this spec change and write it up in English.