Repository navigation
Improve speed of textwrap.dedent #131791
Copy link
Copy link
Closed
Labels
performancePerformance or resource usagePerformance or resource usagestdlibStandard Library Python modules in the Lib/ directoryStandard Library Python modules in the Lib/ directorytype-featureA feature request or enhancementA feature request or enhancement
Description
Feature or enhancement
Proposal:
Current code:
_whitespace_only_re = re.compile('^[ \t]+$', re.MULTILINE)
_leading_whitespace_re = re.compile('(^[ \t]*)(?:[^ \t\n])', re.MULTILINE)
def dedent(text):
"""Remove any common leading whitespace from every line in `text`.
This can be used to make triple-quoted strings line up with the left
edge of the display, while still presenting them in the source code
in indented form.
Note that tabs and spaces are both treated as whitespace, but they
are not equal: the lines " hello" and "\\thello" are
considered to have no common leading whitespace.
Entirely blank lines are normalized to a newline character.
"""
# Look for the longest leading string of spaces and tabs common to
# all lines.
margin = None
text = _whitespace_only_re.sub('', text)
indents = _leading_whitespace_re.findall(text)
for indent in indents:
if margin is None:
margin = indent
# Current line more deeply indented than previous winner:
# no change (previous winner is still on top).
elif indent.startswith(margin):
pass
# Current line consistent with and no deeper than previous winner:
# it's the new winner.
elif margin.startswith(indent):
margin = indent
# Find the largest common whitespace between current line and previous
# winner.
else:
for i, (x, y) in enumerate(zip(margin, indent)):
if x != y:
margin = margin[:i]
break
# sanity check (testing/debugging only)
if 0 and margin:
for line in text.split("\n"):
assert not line or line.startswith(margin), \
"line = %r, margin = %r" % (line, margin)
if margin:
text = re.sub(r'(?m)^' + margin, '', text)
return textCan speed up the process for large files up to 4x the speed:
def dedent_faster(text: str, only_whitespace: bool = True) -> str:
"""Remove any common leading whitespace from every line in `text`.
This can be used to make triple-quoted strings line up with the left
edge of the display, while still presenting them in the source code
in indented form.
Note that tabs and spaces are both treated as whitespace, but they
are not equal: the lines " hello" and "\\thello" are
considered to have no common leading whitespace.
If `only_whitespace` is `True`, the leading whitespaces are removed from the text. Otherwise, all the common leading text is removed.
Entirely blank lines are normalized to a newline character.
"""
# Early return for empty input
if not text:
return text
# Split into lines
lines = text.splitlines(True)
# Fast path for single line - but make sure we still dedent!
if len(lines) == 1:
line = lines[0]
stripped = line.strip()
if not stripped: # Blank line
return "\n" if line.endswith("\n") else ""
# Find leading whitespace for a single line
if only_whitespace:
i = 0
while i < len(line) and line[i] in " \t":
i += 1
if i > 0: # Has leading whitespace to remove
return line[i:]
else:
lead_size = len(line) - len(line.lstrip())
if lead_size > 0: # Has leading whitespace to remove
return line[lead_size:]
return line # No whitespace to remove
# Cache method lookups for faster access
_strip = str.strip
_startswith = str.startswith
_endswith = str.endswith
# Find first two non-blank lines
non_blank = []
for line in lines:
if _strip(line):
non_blank.append(line)
if len(non_blank) == 2:
break
# All lines are blank
if not non_blank:
result = []
append = result.append
for line in lines:
append("\n" if _endswith(line, "\n") else "")
return "".join(result)
# Calculate margin length efficiently
if len(non_blank) == 1:
# Single non-blank line
line = non_blank[0]
if only_whitespace:
# Manually find leading whitespace (faster than regex)
i = 0
line_len = len(line)
while i < line_len and line[i] in " \t":
i += 1
margin_len = i
else:
# Use built-in lstrip for non-whitespace case
margin_len = len(line) - len(line.lstrip())
else:
# Find common prefix of first two non-blank lines
a, b = non_blank
min_len = min(len(a), len(b))
i = 0
if only_whitespace:
# Manual loop is faster than character-by-character comparison
while i < min_len and a[i] == b[i] and a[i] in " \t":
i += 1
else:
while i < min_len and a[i] == b[i]:
i += 1
margin_len = i
# No margin to remove - return original with blank line normalization
if margin_len == 0:
result = []
append = result.append
for line in lines:
if _strip(line): # Non-blank line
append(line)
else: # Blank line
append("\n" if _endswith(line, "\n") else "")
return "".join(result)
# Get margin string once for repeated comparison
margin = non_blank[0][:margin_len]
# Pre-allocate result list with a size hint for better memory efficiency
result = []
append = result.append
# Process all lines with optimized operations
for line in lines:
if not _strip(line): # Blank line (including whitespace-only lines)
append("\n" if _endswith(line, "\n") else "")
elif _startswith(line, margin): # Has margin
# Slice operation is very fast in Python
append(line[margin_len:])
else: # No matching margin
append(line)
# Single join is faster than incremental string building
return "".join(result)which has the following speed outputs:
if __name__ == '__main__':
with open("../Objects/unicodeobject.c") as f:
raw_text = f.read()
tests = []
test_names = []
test_names = ['', " ", "\t", "abc \t", ' \t abc', ' \t abc \t ']
index = 0
temp = dedent(raw_text)
for indent_v in test_names:
text = indent(raw_text, indent_v)
tests.append(text)
output = dedent_faster(text, only_whitespace=False)
print("Validating large text with not only whitespace indentation:", indent_v.encode("ascii"), output == temp)
# Basic indented text with empty lines
text = """
def hello():
print("Hello, world!")
"""
tests.append(text)
test_names.append("Basic indented text with empty lines")
# Text with mixed indentation and blank lines
text = """
This is a test.
Another line.
"""
tests.append(text)
test_names.append("Text with mixed indentation and blank lines")
# No indentation (edge case)
text = """No indents here.
Just normal text.
With empty lines."""
tests.append(text)
test_names.append("No indentation (edge case)")
# Only blank lines (should preserve them)
text = """
"""
tests.append(text)
test_names.append("Only blank lines")
# Edge case: No common prefix to remove
text = """hello
world
"""
tests.append(text)
test_names.append("Edge case: No common prefix to remove")
# Edge case: Single indented line
text = """ Only one indented line"""
tests.append(text)
test_names.append("Edge case: Single indented line")
# Edge case: Single indented line
text = """ """
tests.append(text)
test_names.append("Edge case: Single indented line only")
# Edge case: Single indented line
text = """"""
tests.append(text)
test_names.append("Edge case: Empty text")
for text, name in zip(tests, test_names):
print(f"========= Case: {name.encode('ascii')} =========")
a = dedent(text)
for func in [dedent_faster, dedent]:
single_test = func(text)
print(func.__name__, a == single_test)
it = timeit.Timer(lambda: func(text))
result = it.repeat(number=10_000)
result.sort()
print(f"{func.__name__} Min: {result[0]:.4f}msec")Returning the following:
Validating large text with not only whitespace indentation: b'' True
Validating large text with not only whitespace indentation: b' ' True
Validating large text with not only whitespace indentation: b'\t' True
Validating large text with not only whitespace indentation: b'abc \t' True
Validating large text with not only whitespace indentation: b' \t abc' True
Validating large text with not only whitespace indentation: b' \t abc \t ' True
========= Case: b'' =========
dedent_faster True
dedent_faster Min: 1.5848msec
dedent True
dedent Min: 6.6143msec
========= Case: b' ' =========
dedent_faster True
dedent_faster Min: 2.5275msec
dedent True
dedent Min: 10.6884msec
========= Case: b'\t' =========
dedent_faster True
dedent_faster Min: 2.3215msec
dedent True
dedent Min: 9.9588msec
========= Case: b'abc \t' =========
dedent_faster True
dedent_faster Min: 1.5026msec
dedent True
dedent Min: 6.7075msec
========= Case: b' \t abc' =========
dedent_faster True
dedent_faster Min: 2.5997msec
dedent True
dedent Min: 10.6693msec
========= Case: b' \t abc \t ' =========
dedent_faster True
dedent_faster Min: 2.6204msec
dedent True
dedent Min: 11.7285msec
========= Case: b'Basic indented text with empty lines' =========
dedent_faster True
dedent_faster Min: 0.0016msec
dedent True
dedent Min: 0.0022msec
========= Case: b'Text with mixed indentation and blank lines' =========
dedent_faster True
dedent_faster Min: 0.0016msec
dedent True
dedent Min: 0.0020msec
========= Case: b'No indentation (edge case)' =========
dedent_faster True
dedent_faster Min: 0.0008msec
dedent True
dedent Min: 0.0013msec
========= Case: b'Only blank lines' =========
dedent_faster True
dedent_faster Min: 0.0006msec
dedent True
dedent Min: 0.0005msec
========= Case: b'Edge case: No common prefix to remove' =========
dedent_faster True
dedent_faster Min: 0.0007msec
dedent True
dedent Min: 0.0008msec
========= Case: b'Edge case: Single indented line' =========
dedent_faster True
dedent_faster Min: 0.0004msec
dedent True
dedent Min: 0.0013msec
========= Case: b'Edge case: Single indented line only' =========
dedent_faster True
dedent_faster Min: 0.0002msec
dedent True
dedent Min: 0.0003msec
========= Case: b'Edge case: Empty text' =========
dedent_faster True
dedent_faster Min: 0.0001msec
dedent True
dedent Min: 0.0002msec
Which thus returns a 4x performance boost for large files. Another advantage is this this method allows for people to just remove all common prefixes to the file as an option instead of just whitespaces.
( This was optimized iteratively using Claude + ChatGPT models )
Has this already been discussed elsewhere?
No response given
Links to previous discussion of this feature:
No response
Linked PRs
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
performancePerformance or resource usagePerformance or resource usagestdlibStandard Library Python modules in the Lib/ directoryStandard Library Python modules in the Lib/ directorytype-featureA feature request or enhancementA feature request or enhancement
Activity
Marius-Juston commentedon Mar 27, 2025
#131792
StanFromIreland commentedon Mar 27, 2025
Firstly, it seems this issue and pr are fully generated by ai, please see the devguide.
Secondly, it seems you have both a proposal to optimize and a proposal for new functionality? These generally should not be mixed together. Discussions for new functionality should be held in the discuss python-ideas.
Lastly, changes like this will need tests, I also doubt they pass existing ones, for some reason the CI has not run on your PR.
When one applies provided patch:
donbarbos commentedon Mar 27, 2025
looks like related to #130167
Marius-Juston commentedon Mar 27, 2025
Yeah, I was clearly overeager for the performance improvement, and I clearly did not do my due diligence. I will probably just close everything and work on it offline until a proper solution is developed. In the linked PR I was made aware of the these comments.
donbarbos commentedon Mar 27, 2025
i don't think you should close this issue (as completed), i also think your PR could be split, for example send something that replaces the use of
rein #130167if you want to add a feature, for example a new argument, then first write about it separately and don't send it in the same PR since it's a separate topic for discussion
but your changes are good, i would be glad to see them
it's better to start with small changes :)
Marius-Juston commentedon Mar 27, 2025
Appreciate the comment!
Another promising approach is simply this (non AI generated this time lol ) which greatly simplifies everything with the same-ish performance gains
Marius-Juston commentedon Mar 28, 2025
@donbarbos thank you for the suggestion, I will be reopen the issue.
In addition:
has been now further optimized to just
with no noticeable performance impacts
Marius-Juston commentedon Mar 28, 2025
I feel like there should be a way to good way cache the line.lstrip() since it is computed twice, once in the list comprehension and once in the filter. After trying a bunch of things I have not been able to get the performance to increase from this new implementation.
My current best implementation of that:
I also tried to encode the margins through a binary encoding since the lstrip only extracts
and\t, so I thoughts using a numerical encoding rather than using string comparisons might help? But sadly that did not become faster.I guess currently the C compiled
lstripis so fast that it does not really matter. This is; however, twice as fast for the case in large files where there is no white spaces.( I have validated that both set of functions above do pass all the test cases inside the test suite )
I thought that the bit method would be interesting to use but that is probably best suited for a C implementation?
Marius-Juston commentedon Mar 28, 2025
Speed comparisons
StanFromIreland commentedon Mar 28, 2025
This is a duplicate of #130167 (well, the pr is, and the feature request needs its own issue)
donbarbos commentedon Mar 28, 2025
yeah, we can rename current issue to somethink like
Add only_whitespace option to textwrap.dedentand reassign the existing PR togh-130167Marius-Juston commentedon Mar 28, 2025
I have actually removed that option now since @ZeroIntensity informed me to remove that flag. So this PR is now purely on speed optimizations for the function. I can add it back to the new current code as it should be easy to do if so desired.
[-]Optimize textwrap.dedent[/-][+]Improve speed of `textwrap.dedent` by replacing `re`[/+][-]Improve speed of `textwrap.dedent` by replacing `re`[/-][+]gh-gh-131791: Improve speed of `textwrap.dedent` by replacing `re`[/+][-]gh-gh-131791: Improve speed of `textwrap.dedent` by replacing `re`[/-][+]gh-131791: Improve speed of `textwrap.dedent` by replacing `re`[/+]donbarbos commentedon Mar 28, 2025
no :-) I mean change title of current issue to
Add only_whitespace option to textwrap.dedentand change your PR title togh-130167: Improve speed of `textwrap.dedent` by replacing `re`P.S.: i'm taking only about title, please, don't add more functionality to your code. you can do it later in a separate PR
picnixz commentedon Mar 28, 2025
IMO, I wouldn't put it under the same issue as this one is really a change of algorithm. 130167 was meant to track issues that are "easy" to review (not those for which the code base is changed).
FTR, we don't like adding C implementations when it adds maintenance burden. Even if we end up being much faster, the cost of a C implementation to maintain is usually not worth.
I'm continuing a review on the PR side, especially concerning the shown benchmarks and the implementation.
[-]gh-131791: Improve speed of `textwrap.dedent` by replacing `re`[/-][+]Add `only_whitespace` option to `textwrap.dedent`[/+]Marius-Juston commentedon Mar 28, 2025
Sounds good, done!
Marius-Juston commentedon Mar 28, 2025
Makes sense!
Sounds good, if I did something is stupid, please let me know!
picnixz commentedon Mar 28, 2025
Adding
only_whitespaceis a new feature that probably requires a DPO thread IMO. I can see some use when a file mixes both spaces and tabs, in which case one would be happy to dedent the entire text without bothering about them, but OTOH, I'm afraid that this isn't useful if we're only considering removing whitespaces (so I'm more +0). I think it would make sense to provide a new function to remove a common prefix (say,textwrap.remove_common_prefix()), however this also requires a DPO discussion.Marius-Juston commentedon Mar 28, 2025
This is already what
dedentdoes. It removes all common whitespace so all the prefixedand\tand any combination of them as indentation.For the
remove_common_prefixI was thinking it's use could be for things like this (which is something I remember coming across):etc...
So I can see the this function changing it to
instead. But yeah, overall, it is not incredibly useful and very nichel.
picnixz commentedon Apr 5, 2025
I'm going to close this one as the original purpose was to make
textwrap.dedent()faster.[-]Add `only_whitespace` option to `textwrap.dedent`[/-][+]Improve speed of `textwrap.dedent`[/+]