From 40be9e7eaa161abbff90130b5e3097844afe8149 Mon Sep 17 00:00:00 2001
From: Graham Preston <16179442+grahamcracker1234@users.noreply.github.com>
Date: Mon, 17 Aug 2026 20:50:21 -0400
Subject: [PATCH 01/15] Align dedent behavior with PEP 822
Use exact indentation-prefix semantics while preserving interpolation support and documenting runtime differences.
---
README.md | 101 +--
pyproject.toml | 5 +-
src/dedent/__init__.py | 2 +-
src/dedent/_dedent.py | 147 +++--
tests/__snapshots__/test_dedent.ambr | 766 ---------------------
tests/test_dedent.py | 949 ++++++++++-----------------
uv.lock | 85 ++-
7 files changed, 593 insertions(+), 1462 deletions(-)
delete mode 100644 tests/__snapshots__/test_dedent.ambr
diff --git a/README.md b/README.md
index bc3c5ac..bfe1461 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@ Dedent and strip multiline strings, and keep interpolated values alignedβwitho
## Table of Contents
- [Usage](#usage)
+- [Dedentation Rules](#dedentation-rules)
- [Installation](#installation)
- [Options](#options)
- [`align`](#align)
@@ -25,7 +26,7 @@ name = "Alice"
greeting = dedent(t"""
Hello, {name}!
Welcome to the party.
-""")
+ """)
print(greeting)
# Hello, Alice!
# Welcome to the party.
@@ -34,12 +35,12 @@ print(greeting)
items = dedent("""
- apples
- bananas
-""")
+ """)
shopping_list = dedent(t"""
Groceries:
{items:align}
---
-""")
+ """)
print(shopping_list)
# Groceries:
# - apples
@@ -47,6 +48,48 @@ print(shopping_list)
# ---
```
+## Dedentation Rules
+
+`dedent` follows the indentation model proposed by
+[PEP 822](https://peps.python.org/pep-0822/):
+
+1. Indentation is an exact prefix of spaces and tabs; a tab never equals spaces.
+2. The longest indentation prefix shared by every nonblank line is removed.
+3. Whitespace-only lines do not determine that prefix, except for the final line. A final
+ whitespace-only line is treated like the line containing closing triple quotes.
+4. Every line must be compatible with the chosen prefix. Otherwise, `IndentationError` is raised.
+5. The closing-quotes indentation can preserve intentional indentation:
+
+```python
+result = dedent("""
+ Hello
+ World!
+ """)
+
+assert result == " Hello\n World!"
+```
+
+### Why `IndentationError`?
+
+Whitespace-only lines are ignored when finding the common prefix, but they must still be
+compatible with it when that prefix is removed:
+
+```python
+dedent("\n hello\n \t\n world\n ")
+# IndentationError: inconsistent indentation in dedented string at line 3
+```
+
+Here the nonblank lines and closing line establish a two-space prefix. The middle line contains
+one space followed by a tab, so that exact prefix cannot be removed. Raising an error catches
+mixed or malformed indentation instead of silently changing or preserving ambiguous whitespace.
+A shorter whitespace-only line is valid when it matches the beginning of the prefix; it simply
+becomes empty. This mirrors PEP 822.
+
+With the default `strip="smart"`, the usual opening newline and closing-quotes line are removed
+from the result. Unlike native d-strings, `dedent()` runs after Python parses a string, so escape
+sequences have already been processed. For t-strings, literal text is dedented before
+interpolations are rendered.
+
## Installation
```bash
@@ -79,14 +122,14 @@ from dedent import dedent
items = dedent("""
- one
- two
-""")
+ """)
result = dedent(t"""
Aligned:
{items:align}
Not aligned:
{items}
-""")
+ """)
print(result)
# Aligned:
@@ -113,7 +156,7 @@ from dedent import dedent
items = dedent("""
- one
- two
-""")
+ """)
result = dedent(
t"""
@@ -122,7 +165,7 @@ result = dedent(
List 2:
{items}
---
- """,
+ """,
align=True,
)
@@ -149,7 +192,7 @@ from dedent import dedent
result = dedent("""
hello!
-""")
+ """)
print(repr(result))
# 'hello!'
@@ -169,7 +212,7 @@ result = dedent(
hello!
- """,
+ """,
strip="all",
)
print(repr(result))
@@ -186,7 +229,7 @@ from dedent import dedent
result = dedent(
"""
hello!
- """,
+ """,
strip="none",
)
@@ -205,7 +248,7 @@ from dedent import align, dedent
message = dedent("""
Hello,
World!
-""")
+ """)
print(message)
# Hello,
# World!
@@ -214,12 +257,12 @@ print(message)
items = dedent("""
- apples
- bananas
-""")
+ """)
shopping_list = dedent(f"""
Groceries:
{align(items)}
---
-""")
+ """)
print(shopping_list)
# Groceries:
# - apples
@@ -227,31 +270,9 @@ print(shopping_list)
# ---
```
-Per-value control with `align()` mirrors the format spec directives available on 3.14+:
-
-```python
-from dedent import align, dedent
-
-items = dedent("""
- - one
- - two
-""")
-
-result = dedent(f"""
- Aligned:
- {align(items)}
- Not aligned:
- {items}
-""")
-
-print(result)
-# Aligned:
-# - one
-# - two
-# Not aligned:
-# - one
-# - two
-```
+Because Python renders an f-string before calling `dedent()`, wrap every multiline interpolation
+with `align()`. Unwrapped continuation lines at column zero correctly reduce the common indentation
+prefix to zero. T-strings retain interpolation boundaries, so they do not have this limitation.
> There is no equivalent of the [`align` argument](#align-argument) in Python 3.10-3.13. There is no way to automatically align multiline values when using f-strings.
@@ -346,13 +367,13 @@ groceries = dedent("""
- apples
- bananas
- cherries
-""")
+ """)
shopping_list = dedent(t"""
Groceries:
{groceries:align}
---
-""")
+ """)
print(shopping_list)
# Groceries:
diff --git a/pyproject.toml b/pyproject.toml
index 33eec67..5448ca1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -62,10 +62,10 @@ Changelog = "https://github.com/grahamcracker1234/dedent/releases"
[dependency-groups]
dev = [
"basedpyright>=1.37.1",
+ "inline-snapshot>=0.35.4",
"pytest>=9.0.2",
"pytest-cov>=7.0.0",
"ruff>=0.14.11",
- "syrupy>=5.1.0",
]
[build-system]
@@ -202,3 +202,6 @@ convention = "pep257"
[tool.pytest.ini_options]
pythonpath = ["src"]
addopts = ["--import-mode=importlib"]
+
+[tool.inline-snapshot]
+format-command = "ruff format --stdin-filename {filename}"
diff --git a/src/dedent/__init__.py b/src/dedent/__init__.py
index de62f62..b893c26 100644
--- a/src/dedent/__init__.py
+++ b/src/dedent/__init__.py
@@ -7,7 +7,7 @@
Key features:
-- Removes minimum common indentation from all lines
+- Uses PEP 822's exact common-prefix and closing-line indentation rules
- Smart-strips leading/trailing whitespace by default
- Aligns multiline interpolated values to match surrounding indentation
- On Python 3.14+: t-strings with per-value format spec directives (`{value:align}`)
diff --git a/src/dedent/_dedent.py b/src/dedent/_dedent.py
index 6490841..8dca744 100644
--- a/src/dedent/_dedent.py
+++ b/src/dedent/_dedent.py
@@ -3,7 +3,6 @@
from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
from enum import Enum
-from functools import reduce
from itertools import filterfalse, tee
from typing import TYPE_CHECKING, Final, Literal, cast, final
from uuid import uuid4
@@ -29,7 +28,6 @@ class AlignSpec(str, Enum):
Strip = Literal["smart", "all", "none"]
_INDENTED: Final = re.compile(r"^(\s+)")
-_INDENTED_WITH_CONTENT: Final = re.compile(r"^(\s+)\S+")
_SMART_STRIP: Final = re.compile(r"^[^\S\n]*\n?")
_ALIGN_MARKER_PREFIX: Final = "DEDENT_ALIGN"
@@ -104,33 +102,59 @@ def _strip_string(string: str, strip: Strip) -> str:
def _dedent_string(string: str) -> str:
"""
- Remove common leading whitespace from a string.
+ Remove the longest common leading indentation from a string.
+
+ This follows the indentation rules proposed by PEP 822: indentation is an
+ exact prefix of spaces and tabs, whitespace-only lines are ignored, and a
+ final whitespace-only line constrains the indentation like a closing
+ triple-quote line.
Args:
string: The string to dedent.
+ Raises:
+ IndentationError: If a line's leading whitespace is inconsistent with the
+ common indentation prefix.
+
Returns:
The dedented string.
"""
lines = string.split("\n")
- max_indent = len(string)
- min_indent: int = reduce(
- lambda acc, line: (
- min(acc, len(indent))
- if (indent := _safe_match_first_group(_INDENTED_WITH_CONTENT, line))
- else acc
- ),
- lines,
- max_indent,
- )
-
- if min_indent == max_indent:
+ last_index = len(lines) - 1
+ indentations = [
+ line[: len(line) - len(line.lstrip(" \t"))]
+ for index, line in enumerate(lines)
+ if line.strip(" \t") or (last_index > 0 and index == last_index)
+ ]
+
+ if not indentations:
return string
- return "\n".join(
- line[min_indent:] if len(line) >= min_indent and line[:min_indent].isspace() else line
- for line in lines
- )
+ indentation = indentations[0]
+ for candidate in indentations[1:]:
+ common_length = 0
+ for left, right in zip(indentation, candidate, strict=False):
+ if left != right:
+ break
+ common_length += 1
+ indentation = indentation[:common_length]
+ if not indentation:
+ return string
+
+ dedented_lines: list[str] = []
+ for line_number, line in enumerate(lines, start=1):
+ if len(line) >= len(indentation):
+ if not line.startswith(indentation):
+ message = f"inconsistent indentation in dedented string at line {line_number}"
+ raise IndentationError(message)
+ dedented_lines.append(line[len(indentation) :])
+ elif indentation.startswith(line):
+ dedented_lines.append("")
+ else:
+ message = f"inconsistent indentation in dedented string at line {line_number}"
+ raise IndentationError(message)
+
+ return "\n".join(dedented_lines)
@final
@@ -241,6 +265,29 @@ def process_align_markers(string: str) -> str:
return "".join(result_parts)
+def _dedent_marked_string(string: str, strip: Strip) -> str:
+ """
+ Dedent static text without letting aligned values affect its indentation.
+
+ Returns:
+ The dedented string with deferred values restored and aligned.
+ """
+ marker_id = uuid4().hex
+ deferred_markers: list[tuple[str, str]] = []
+
+ def defer_marker(match: re.Match[str]) -> str:
+ token = f"{_SEP}{_ALIGN_MARKER_PREFIX}:{marker_id}:{len(deferred_markers)}{_SEP}"
+ deferred_markers.append((token, match.group()))
+ return token
+
+ string = _dedent_string(_ALIGN_MARKER.sub(defer_marker, string))
+
+ for token, marker in deferred_markers:
+ string = string.replace(token, marker, 1)
+
+ return _strip_string(process_align_markers(string), strip)
+
+
if sys.version_info >= (3, 14):
from string.templatelib import Interpolation, Template, convert
from typing import LiteralString, TypeVar
@@ -333,6 +380,36 @@ def _handle_item(
return value
+ def _dedent_template(template: Template, *, align: bool, strip: Strip) -> str:
+ """
+ Dedent template literals before rendering their interpolations.
+
+ Returns:
+ The dedented and rendered template.
+ """
+ marker_id = uuid4().hex
+ interpolations: list[tuple[str, Interpolation]] = []
+ framework_parts: list[str] = []
+
+ for item in template:
+ if isinstance(item, str):
+ framework_parts.append(item)
+ continue
+
+ token = f"{_SEP}{_ALIGN_MARKER_PREFIX}:{marker_id}:{len(interpolations)}{_SEP}"
+ interpolations.append((token, item))
+ framework_parts.append(token)
+
+ framework = _dedent_string("".join(framework_parts))
+ result = ""
+
+ for token, interpolation in interpolations:
+ preceding, framework = framework.split(token, maxsplit=1)
+ result += preceding
+ result += _handle_item(interpolation, preceding_text=result, align=align)
+
+ return _strip_string(process_align_markers(result + framework), strip)
+
def dedent( # pyright: ignore[reportUnreachable]
string: Template | LiteralString,
/,
@@ -343,9 +420,10 @@ def dedent( # pyright: ignore[reportUnreachable]
r'''
Dedent, strip, and align a template string.
- This function removes the minimum common indentation from all lines in the string,
- preserving relative indentation. It supports both literal strings and t-strings (Template
- objects) with interpolations.
+ This function removes the longest exact indentation prefix shared by all nonblank lines,
+ preserving relative indentation. A final whitespace-only line constrains the prefix like
+ the closing-quotes line in PEP 822. It supports both literal strings and t-strings
+ (`Template` objects) with interpolations.
For t-strings, interpolated values can include format spec directives:
- `{value:align}` - Enable alignment for this value
@@ -374,7 +452,7 @@ def dedent( # pyright: ignore[reportUnreachable]
>>> from dedent import dedent
>>> result = dedent(t"""
... Hello, {"World"}!
- ... """)
+ ... """)
>>> print(result)
Hello, World!
'''
@@ -383,21 +461,13 @@ def dedent( # pyright: ignore[reportUnreachable]
match string:
case str() as formatted_string:
- pass
+ return _dedent_marked_string(formatted_string, strip)
case Template() as template:
- formatted_string = reduce(
- lambda acc, item: acc + _handle_item(item, preceding_text=acc, align=align),
- template,
- initial="",
- )
+ return _dedent_template(template, align=align, strip=strip)
case unknown if not TYPE_CHECKING: # pyright: ignore[reportUnnecessaryComparison]
message = f"expected str or Template, not {type(unknown).__qualname__!r}" # pyright: ignore[reportUnreachable]
raise TypeError(message)
- formatted_string = process_align_markers(formatted_string)
- formatted_string = _dedent_string(formatted_string)
- return _strip_string(formatted_string, strip)
-
else:
def dedent( # pyright: ignore[reportUnreachable]
@@ -409,8 +479,9 @@ def dedent( # pyright: ignore[reportUnreachable]
r'''
Dedent and strip a string, with optional multiline-value alignment.
- This function removes the minimum common indentation from all lines in the string,
- preserving relative indentation. Use `align()` to mark interpolated values for automatic
+ This function removes the longest exact indentation prefix shared by all nonblank lines,
+ preserving relative indentation. A final whitespace-only line constrains the prefix like
+ the closing-quotes line in PEP 822. Use `align()` to mark interpolated values for automatic
indentation alignment inside f-strings.
Args:
@@ -433,7 +504,7 @@ def dedent( # pyright: ignore[reportUnreachable]
>>> result = dedent(f"""
... List:
... {align(items)}
- ... """)
+ ... """)
>>> print(result)
List:
- a
@@ -445,6 +516,4 @@ def dedent( # pyright: ignore[reportUnreachable]
message = f"expected str, not {type(string).__qualname__!r}"
raise TypeError(message) # pyright: ignore[reportUnreachable]
- formatted_string = process_align_markers(string)
- formatted_string = _dedent_string(formatted_string)
- return _strip_string(formatted_string, strip)
+ return _dedent_marked_string(string, strip)
diff --git a/tests/__snapshots__/test_dedent.ambr b/tests/__snapshots__/test_dedent.ambr
deleted file mode 100644
index 981df9a..0000000
--- a/tests/__snapshots__/test_dedent.ambr
+++ /dev/null
@@ -1,766 +0,0 @@
-# serializer version: 1
-# name: TestAlign.test_aligned_and_plain[False]
- '''
- List:
- - apples
- - bananas
- Plain:
- hello
- '''
-# ---
-# name: TestAlign.test_aligned_and_plain[True]
- '''
- List:
- - apples
- - bananas
- Plain:
- hello
- '''
-# ---
-# name: TestAlign.test_aligned_and_plain[align0]
- '''
- List:
- - apples
- - bananas
- Plain:
- hello
- '''
-# ---
-# name: TestAlign.test_aligned_and_plain_legacy[False]
- '''
- List:
- - apples
- - bananas
- Plain:
- hello
- '''
-# ---
-# name: TestAlign.test_aligned_and_plain_legacy[True]
- '''
- List:
- - apples
- - bananas
- Plain:
- hello
- '''
-# ---
-# name: TestAlign.test_aligned_and_plain_legacy[align0]
- '''
- List:
- - apples
- - bananas
- Plain:
- hello
- '''
-# ---
-# name: TestAlign.test_empty_string[False]
- ''
-# ---
-# name: TestAlign.test_empty_string[True]
- ''
-# ---
-# name: TestAlign.test_empty_string[align0]
- ''
-# ---
-# name: TestAlign.test_empty_string_legacy[False]
- ''
-# ---
-# name: TestAlign.test_empty_string_legacy[True]
- ''
-# ---
-# name: TestAlign.test_empty_string_legacy[align0]
- ''
-# ---
-# name: TestAlign.test_no_indentation[False]
- '''
- - apples
- - bananas
- - cherries
- '''
-# ---
-# name: TestAlign.test_no_indentation[True]
- '''
- - apples
- - bananas
- - cherries
- '''
-# ---
-# name: TestAlign.test_no_indentation[align0]
- '''
- - apples
- - bananas
- - cherries
- '''
-# ---
-# name: TestAlign.test_no_indentation_legacy[False]
- '''
- - apples
- - bananas
- - cherries
- '''
-# ---
-# name: TestAlign.test_no_indentation_legacy[True]
- '''
- - apples
- - bananas
- - cherries
- '''
-# ---
-# name: TestAlign.test_no_indentation_legacy[align0]
- '''
- - apples
- - bananas
- - cherries
- '''
-# ---
-# name: TestAlign.test_numeric_format
- '''
- Value:
- 1.23
- '''
-# ---
-# name: TestAlign.test_numeric_format_legacy
- '''
- Value:
- 1.23
- '''
-# ---
-# name: TestAlign.test_override[False-align]
- '''
- List:
- - apples
- - bananas
- - cherries
- ---
- '''
-# ---
-# name: TestAlign.test_override[False-noalign]
- '''
- List:
- - apples
- - bananas
- - cherries
- ---
- '''
-# ---
-# name: TestAlign.test_override[True-align]
- '''
- List:
- - apples
- - bananas
- - cherries
- ---
- '''
-# ---
-# name: TestAlign.test_override[True-noalign]
- '''
- List:
- - apples
- - bananas
- - cherries
- ---
- '''
-# ---
-# name: TestAlign.test_override[align0-align]
- '''
- List:
- - apples
- - bananas
- - cherries
- ---
- '''
-# ---
-# name: TestAlign.test_override[align0-noalign]
- '''
- List:
- - apples
- - bananas
- - cherries
- ---
- '''
-# ---
-# name: TestAlign.test_override_with_format_spec[False-align]
- '''
- List:
-
- - apples
- - bananas
- - cherries
-
-
- - apples
- - bananas
- - cherries
-
- ---
- '''
-# ---
-# name: TestAlign.test_override_with_format_spec[False-noalign]
- '''
- List:
-
- - apples
- - bananas
- - cherries
-
-
- - apples
- - bananas
- - cherries
-
- ---
- '''
-# ---
-# name: TestAlign.test_override_with_format_spec[True-align]
- '''
- List:
-
- - apples
- - bananas
- - cherries
-
-
- - apples
- - bananas
- - cherries
-
- ---
- '''
-# ---
-# name: TestAlign.test_override_with_format_spec[True-noalign]
- '''
- List:
-
- - apples
- - bananas
- - cherries
-
-
- - apples
- - bananas
- - cherries
-
- ---
- '''
-# ---
-# name: TestAlign.test_repr_conversion
- '''
- Value:
- 'hello'
- '''
-# ---
-# name: TestAlign.test_repr_conversion_legacy
- '''
- Value:
- 'hello'
- '''
-# ---
-# name: TestAlign.test_str_conversion
- '''
- List:
- - apples
- - bananas
- '''
-# ---
-# name: TestAlign.test_str_conversion_legacy
- '''
- List:
- - apples
- - bananas
- '''
-# ---
-# name: TestAlign.test_string_format
- '''
- Header:
- hi
- '''
-# ---
-# name: TestAlign.test_string_format_legacy
- '''
- Header:
- hi
- '''
-# ---
-# name: TestAlign.test_two_aligned_values[False]
- '''
- A:
- line1
- line2
- B:
- foo
- bar
- '''
-# ---
-# name: TestAlign.test_two_aligned_values[True]
- '''
- A:
- line1
- line2
- B:
- foo
- bar
- '''
-# ---
-# name: TestAlign.test_two_aligned_values[align0]
- '''
- A:
- line1
- line2
- B:
- foo
- bar
- '''
-# ---
-# name: TestAlign.test_two_aligned_values_legacy[False]
- '''
- A:
- line1
- line2
- B:
- foo
- bar
- '''
-# ---
-# name: TestAlign.test_two_aligned_values_legacy[True]
- '''
- A:
- line1
- line2
- B:
- foo
- bar
- '''
-# ---
-# name: TestAlign.test_two_aligned_values_legacy[align0]
- '''
- A:
- line1
- line2
- B:
- foo
- bar
- '''
-# ---
-# name: TestAlign.test_with_multiple_lines[False]
- '''
- List:
- - apples
- - bananas
- - cherries
- ---
- '''
-# ---
-# name: TestAlign.test_with_multiple_lines[True]
- '''
- List:
- - apples
- - bananas
- - cherries
- ---
- '''
-# ---
-# name: TestAlign.test_with_multiple_lines[align0]
- '''
- List:
- - apples
- - bananas
- - cherries
- ---
- '''
-# ---
-# name: TestAlign.test_with_multiple_lines_legacy[False]
- '''
- List:
- - apples
- - bananas
- - cherries
- ---
- '''
-# ---
-# name: TestAlign.test_with_multiple_lines_legacy[True]
- '''
- List:
- - apples
- - bananas
- - cherries
- ---
- '''
-# ---
-# name: TestAlign.test_with_multiple_lines_legacy[align0]
- '''
- List:
- - apples
- - bananas
- - cherries
- ---
- '''
-# ---
-# name: TestAlign.test_with_single_line[False]
- '''
- List:
- - apples
- ---
- '''
-# ---
-# name: TestAlign.test_with_single_line[True]
- '''
- List:
- - apples
- ---
- '''
-# ---
-# name: TestAlign.test_with_single_line[align0]
- '''
- List:
- - apples
- ---
- '''
-# ---
-# name: TestAlign.test_with_single_line_legacy[False]
- '''
- List:
- - apples
- ---
- '''
-# ---
-# name: TestAlign.test_with_single_line_legacy[True]
- '''
- List:
- - apples
- ---
- '''
-# ---
-# name: TestAlign.test_with_single_line_legacy[align0]
- '''
- List:
- - apples
- ---
- '''
-# ---
-# name: TestDedent.test_does_not_strip_explicit_newlines
- '''
-
Hello world!
-
- '''
-# ---
-# name: TestDedent.test_does_not_strip_explicit_newlines_with_min_indent
- '''
-
- Hello world!
-
-
- '''
-# ---
-# name: TestDedent.test_empty_format_spec
- '123'
-# ---
-# name: TestDedent.test_empty_format_spec_legacy
- '123'
-# ---
-# name: TestDedent.test_escaped_explicit_newlines_and_tabs
- '\\n\\tfirst\\n'
-# ---
-# name: TestDedent.test_format_spec
- '''
- =======Receipt=======
- - Total: 123
- - Discount: 12.35%
- '''
-# ---
-# name: TestDedent.test_format_spec_legacy
- '''
- =======Receipt=======
- - Total: 123
- - Discount: 12.35%
- '''
-# ---
-# name: TestDedent.test_works_with_blank_first_line
- '''
- Some text that I might want to indent:
- * reasons
- * fun
- That's all.
- '''
-# ---
-# name: TestDedent.test_works_with_explicit_tabs_for_indentation
- '''
- first
- second
- third
- '''
-# ---
-# name: TestDedent.test_works_with_interpolation
- '''
- first line
- second
- third
- '''
-# ---
-# name: TestDedent.test_works_with_interpolation_legacy
- '''
- first line
- second
- third
- '''
-# ---
-# name: TestDedent.test_works_with_multiple_blank_first_lines
- '''
-
- first
- second
- third
- '''
-# ---
-# name: TestDedent.test_works_with_multiple_blank_first_lines_legacy
- '''
-
- first
- second
- third
- '''
-# ---
-# name: TestDedent.test_works_with_removing_same_number_of_spaces
- '''
- first
- second
- third
- '''
-# ---
-# name: TestDedent.test_works_with_removing_same_number_of_spaces_legacy
- '''
- first
- second
- third
- '''
-# ---
-# name: TestDedent.test_works_with_spaces_for_indentation
- '''
- first
- second
- third
- '''
-# ---
-# name: TestDedent.test_works_with_suppressed_newlines
- '''
- first second
- third
- '''
-# ---
-# name: TestDedent.test_works_with_suppressed_newlines_legacy
- '''
- first second
- third
- '''
-# ---
-# name: TestDedent.test_works_with_tabs_for_indentation
- '''
- first
- second
- third
- '''
-# ---
-# name: TestDedent.test_works_without_interpolation
- '''
- first
- second
- third
- '''
-# ---
-# name: TestSingleLine.test_works
- 'A single line of input.'
-# ---
-# name: TestSingleLine.test_works_with_closing_quotes_on_newline
- 'A single line of input.'
-# ---
-# name: TestSingleLine.test_works_with_opening_quotes_on_newline
- 'A single line of input.'
-# ---
-# name: TestSingleLine.test_works_with_quotes_on_newlines
- 'A single line of input.'
-# ---
-# name: TestStrip.test_with_inverse_special_whitespace[all]
- '''
- foo
- bar
- '''
-# ---
-# name: TestStrip.test_with_inverse_special_whitespace[none]
- '''
-
-
- foo
- bar
-
-
- '''
-# ---
-# name: TestStrip.test_with_inverse_special_whitespace[smart]
- '''
-
- foo
- bar
-
- '''
-# ---
-# name: TestStrip.test_with_inverse_special_whitespace[strip0]
- '''
-
- foo
- bar
-
- '''
-# ---
-# name: TestStrip.test_with_leading_whitespace[all]
- '''
- foo
- bar
- '''
-# ---
-# name: TestStrip.test_with_leading_whitespace[none]
- '''
-
-
-
- foo
- bar
-
- '''
-# ---
-# name: TestStrip.test_with_leading_whitespace[smart]
- '''
-
-
- foo
- bar
- '''
-# ---
-# name: TestStrip.test_with_leading_whitespace[strip0]
- '''
-
-
- foo
- bar
- '''
-# ---
-# name: TestStrip.test_with_special_whitespace[all]
- '''
- foo
- bar
- '''
-# ---
-# name: TestStrip.test_with_special_whitespace[none]
- '''
-
-
- foo
- bar
-
-
- '''
-# ---
-# name: TestStrip.test_with_special_whitespace[smart]
- '''
-
- foo
- bar
-
- '''
-# ---
-# name: TestStrip.test_with_special_whitespace[strip0]
- '''
-
- foo
- bar
-
- '''
-# ---
-# name: TestStrip.test_with_trailing_whitespace[all]
- '''
- foo
- bar
- '''
-# ---
-# name: TestStrip.test_with_trailing_whitespace[none]
- '''
-
- foo
- bar
-
- '''
-# ---
-# name: TestStrip.test_with_trailing_whitespace[smart]
- '''
- foo
- bar
- '''
-# ---
-# name: TestStrip.test_with_trailing_whitespace[strip0]
- '''
- foo
- bar
- '''
-# ---
-# name: TestStrip.test_without_trailing_whitespace[all]
- '''
- foo
- bar
- '''
-# ---
-# name: TestStrip.test_without_trailing_whitespace[none]
- '''
-
- foo
- bar
-
- '''
-# ---
-# name: TestStrip.test_without_trailing_whitespace[smart]
- '''
- foo
- bar
- '''
-# ---
-# name: TestStrip.test_without_trailing_whitespace[strip0]
- '''
- foo
- bar
- '''
-# ---
-# name: TestTyping.test_literal
- '''
- first
- second
- third
- '''
-# ---
-# name: TestTyping.test_non_literal
- '''
- hello
- python
- '''
-# ---
-# name: TestTyping.test_non_literal.1
- '''
- hello
- 123
- '''
-# ---
-# name: TestTyping.test_non_literal.2
- '''
- hello
- python
- '''
-# ---
-# name: TestTyping.test_template
- '''
- first
- second
- third
- '''
-# ---
-# name: TestUnicodeCharacterPreservation.test_preserves_emojis
- 'π'
-# ---
-# name: TestUnicodeCharacterPreservation.test_preserves_ideographs
- 'εΌζ°'
-# ---
diff --git a/tests/test_dedent.py b/tests/test_dedent.py
index 933c0e1..2f835cd 100644
--- a/tests/test_dedent.py
+++ b/tests/test_dedent.py
@@ -1,28 +1,14 @@
-"""
-Test functionality.
-
-Adapted from https://github.com/dmnd/dedent/blob/7b38d9/src/dedent.test.ts
-"""
-# ruff: noqa: PLC2701
-# pyright: reportPrivateUsage=false
+"""Tests for `dedent`."""
import sys
-from collections.abc import Callable
-from typing import Final, cast
+from typing import Literal, cast
import pytest
-from _pytest.fixtures import SubRequest
-from syrupy.assertion import SnapshotAssertion
-
-from dedent import align as align_values
-from dedent import dedent
-from dedent._dedent import _ALIGN_MARKER_PREFIX, _SEP, MISSING, AlignSpec, Missing, Strip
+from inline_snapshot import snapshot
-StripOption = Strip | Missing
-AlignOption = bool | Missing
+from dedent import align, dedent
-STRIP_OPTIONS: Final[list[StripOption]] = [MISSING, "smart", "all", "none"]
-ALIGN_OPTIONS: Final[list[AlignOption]] = [MISSING, False, True]
+StripMode = Literal["smart", "all", "none"]
required_py314 = pytest.mark.skipif(sys.version_info < (3, 14), reason="requires Python 3.14+")
@@ -33,671 +19,432 @@
else:
_T = str
+ITEMS = "- apples\n- bananas\n- cherries"
+TYPICAL = """
+ foo
+ bar
+ """
+
def t(source: str, /, **ns: object) -> _T:
code = compile(f"t'''{source}'''", "", "eval")
return cast("_T", eval(code, ns)) # noqa: S307
-class TestDedent:
- @staticmethod
- def test_works_without_interpolation(snapshot: SnapshotAssertion) -> None:
- assert snapshot == dedent("""first
- second
- third
- """)
+def dedent_f(string: str) -> str:
+ """Call `dedent` on an interpolated f-string (not `LiteralString` on 3.14+)."""
+ return dedent(string) # pyright: ignore[reportArgumentType]
- @required_py314
- @staticmethod
- def test_works_with_interpolation(snapshot: SnapshotAssertion) -> None:
- assert snapshot == dedent(t("""first {"line"}
- {"second"}
- third
- """)) # fmt: skip
+class TestDedent:
@staticmethod
- def test_works_with_interpolation_legacy(snapshot: SnapshotAssertion) -> None:
- assert snapshot == dedent(f"""first {"line"}
- {"second"}
- third
- """)
+ def test_uses_exact_common_indentation_prefix() -> None:
+ assert dedent(" \tfirst\n \t\tsecond", strip="none") == snapshot("""\
+first
+ second\
+""")
+ assert dedent(" first\n\tsecond", strip="none") == snapshot("""\
+ first
+ second\
+""")
- @required_py314
@staticmethod
- def test_works_with_suppressed_newlines(snapshot: SnapshotAssertion) -> None:
- assert snapshot == dedent(t("""first \
- {"second"}
- third
- """)) # fmt: skip
+ def test_closing_line_constrains_indentation() -> None:
+ assert dedent("\n first\n ") == snapshot(" first")
+ assert dedent("\n first\n ") == snapshot("first")
@staticmethod
- def test_works_with_suppressed_newlines_legacy(snapshot: SnapshotAssertion) -> None:
- assert snapshot == dedent(f"""first \
- {"second"}
- third
- """)
+ def test_ignores_compatible_whitespace_only_lines() -> None:
+ assert dedent("\n first\n \n second\n ") == snapshot("""\
+first
- @staticmethod
- def test_works_with_blank_first_line(snapshot: SnapshotAssertion) -> None:
- assert snapshot == dedent("""
- Some text that I might want to indent:
- * reasons
- * fun
- That's all.
- """)
+second\
+""")
- @required_py314
@staticmethod
- def test_works_with_multiple_blank_first_lines(snapshot: SnapshotAssertion) -> None:
- assert snapshot == dedent(t("""
-
- first
- second
- third
- """)) # fmt: skip
+ def test_rejects_incompatible_whitespace_only_lines() -> None:
+ with pytest.raises(IndentationError, match="line 3"):
+ _ = dedent("\n first\n \t\n second\n ")
@staticmethod
- def test_works_with_multiple_blank_first_lines_legacy(snapshot: SnapshotAssertion) -> None:
- assert snapshot == dedent("""
-
- first
- second
- third
- """)
+ def test_removes_common_indent() -> None:
+ assert dedent("""
+ first
+ second
+ third
+ """) == snapshot("""\
+first
+ second
+ third\
+""")
- @required_py314
@staticmethod
- def test_works_with_removing_same_number_of_spaces(snapshot: SnapshotAssertion) -> None:
- assert snapshot == dedent(t("""
- first
- second
- third
- """)) # fmt: skip
+ def test_min_indent_from_least_indented_line() -> None:
+ assert dedent("""
+ first
+ second
+ third
+ """) == snapshot("""\
+first
+ second
+ third\
+""")
@staticmethod
- def test_works_with_removing_same_number_of_spaces_legacy(snapshot: SnapshotAssertion) -> None:
- assert snapshot == dedent("""
- first
- second
- third
- """)
+ def test_tabs() -> None:
+ assert dedent("\n\t\tfirst\n\t\t\tsecond\n\t\t\t\tthird\n\t\t") == snapshot("""\
+first
+ second
+ third\
+""")
@staticmethod
- def test_does_not_strip_explicit_newlines(snapshot: SnapshotAssertion) -> None:
- assert snapshot == dedent("""
- Hello world!
\n
- """)
+ def test_empty() -> None:
+ assert dedent("") == snapshot("")
@staticmethod
- def test_does_not_strip_explicit_newlines_with_min_indent(snapshot: SnapshotAssertion) -> None:
- assert snapshot == dedent("""
-
- Hello world!
-
\n
- """)
+ def test_single_line() -> None:
+ assert dedent("A single line of input.") == snapshot("A single line of input.")
@staticmethod
- def test_works_with_spaces_for_indentation(snapshot: SnapshotAssertion) -> None:
- assert snapshot == dedent("""
- first
- second
- third
- """)
+ def test_escaped_newlines_are_literal() -> None:
+ assert dedent("\\n\\tfirst\\n") == snapshot("\\n\\tfirst\\n")
@staticmethod
- def test_works_with_tabs_for_indentation(snapshot: SnapshotAssertion) -> None:
- assert snapshot == dedent("""
- first
- second
- third
- """)
+ def test_explicit_newline_is_kept() -> None:
+ assert dedent("""
+ Hello world!
\n
+ """) == snapshot("Hello world!
\n")
@staticmethod
- def test_works_with_explicit_tabs_for_indentation(snapshot: SnapshotAssertion) -> None:
- assert snapshot == dedent("\n\t\tfirst\n\t\t\tsecond\n\t\t\t\tthird\n")
-
- @staticmethod
- def test_escaped_explicit_newlines_and_tabs(snapshot: SnapshotAssertion) -> None:
- assert snapshot == dedent("\\n\\tfirst\\n")
-
- @required_py314
- @staticmethod
- def test_format_spec(snapshot: SnapshotAssertion) -> None:
- header = "Receipt"
- total = 123
- discount = 0.123456789
- decimals = 2
- assert snapshot == dedent(t("""
- {header:=^21}
- - Total: {total: 9d}
- - Discount: {discount: 9.{decimals}%}
- """, header=header, total=total, discount=discount, decimals=decimals)) # fmt: skip
-
- @staticmethod
- def test_format_spec_legacy(snapshot: SnapshotAssertion) -> None:
- header = "Receipt"
- total = 123
- discount = 0.123456789
- decimals = 2
- assert snapshot == dedent(f"""
- {header:=^21}
- - Total: {total: 9d}
- - Discount: {discount: 9.{decimals}%}
- """) # pyright: ignore[reportArgumentType]
+ def test_source_line_continuation() -> None:
+ assert dedent("""
+ first \
+ second
+ third
+ """) == snapshot("""\
+first second
+third\
+""")
- @required_py314
@staticmethod
- def test_empty_format_spec(snapshot: SnapshotAssertion) -> None:
- assert snapshot == dedent(t("{123:}"))
+ def test_unicode() -> None:
+ assert dedent(" π εΌζ°") == snapshot("π εΌζ°")
@staticmethod
- def test_empty_format_spec_legacy(snapshot: SnapshotAssertion) -> None:
- assert snapshot == dedent(f"{123:}") # pyright: ignore[reportArgumentType]
+ def test_rejects_non_string() -> None:
+ with pytest.raises(TypeError, match="expected str"):
+ _ = dedent(123) # pyright: ignore[reportArgumentType]
-@pytest.mark.parametrize("strip", STRIP_OPTIONS)
class TestStrip:
@staticmethod
- def test_with_trailing_whitespace(snapshot: SnapshotAssertion, strip: StripOption) -> None:
- assert snapshot == dedent(
- """
- foo---
- bar---
- """.replace("-", " "),
- strip=strip,
- )
-
- @staticmethod
- def test_without_trailing_whitespace(snapshot: SnapshotAssertion, strip: StripOption) -> None:
- assert snapshot == dedent(
+ def test_default_is_smart() -> None:
+ assert dedent(TYPICAL) == dedent(TYPICAL, strip="smart")
+
+ @pytest.mark.parametrize(
+ ("strip", "expected"),
+ [
+ (
+ "smart",
+ snapshot("""\
+foo
+bar\
+"""),
+ ),
+ (
+ "all",
+ snapshot("""\
+foo
+bar\
+"""),
+ ),
+ (
+ "none",
+ snapshot("""\
+
+foo
+bar
+"""),
+ ),
+ ],
+ )
+ @staticmethod
+ def test_typical(strip: StripMode, expected: str) -> None:
+ assert dedent(TYPICAL, strip=strip) == expected
+
+ @staticmethod
+ def test_smart_strips_one_blank_segment() -> None:
+ assert dedent("""
+
+
+ foo
+ bar
+ """) == snapshot("""\
+
+
+foo
+bar\
+""")
+
+ @staticmethod
+ def test_smart_keeps_trailing_spaces_on_content() -> None:
+ assert dedent(
"""
- foo
- bar
- """,
- strip=strip,
- )
+ foo---
+ bar---
+ """.replace("-", " ")
+ ) == snapshot("""\
+foo \n\
+bar \
+""")
@staticmethod
- def test_with_leading_whitespace(snapshot: SnapshotAssertion, strip: StripOption) -> None:
- assert snapshot == dedent(
- """
-
+ def test_all_strips_surrounding_whitespace() -> None:
+ padded = """---
foo
bar
- """.replace("-", " "),
- strip=strip,
- )
- @staticmethod
- def test_with_special_whitespace(snapshot: SnapshotAssertion, strip: StripOption) -> None:
- assert snapshot == dedent(
- """---
-
- foo
- bar
-
- ---""".replace("-", " "),
- strip=strip,
- )
+ ---""".replace("-", " ")
+ assert dedent(padded, strip="all") == snapshot("""\
+foo
+bar\
+""")
@staticmethod
- def test_with_inverse_special_whitespace(
- snapshot: SnapshotAssertion, strip: StripOption
- ) -> None:
- assert snapshot == dedent(
+ def test_smart_keeps_whitespace_only_lines() -> None:
+ assert dedent(
"""
- ---
- foo
- bar
- ---
- """.replace("-", " "),
- strip=strip,
- )
-
-
-class TestAlign: # noqa: PLR0904
- @pytest.fixture(scope="class", params=ALIGN_OPTIONS)
- @staticmethod
- def align(request: SubRequest) -> AlignOption:
- return request.param # pyright: ignore[reportAny]: bad pytest typing
-
- @pytest.fixture(scope="class", params=[align.value for align in AlignSpec])
- @staticmethod
- def spec(request: SubRequest) -> str:
- return request.param # pyright: ignore[reportAny]: bad pytest typing
-
- @staticmethod
- def align_to_fn(align: AlignOption) -> Callable[..., object]:
- def identity(value: object) -> object:
- return value
-
- return align_values if isinstance(align, bool) and align else identity
+ ---
+ foo
+ bar
+ ---
+ """.replace("-", " ")
+ ) == snapshot("""\
+ \n\
+foo
+bar
+ \
+""")
+
+
+class TestInterpolation:
+ @staticmethod
+ def test_fstring() -> None:
+ line = "line"
+ assert dedent(f"""
+ first {line}
+ second
+ """) == snapshot("""\
+first line
+second\
+""")
@required_py314
@staticmethod
- def test_with_multiple_lines(snapshot: SnapshotAssertion, align: AlignOption) -> None:
- items = dedent("""
- - apples
- - bananas
- - cherries
- """)
-
- assert snapshot == dedent(
+ def test_tstring() -> None:
+ assert dedent(
t("""
- List:
- {items}
- ---
- """, items=items),
- align=align, # pyright: ignore[reportCallIssue]: matrix type checking
- ) # fmt: skip
-
- def test_with_multiple_lines_legacy(
- self, snapshot: SnapshotAssertion, align: AlignOption
- ) -> None:
- align_fn = self.align_to_fn(align)
-
- items = dedent("""
- - apples
- - bananas
- - cherries
- """)
-
- assert snapshot == dedent(
- f"""
- List:
- {align_fn(items)}
- ---
- """, # pyright: ignore[reportArgumentType]: matrix type checking
- )
+ first {"line"}
+ second
+ """)
+ ) == snapshot("""\
+first line
+second\
+""")
@required_py314
@staticmethod
- def test_with_single_line(snapshot: SnapshotAssertion, align: AlignOption) -> None:
- assert snapshot == dedent(
- t("""
- List:
- {"- apples"}
- ---
- """),
- align=align, # pyright: ignore[reportCallIssue]: matrix type checking
- ) # fmt: skip
-
- def test_with_single_line_legacy(self, snapshot: SnapshotAssertion, align: AlignOption) -> None:
- align_fn = self.align_to_fn(align)
-
- assert snapshot == dedent(
- f"""
- List:
- {align_fn("- apples")}
- ---
- """, # pyright: ignore[reportArgumentType]: matrix type checking
- )
+ def test_tstring_format_spec() -> None:
+ assert dedent(
+ t(
+ """
+ {header:=^21}
+ - Total: {total: 9d}
+ """,
+ header="Receipt",
+ total=123,
+ )
+ ) == snapshot("""\
+=======Receipt=======
+- Total: 123\
+""")
@required_py314
@staticmethod
- def test_no_indentation(snapshot: SnapshotAssertion, align: AlignOption) -> None:
- items = dedent("""
- - apples
- - bananas
- - cherries
- """)
- assert snapshot == dedent(t("{items}", items=items), align=align) # pyright: ignore[reportCallIssue]: matrix type checking
-
- def test_no_indentation_legacy(self, snapshot: SnapshotAssertion, align: AlignOption) -> None:
- align_fn = self.align_to_fn(align)
+ def test_tstring_empty_format_spec() -> None:
+ assert dedent(t("{123:}")) == snapshot("123")
- items = dedent("""
- - apples
- - bananas
- - cherries
- """)
- assert snapshot == dedent(f"{align_fn(items)}") # pyright: ignore[reportArgumentType]: matrix type checking
-
- @required_py314
- @staticmethod
- def test_override(snapshot: SnapshotAssertion, align: AlignOption, spec: str) -> None:
- items = dedent("""
- - apples
- - bananas
- - cherries
- """)
- assert snapshot == dedent(
- t("""
- List:
- {items:{spec}}
- ---
- """, items=items, spec=spec),
- align=align, # pyright: ignore[reportCallIssue]: matrix type checking
- ) # fmt: skip
- @required_py314
+class TestAlign:
@staticmethod
- def test_override_with_format_spec(
- snapshot: SnapshotAssertion, align: AlignOption, spec: str
- ) -> None:
- if isinstance(align, Missing):
- return
-
- items = dedent("""
- - apples
- - bananas
- - cherries
- """)
- size = len(items) + 2
+ def test_unaligned_multiline_value() -> None:
+ # Column-0 lines from interpolation prevent a common indent prefix.
+ assert dedent_f(f"\n List:\n {ITEMS}\n ---\n ") == snapshot("""\
+ List:
+ - apples
+- bananas
+- cherries
+ ---\
+""")
- assert snapshot == dedent(
- t("""
- List:
- {items:\n^{size}:{spec}}
- {items:{spec}:\n^{size}}
- ---
- """, items=items, spec=spec, size=size),
- align=align, # pyright: ignore[reportCallIssue]: matrix type checking
- ) # fmt: skip
-
- @required_py314
@staticmethod
- def test_unknown_format_spec() -> None:
- with pytest.raises(ValueError, match=r"(?i)invalid format spec"):
- _ = dedent(t("{123:algn}")) # misspelled "align"
+ def test_align_wrapper() -> None:
+ assert dedent_f(f"""
+ List:
+ {align(ITEMS)}
+ ---
+ """) == snapshot("""\
+List:
+ - apples
+ - bananas
+ - cherries
+---\
+""")
- @required_py314
@staticmethod
- def test_empty_string(snapshot: SnapshotAssertion, align: AlignOption) -> None:
- assert snapshot == dedent("", align=align) # pyright: ignore[reportArgumentType,reportCallIssue]: matrix type checking
-
- def test_empty_string_legacy(self, snapshot: SnapshotAssertion, align: AlignOption) -> None:
- align_fn = self.align_to_fn(align)
-
- assert snapshot == dedent(f"{align_fn('')}") # pyright: ignore[reportArgumentType]: matrix type checking
-
- @required_py314
- @staticmethod
- def test_two_aligned_values(snapshot: SnapshotAssertion, align: AlignOption) -> None:
- a = "line1\nline2"
- b = "foo\nbar"
- assert snapshot == dedent(
- t("""
- A:
- {a}
- B:
- {b}
- """, a=a, b=b),
- align=align, # pyright: ignore[reportCallIssue]: matrix type checking
- ) # fmt: skip
-
- def test_two_aligned_values_legacy(
- self, snapshot: SnapshotAssertion, align: AlignOption
- ) -> None:
- align_fn = self.align_to_fn(align)
+ def test_two_values() -> None:
a = "line1\nline2"
b = "foo\nbar"
- assert snapshot == dedent(f"""
- A:
- {align_fn(a)}
- B:
- {align_fn(b)}
- """) # pyright: ignore[reportArgumentType]: matrix type checking
-
- @required_py314
- @staticmethod
- def test_aligned_and_plain(snapshot: SnapshotAssertion, align: AlignOption) -> None:
- items = "- apples\n- bananas"
- plain = "hello"
- assert snapshot == dedent(
- t("""
- List:
- {items}
- Plain:
- {plain}
- """, items=items, plain=plain),
- align=align, # pyright: ignore[reportCallIssue]: matrix type checking
- ) # fmt: skip
-
- def test_aligned_and_plain_legacy(
- self, snapshot: SnapshotAssertion, align: AlignOption
- ) -> None:
- align_fn = self.align_to_fn(align)
- items = "- apples\n- bananas"
- plain = "hello"
- assert snapshot == dedent(f"""
- List:
- {align_fn(items)}
- Plain:
- {plain}
- """) # pyright: ignore[reportArgumentType]: matrix type checking
-
- @required_py314
- @staticmethod
- def test_str_conversion(snapshot: SnapshotAssertion) -> None:
- items = "- apples\n- bananas"
- assert snapshot == dedent(
- t("""
- List:
- {items!s}
- """, items=items),
- align=True, # pyright: ignore[reportCallIssue]: matrix type checking
- ) # fmt: skip
-
- @staticmethod
- def test_str_conversion_legacy(snapshot: SnapshotAssertion) -> None:
- items = "- apples\n- bananas"
- assert snapshot == dedent(f"""
- List:
- {align_values(items)!s}
- """) # pyright: ignore[reportArgumentType]: matrix type checking
-
- @required_py314
- @staticmethod
- def test_repr_conversion(snapshot: SnapshotAssertion) -> None:
- value = "hello"
- assert snapshot == dedent(
- t("""
- Value:
- {value!r}
- """, value=value),
- align=True, # pyright: ignore[reportCallIssue]: matrix type checking
- ) # fmt: skip
-
- @staticmethod
- def test_repr_conversion_legacy(snapshot: SnapshotAssertion) -> None:
- value = "hello"
- assert snapshot == dedent(f"""
- Value:
- {align_values(value)!r}
- """) # pyright: ignore[reportArgumentType]: matrix type checking
+ assert dedent_f(f"""
+ A:
+ {align(a)}
+ B:
+ {align(b)}
+ """) == snapshot("""\
+A:
+ line1
+ line2
+B:
+ foo
+ bar\
+""")
+
+ @staticmethod
+ def test_nested_wrappers() -> None:
+ inner = "line1\nline2"
+ outer = f"outer {align(inner)}"
+ result = dedent_f(f" {align(outer)}")
+ assert result == snapshot("""\
+outer line1
+line2\
+""")
+ assert "\x00" not in result
+ assert "DEDENT_ALIGN" not in result
+
+ @staticmethod
+ def test_conversions() -> None:
+ assert dedent_f(f"""
+ List:
+ {align(ITEMS)!s}
+ """) == snapshot("""\
+List:
+ - apples
+ - bananas
+ - cherries\
+""")
+ assert dedent_f(f"""
+ Value:
+ {align("hello")!r}
+ """) == snapshot("""\
+Value:
+ 'hello'\
+""")
+ assert dedent_f(f"""
+ Value:
+ {align(1.23456):.2f}
+ """) == snapshot("""\
+Value:
+ 1.23\
+""")
+ assert dedent_f(f"""
+ Header:
+ {align("hi"):>10}
+ """) == snapshot("""\
+Header:
+ hi\
+""")
@required_py314
@staticmethod
- def test_numeric_format(snapshot: SnapshotAssertion) -> None:
- assert snapshot == dedent(t("""
- Value:
- {1.23456:.2f}
- """),
- align=True, # pyright: ignore[reportCallIssue]: matrix type checking
- ) # fmt: skip
-
- @staticmethod
- def test_numeric_format_legacy(snapshot: SnapshotAssertion) -> None:
- assert snapshot == dedent(f"""
- Value:
- {align_values(1.23456):.2f}
- """) # pyright: ignore[reportArgumentType]: matrix type checking
+ def test_align_argument() -> None:
+ assert dedent(
+ t(
+ """
+ List:
+ {items}
+ ---
+ """,
+ items=ITEMS,
+ ),
+ align=True, # pyright: ignore[reportCallIssue]: test is skipped before Python 3.14
+ ) == snapshot("""\
+List:
+ - apples
+ - bananas
+ - cherries
+---\
+""")
@required_py314
@staticmethod
- def test_string_format(snapshot: SnapshotAssertion) -> None:
- assert snapshot == dedent(
- t("""
- Header:
- {"hi":>10}
- """),
- align=True, # pyright: ignore[reportCallIssue]: matrix type checking
- ) # fmt: skip
-
- @staticmethod
- def test_string_format_legacy(snapshot: SnapshotAssertion) -> None:
- assert snapshot == dedent(f"""
- Header:
- {align_values("hi"):>10}
- """) # pyright: ignore[reportArgumentType]: matrix type checking
+ def test_align_format_spec() -> None:
+ assert dedent(
+ t(
+ """
+ List:
+ {items:align}
+ ---
+ """,
+ items=ITEMS,
+ )
+ ) == snapshot("""\
+List:
+ - apples
+ - bananas
+ - cherries
+---\
+""")
@required_py314
@staticmethod
- def test_no_null_bytes_in_output() -> None:
- items = "- apples\n- bananas"
- result = dedent( # pyright: ignore[reportUnknownVariableType]
- t("""
- List:
- {items}
- """, items=items),
- align=True, # pyright: ignore[reportCallIssue]: matrix type checking
- ) # fmt: skip
- assert _SEP not in result
-
- @staticmethod
- def test_no_null_bytes_in_output_legacy() -> None:
- items = "- apples\n- bananas"
- result = dedent(f"""
- List:
- {align_values(items)}
- """) # pyright: ignore[reportArgumentType,reportUnknownVariableType]: matrix type checking
- assert _SEP not in result
+ def test_noalign_overrides_align_argument() -> None:
+ assert dedent(
+ t(
+ """
+ List:
+ {items:noalign}
+ ---
+ """,
+ items=ITEMS,
+ ),
+ align=True, # pyright: ignore[reportCallIssue]: test is skipped before Python 3.14
+ ) == snapshot("""\
+List:
+ - apples
+- bananas
+- cherries
+---\
+""")
@required_py314
@staticmethod
- def test_no_markers_in_output() -> None:
- items = "- apples\n- bananas"
- result = dedent( # pyright: ignore[reportUnknownVariableType]
- t("{items}", items=items),
- align=True, # pyright: ignore[reportCallIssue]: matrix type checking
- )
- assert _ALIGN_MARKER_PREFIX not in result
-
- @staticmethod
- def test_no_markers_in_output_legacy() -> None:
- items = "- apples\n- bananas"
- result = dedent(f"{align_values(items)}") # pyright: ignore[reportArgumentType,reportUnknownVariableType]: matrix type checking
- assert _ALIGN_MARKER_PREFIX not in result
-
- @staticmethod
- def test_nested_align_markers_processed() -> None:
- inner = "line1\nline2"
- outer = f"outer {align_values(inner)}"
- result = dedent(f" {align_values(outer)}") # pyright: ignore[reportArgumentType,reportUnknownVariableType]: matrix type checking
- assert _ALIGN_MARKER_PREFIX not in result
- assert _SEP not in result
- assert "line1" in result
- assert "line2" in result
-
- @staticmethod
- def test_aligned_str_contains_markers() -> None:
- hello = "hello"
- wrapper = align_values(hello)
- text = str(wrapper)
- assert _SEP in text
- assert hello in text
-
- @staticmethod
- def test_aligned_repr_contains_markers() -> None:
- hello = "hello"
- wrapper = align_values(hello)
- text = repr(wrapper)
- assert _SEP in text
- assert repr(hello) in text
-
- @staticmethod
- def test_aligned_format_contains_markers() -> None:
- value = 1.23
- wrapper = align_values(value)
- format_spec = ".1f"
- text = format(wrapper, format_spec)
- assert _SEP in text
- assert format(value, format_spec) in text
-
-
-class TestTyping:
- @staticmethod
- def test_literal(snapshot: SnapshotAssertion) -> None:
- assert snapshot == dedent("""
- first
- second
- third
- """)
+ def test_align_directive_with_format_spec() -> None:
+ assert dedent(t("{123:06d:align}")) == snapshot("000123")
+ assert dedent(t("{123:align:06d}")) == snapshot("000123")
@required_py314
@staticmethod
- def test_template(snapshot: SnapshotAssertion) -> None:
- assert snapshot == dedent(t("""
- {"first"}
- {"second"}
- {"third"}
- """)) # fmt: skip
-
- @staticmethod
- def test_non_literal(snapshot: SnapshotAssertion) -> None:
- name = "python"
- assert snapshot == dedent(f"""
- hello
- {name}
- """)
-
- assert snapshot == dedent(f"""
- hello
- {123}
- """) # pyright: ignore[reportArgumentType]: allow non-literal string for this test
-
- def wrapped(value: str) -> str:
- return dedent(f"""
- hello
- {value}
- """) # pyright: ignore[reportArgumentType]: allow non-literal string for this test
-
- assert snapshot == wrapped("python")
-
- @staticmethod
- def test_unsupported_type() -> None:
- """
- Test type checking for disallowing unsupported types.
- """
- with pytest.raises(TypeError):
- _ = dedent(123) # pyright: ignore[reportArgumentType]: allow unsupported type for this test
-
-
-class TestSingleLine:
- @staticmethod
- def test_works(snapshot: SnapshotAssertion) -> None:
- assert snapshot == dedent("A single line of input.")
-
- @staticmethod
- def test_works_with_quotes_on_newlines(
- snapshot: SnapshotAssertion,
- ) -> None:
- assert snapshot == dedent("""
- A single line of input.
- """)
-
- @staticmethod
- def test_works_with_opening_quotes_on_newline(
- snapshot: SnapshotAssertion,
- ) -> None:
- assert snapshot == dedent("""
- A single line of input.""")
-
- @staticmethod
- def test_works_with_closing_quotes_on_newline(
- snapshot: SnapshotAssertion,
- ) -> None:
- assert snapshot == dedent("""A single line of input.
- """)
-
-
-class TestUnicodeCharacterPreservation:
- @staticmethod
- def test_preserves_emojis(snapshot: SnapshotAssertion) -> None:
- assert snapshot == dedent("π")
+ def test_unknown_format_spec() -> None:
+ with pytest.raises(ValueError, match=r"(?i)invalid format spec"):
+ _ = dedent(t("{123:algn}"))
@staticmethod
- def test_preserves_ideographs(snapshot: SnapshotAssertion) -> None:
- assert snapshot == dedent("εΌζ°")
+ def test_wrapper_embeds_markers() -> None:
+ text = str(align("hello"))
+ assert "hello" in text
+ assert "\x00" in text
+ assert repr("hello") in repr(align("hello"))
+ assert "1.2" in format(align(1.23), ".1f")
+ assert "\x00" in format(align(1.23), ".1f")
diff --git a/uv.lock b/uv.lock
index 8a1bc05..1d05bfb 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2,6 +2,15 @@ version = 1
revision = 3
requires-python = ">=3.10"
+[[package]]
+name = "asttokens"
+version = "3.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/25/1e/faf0f247f6f881b98fc4d6d07e14085cb89d13665084e6d6ac1dc2c03d0b/asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2", size = 63136, upload-time = "2026-07-12T03:31:49.084Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" },
+]
+
[[package]]
name = "basedpyright"
version = "1.37.3"
@@ -134,10 +143,10 @@ source = { editable = "." }
[package.dev-dependencies]
dev = [
{ name = "basedpyright" },
+ { name = "inline-snapshot" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" },
- { name = "syrupy" },
]
[package.metadata]
@@ -145,10 +154,10 @@ dev = [
[package.metadata.requires-dev]
dev = [
{ name = "basedpyright", specifier = ">=1.37.1" },
+ { name = "inline-snapshot", specifier = ">=0.35.4" },
{ name = "pytest", specifier = ">=9.0.2" },
{ name = "pytest-cov", specifier = ">=7.0.0" },
{ name = "ruff", specifier = ">=0.14.11" },
- { name = "syrupy", specifier = ">=5.1.0" },
]
[[package]]
@@ -163,6 +172,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
+[[package]]
+name = "executing"
+version = "2.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
+]
+
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -172,6 +190,44 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
+[[package]]
+name = "inline-snapshot"
+version = "0.35.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "asttokens" },
+ { name = "executing" },
+ { name = "pytest" },
+ { name = "rich" },
+ { name = "tomli", marker = "python_full_version < '3.11'" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/59/46/3c459f0350e88d6a5ca58a4792a7215c3b0990cf9bb6a2b37ac8d74a2504/inline_snapshot-0.35.4.tar.gz", hash = "sha256:82432a58b7ea77a172515f5024cd26fdea8dc0f873c7a4251e4e0471a914db14", size = 2537089, upload-time = "2026-08-11T07:07:56.213Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/97/b3/dc18eb603de18daf5cacc27bb4c5e80c5403d9ca3eade7342fe4228652bf/inline_snapshot-0.35.4-py3-none-any.whl", hash = "sha256:0ad221c1bc6fe4ce249f7c1c4131284099ecac1a5c1023aa7b1a95921c07265d", size = 95725, upload-time = "2026-08-11T07:07:54.24Z" },
+]
+
+[[package]]
+name = "markdown-it-py"
+version = "4.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mdurl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
+]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
+]
+
[[package]]
name = "nodejs-wheel-binaries"
version = "24.13.0"
@@ -247,6 +303,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
]
+[[package]]
+name = "rich"
+version = "15.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
+]
+
[[package]]
name = "ruff"
version = "0.14.14"
@@ -273,18 +342,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" },
]
-[[package]]
-name = "syrupy"
-version = "5.1.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "pytest" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/2e/b0/24bca682d6a6337854be37f242d116cceeda9942571d5804c44bc1bdd427/syrupy-5.1.0.tar.gz", hash = "sha256:df543c7aa50d3cf1246e83d58fe490afe5f7dab7b41e74ecc0d8d23ae19bd4b8", size = 50495, upload-time = "2026-01-25T14:53:06.2Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/de/70/cf880c3b95a6034ef673e74b369941b42315c01f1554a5637a4f8b911009/syrupy-5.1.0-py3-none-any.whl", hash = "sha256:95162d2b05e61ed3e13f117b88dfab7c58bd6f90e66ebbf918e8a77114ad51c5", size = 51658, upload-time = "2026-01-25T14:53:05.105Z" },
-]
-
[[package]]
name = "tomli"
version = "2.4.0"
From 7c881513cf2634fd3f52dbec7dd6cadc46b41e01 Mon Sep 17 00:00:00 2001
From: Graham Preston <16179442+grahamcracker1234@users.noreply.github.com>
Date: Tue, 18 Aug 2026 09:21:13 -0400
Subject: [PATCH 02/15] Harden PEP 822 dedentation and interpolation handling
Preserve exact line-ending and indentation semantics while rendering template and alignment holes without letting interpolated values alter dedentation.
---
README.md | 22 +++-
src/dedent/_dedent.py | 250 +++++++++++++++++++++++++++++-------------
tests/test_dedent.py | 145 +++++++++++++++++++++---
3 files changed, 320 insertions(+), 97 deletions(-)
diff --git a/README.md b/README.md
index bfe1461..8a6000b 100644
--- a/README.md
+++ b/README.md
@@ -55,8 +55,8 @@ print(shopping_list)
1. Indentation is an exact prefix of spaces and tabs; a tab never equals spaces.
2. The longest indentation prefix shared by every nonblank line is removed.
-3. Whitespace-only lines do not determine that prefix, except for the final line. A final
- whitespace-only line is treated like the line containing closing triple quotes.
+3. Lines containing only spaces and tabs do not determine that prefix, except for the final line.
+ A final space/tab-only line is treated like the line containing closing triple quotes.
4. Every line must be compatible with the chosen prefix. Otherwise, `IndentationError` is raised.
5. The closing-quotes indentation can preserve intentional indentation:
@@ -69,10 +69,18 @@ result = dedent("""
assert result == " Hello\n World!"
```
+Keep the closing quotes at the indentation you want removed. A trailing `\n` creates an empty
+closing line at column zero, so it preserves the content indentation:
+
+```python
+assert dedent("\n Hello\n") == " Hello"
+assert dedent("\n Hello\n ") == "Hello"
+```
+
### Why `IndentationError`?
-Whitespace-only lines are ignored when finding the common prefix, but they must still be
-compatible with it when that prefix is removed:
+Space/tab-only lines are ignored when finding the common prefix, but they must still be compatible
+with it when that prefix is removed:
```python
dedent("\n hello\n \t\n world\n ")
@@ -82,7 +90,7 @@ dedent("\n hello\n \t\n world\n ")
Here the nonblank lines and closing line establish a two-space prefix. The middle line contains
one space followed by a tab, so that exact prefix cannot be removed. Raising an error catches
mixed or malformed indentation instead of silently changing or preserving ambiguous whitespace.
-A shorter whitespace-only line is valid when it matches the beginning of the prefix; it simply
+A shorter space/tab-only line is valid when it matches the beginning of the prefix; it simply
becomes empty. This mirrors PEP 822.
With the default `strip="smart"`, the usual opening newline and closing-quotes line are removed
@@ -90,6 +98,10 @@ from the result. Unlike native d-strings, `dedent()` runs after Python parses a
sequences have already been processed. For t-strings, literal text is dedented before
interpolations are rendered.
+For runtime strings that use CRLF, `dedent` preserves each `\r\n` pair and excludes the terminal
+`\r` from indentation checks. Other whitespace characters, such as form feed and non-breaking
+space, remain content.
+
## Installation
```bash
diff --git a/src/dedent/_dedent.py b/src/dedent/_dedent.py
index 8dca744..1aed630 100644
--- a/src/dedent/_dedent.py
+++ b/src/dedent/_dedent.py
@@ -4,7 +4,7 @@
from dataclasses import dataclass, field
from enum import Enum
from itertools import filterfalse, tee
-from typing import TYPE_CHECKING, Final, Literal, cast, final
+from typing import TYPE_CHECKING, Final, Literal, TypeVar, cast, final
from uuid import uuid4
@@ -26,11 +26,14 @@ class AlignSpec(str, Enum):
Strip = Literal["smart", "all", "none"]
+_T = TypeVar("_T")
-_INDENTED: Final = re.compile(r"^(\s+)")
-_SMART_STRIP: Final = re.compile(r"^[^\S\n]*\n?")
+_INDENTED: Final = re.compile(r"^([ \t]+)")
+_SMART_STRIP_START: Final = re.compile(r"^[^\S\r\n]*(?:\r\n|[\r\n])?")
+_SMART_STRIP_END: Final = re.compile(r"(?:\r\n|[\r\n])?[^\S\r\n]*\Z")
_ALIGN_MARKER_PREFIX: Final = "DEDENT_ALIGN"
+_HOLE_MARKER_PREFIX: Final = "DEDENT_HOLE"
_SEP: Final = "\x00"
_ALIGN_MARKER: Final = re.compile(
@@ -91,9 +94,8 @@ def _strip_string(string: str, strip: Strip) -> str:
"""
match strip:
case "smart":
- string = _SMART_STRIP.sub("", string, count=1)
- # Reversing to get the rightmost match instead of leftmost
- return _SMART_STRIP.sub("", string[::-1], count=1)[::-1]
+ string = _SMART_STRIP_START.sub("", string, count=1)
+ return _SMART_STRIP_END.sub("", string, count=1)
case "all":
return string.strip()
case "none":
@@ -105,9 +107,10 @@ def _dedent_string(string: str) -> str:
Remove the longest common leading indentation from a string.
This follows the indentation rules proposed by PEP 822: indentation is an
- exact prefix of spaces and tabs, whitespace-only lines are ignored, and a
- final whitespace-only line constrains the indentation like a closing
- triple-quote line.
+ exact prefix of spaces and tabs, space/tab-only lines are ignored, and a
+ final space/tab-only line constrains the indentation like a closing
+ triple-quote line. A carriage return before a newline is preserved as part
+ of the line ending rather than treated as content.
Args:
string: The string to dedent.
@@ -119,11 +122,14 @@ def _dedent_string(string: str) -> str:
Returns:
The dedented string.
"""
- lines = string.split("\n")
+ lines = [
+ (line.removesuffix("\r"), "\r" if line.endswith("\r") else "")
+ for line in string.split("\n")
+ ]
last_index = len(lines) - 1
indentations = [
line[: len(line) - len(line.lstrip(" \t"))]
- for index, line in enumerate(lines)
+ for index, (line, _) in enumerate(lines)
if line.strip(" \t") or (last_index > 0 and index == last_index)
]
@@ -142,14 +148,14 @@ def _dedent_string(string: str) -> str:
return string
dedented_lines: list[str] = []
- for line_number, line in enumerate(lines, start=1):
+ for line_number, (line, line_ending) in enumerate(lines, start=1):
if len(line) >= len(indentation):
if not line.startswith(indentation):
message = f"inconsistent indentation in dedented string at line {line_number}"
raise IndentationError(message)
- dedented_lines.append(line[len(indentation) :])
+ dedented_lines.append(line[len(indentation) :] + line_ending)
elif indentation.startswith(line):
- dedented_lines.append("")
+ dedented_lines.append(line_ending)
else:
message = f"inconsistent indentation in dedented string at line {line_number}"
raise IndentationError(message)
@@ -208,12 +214,12 @@ def align(value: object) -> Aligned:
items = dedent(\"\"\"
- apples
- bananas
- \"\"\")
+ \"\"\")
shopping_list = dedent(f\"\"\"
Groceries:
{align(items)}
---
- \"\"\")
+ \"\"\")
print(shopping_list)
# Groceries:
# - apples
@@ -231,7 +237,83 @@ def align(value: object) -> Aligned:
return Aligned(_value=value)
-def process_align_markers(string: str) -> str:
+def _extend_current_line(current_line: str, text: str) -> str:
+ """
+ Extend the current output line with newly rendered text.
+
+ Returns:
+ The text after the final newline, or the extended current line if there is no newline.
+ """
+ _, separator, suffix = text.rpartition("\n")
+ return suffix if separator else current_line + text
+
+
+def _fill_parts(
+ literals: list[str],
+ holes: list[_T],
+ render: Callable[[_T, str], str],
+ *,
+ preceding_text: str = "",
+) -> str:
+ """
+ Fill holes between literal parts while tracking the current output line.
+
+ Raises:
+ RuntimeError: If the literal and hole counts are inconsistent.
+
+ Returns:
+ The rendered literal and hole parts.
+ """
+ if len(literals) != len(holes) + 1:
+ message = "literal and hole counts are inconsistent"
+ raise RuntimeError(message)
+
+ result_parts = [literals[0]]
+ current_line = _extend_current_line(
+ preceding_text[preceding_text.rfind("\n") + 1 :],
+ literals[0],
+ )
+
+ for hole, literal in zip(holes, literals[1:], strict=True):
+ rendered = render(hole, current_line)
+ result_parts.extend((rendered, literal))
+ current_line = _extend_current_line(current_line, rendered)
+ current_line = _extend_current_line(current_line, literal)
+
+ return "".join(result_parts)
+
+
+def _split_align_markers(string: str) -> tuple[list[str], list[str]]:
+ """
+ Split a string into literal parts and top-level aligned values.
+
+ Returns:
+ The alternating literal parts and aligned values.
+ """
+ literals: list[str] = []
+ values: list[str] = []
+ last_end = 0
+
+ for match in _ALIGN_MARKER.finditer(string):
+ literals.append(string[last_end : match.start()])
+ values.append(match.group(2))
+ last_end = match.end()
+
+ literals.append(string[last_end:])
+ return literals, values
+
+
+def _render_align_marker(value: str, preceding_text: str) -> str:
+ """
+ Render one aligned value, processing nested markers first.
+
+ Returns:
+ The marker-free value aligned to its preceding text.
+ """
+ return _align_value(process_align_markers(value), preceding_text)
+
+
+def process_align_markers(string: str, *, preceding_text: str = "") -> str:
"""
Detect alignment markers in `string`, apply indentation alignment, and remove the markers.
@@ -240,61 +322,86 @@ def process_align_markers(string: str) -> str:
Args:
string: The string potentially containing alignment markers.
+ preceding_text: Text preceding `string`, used to align a marker at its start.
Returns:
The string with markers removed and aligned values indented appropriately.
"""
- result_parts: list[str] = []
- last_end = 0
-
- for match in _ALIGN_MARKER.finditer(string):
- # Text before this marker
- result_parts.append(string[last_end : match.start()])
- # Preceding text is what we've built so far
- preceding_text = "".join(result_parts)
- value = match.group(2)
- value = process_align_markers(value) # Handle nested markers first
- aligned_value = _align_value(value, preceding_text)
- result_parts.append(aligned_value)
- last_end = match.end()
-
- if last_end == 0:
+ literals, values = _split_align_markers(string)
+ if not values:
return string
- result_parts.append(string[last_end:])
- return "".join(result_parts)
+ return _fill_parts(
+ literals,
+ values,
+ _render_align_marker,
+ preceding_text=preceding_text,
+ )
-def _dedent_marked_string(string: str, strip: Strip) -> str:
+def _dedent_and_fill(
+ literals: list[str],
+ holes: list[_T],
+ render: Callable[[_T, str], str],
+ strip: Strip,
+) -> str:
"""
- Dedent static text without letting aligned values affect its indentation.
+ Dedent literal structure, then render its opaque holes in one linear pass.
+
+ Raises:
+ RuntimeError: If hole metadata is inconsistent with the literal framework.
Returns:
- The dedented string with deferred values restored and aligned.
+ The dedented, rendered, and stripped string.
"""
+ if len(literals) != len(holes) + 1:
+ message = "literal and hole counts are inconsistent"
+ raise RuntimeError(message)
+
+ if not holes:
+ return _strip_string(_dedent_string(literals[0]), strip)
+
marker_id = uuid4().hex
- deferred_markers: list[tuple[str, str]] = []
+ framework_parts = [literals[0]]
- def defer_marker(match: re.Match[str]) -> str:
- token = f"{_SEP}{_ALIGN_MARKER_PREFIX}:{marker_id}:{len(deferred_markers)}{_SEP}"
- deferred_markers.append((token, match.group()))
- return token
+ for index, literal in enumerate(literals[1:]):
+ token = f"{_SEP}{_HOLE_MARKER_PREFIX}:{marker_id}:{index}{_SEP}"
+ framework_parts.extend((token, literal))
- string = _dedent_string(_ALIGN_MARKER.sub(defer_marker, string))
+ framework = _dedent_string("".join(framework_parts))
+ hole_marker = re.compile(rf"{_SEP}{_HOLE_MARKER_PREFIX}:{marker_id}:(\d+){_SEP}")
+ dedented_literals: list[str] = []
+ last_end = 0
+
+ for match in hole_marker.finditer(framework):
+ if int(match.group(1)) != len(dedented_literals):
+ message = "dedent hole markers are out of order"
+ raise RuntimeError(message)
+ dedented_literals.append(framework[last_end : match.start()])
+ last_end = match.end()
- for token, marker in deferred_markers:
- string = string.replace(token, marker, 1)
+ dedented_literals.append(framework[last_end:])
+ return _strip_string(_fill_parts(dedented_literals, holes, render), strip)
- return _strip_string(process_align_markers(string), strip)
+
+def _dedent_marked_string(string: str, strip: Strip) -> str:
+ """
+ Dedent static text without letting aligned values affect its indentation.
+
+ Returns:
+ The dedented string with deferred values restored and aligned.
+ """
+ literals, values = _split_align_markers(string)
+ return _dedent_and_fill(literals, values, _render_align_marker, strip)
if sys.version_info >= (3, 14):
from string.templatelib import Interpolation, Template, convert
- from typing import LiteralString, TypeVar
-
- T = TypeVar(name="T")
+ from typing import LiteralString
- def _partition(it: Iterable[T], pred: Callable[[T], bool]) -> tuple[filter[T], filterfalse[T]]:
+ def _partition(
+ it: Iterable[_T], pred: Callable[[_T], bool]
+ ) -> tuple[filter[_T], filterfalse[_T]]:
"""
Partition an iterable into two filters based on a predicate.
@@ -342,19 +449,16 @@ def _parse_format_spec(format_spec: str) -> tuple[str, bool | None]:
return format_spec, AlignSpec(dedent_spec) == AlignSpec.ALIGN
def _handle_item(
- item: str | Interpolation,
+ item: Interpolation,
*,
preceding_text: str,
align: bool,
) -> str:
"""
- Process a single template item (string or interpolation).
-
- For string items, returns them as-is. For interpolations, converts the value, applies format
- specifications (extracting dedent directives), and optionally aligns multiline output.
+ Convert and format one interpolation, then apply requested alignment.
Args:
- item: Either a string literal or an Interpolation object.
+ item: The interpolation to render.
preceding_text: The text that precedes this item, used for alignment.
align: Whether to align multiline values by default (can be overridden by format spec
directives).
@@ -362,9 +466,6 @@ def _handle_item(
Returns:
The processed string representation of the item.
"""
- if isinstance(item, str):
- return item
-
value = convert(cast("object", item.value), item.conversion)
align_override: bool | None = None
@@ -375,6 +476,8 @@ def _handle_item(
value = str(value)
should_align = align_override if align_override is not None else align
+ if _ALIGN_MARKER.search(value):
+ return process_align_markers(value, preceding_text=preceding_text)
if should_align:
value = _align_value(value, preceding_text)
@@ -387,28 +490,21 @@ def _dedent_template(template: Template, *, align: bool, strip: Strip) -> str:
Returns:
The dedented and rendered template.
"""
- marker_id = uuid4().hex
- interpolations: list[tuple[str, Interpolation]] = []
- framework_parts: list[str] = []
+ interpolations: list[Interpolation] = []
+ literals = [""]
for item in template:
if isinstance(item, str):
- framework_parts.append(item)
+ literals[-1] += item
continue
- token = f"{_SEP}{_ALIGN_MARKER_PREFIX}:{marker_id}:{len(interpolations)}{_SEP}"
- interpolations.append((token, item))
- framework_parts.append(token)
-
- framework = _dedent_string("".join(framework_parts))
- result = ""
+ interpolations.append(item)
+ literals.append("")
- for token, interpolation in interpolations:
- preceding, framework = framework.split(token, maxsplit=1)
- result += preceding
- result += _handle_item(interpolation, preceding_text=result, align=align)
+ def render_interpolation(item: Interpolation, preceding_text: str) -> str:
+ return _handle_item(item, preceding_text=preceding_text, align=align)
- return _strip_string(process_align_markers(result + framework), strip)
+ return _dedent_and_fill(literals, interpolations, render_interpolation, strip)
def dedent( # pyright: ignore[reportUnreachable]
string: Template | LiteralString,
@@ -441,6 +537,7 @@ def dedent( # pyright: ignore[reportUnreachable]
- "none": Leaves whitespace exactly as-is after dedenting.
Raises:
+ IndentationError: If a line is incompatible with the common indentation prefix.
TypeError: If the input is not a string or Template object.
Returns:
@@ -455,7 +552,7 @@ def dedent( # pyright: ignore[reportUnreachable]
... """)
>>> print(result)
Hello, World!
- '''
+ ''' # noqa: DOC502
align = align if not isinstance(align, Missing) else DEFAULT_ALIGN
strip = strip if not isinstance(strip, Missing) else DEFAULT_STRIP
@@ -492,6 +589,7 @@ def dedent( # pyright: ignore[reportUnreachable]
- "none": Leaves whitespace exactly as-is after dedenting.
Raises:
+ IndentationError: If a line is incompatible with the common indentation prefix.
TypeError: If the input is not a string.
Returns:
@@ -509,7 +607,7 @@ def dedent( # pyright: ignore[reportUnreachable]
List:
- a
- b
- '''
+ ''' # noqa: DOC502
strip = strip if not isinstance(strip, Missing) else DEFAULT_STRIP
if not isinstance(string, str): # pyright: ignore[reportUnnecessaryIsInstance]
diff --git a/tests/test_dedent.py b/tests/test_dedent.py
index 2f835cd..a06186b 100644
--- a/tests/test_dedent.py
+++ b/tests/test_dedent.py
@@ -53,6 +53,17 @@ def test_closing_line_constrains_indentation() -> None:
assert dedent("\n first\n ") == snapshot(" first")
assert dedent("\n first\n ") == snapshot("first")
+ @staticmethod
+ def test_column_zero_closer_preserves_indentation() -> None:
+ assert dedent("\n first\n second\n") == snapshot("""\
+ first
+ second\
+""")
+ assert dedent("\n first\n second") == snapshot("""\
+first
+second\
+""")
+
@staticmethod
def test_ignores_compatible_whitespace_only_lines() -> None:
assert dedent("\n first\n \n second\n ") == snapshot("""\
@@ -62,9 +73,35 @@ def test_ignores_compatible_whitespace_only_lines() -> None:
""")
@staticmethod
- def test_rejects_incompatible_whitespace_only_lines() -> None:
+ def test_preserves_crlf_when_ignoring_blank_lines() -> None:
+ assert dedent(" first\r\n\r\n second\r\n ", strip="none") == snapshot(
+ "first\r\n\r\nsecond\r\n"
+ )
+
+ @staticmethod
+ def test_smart_strip_removes_complete_crlf_boundary() -> None:
+ assert dedent(" first\r\n ") == snapshot("first")
+
+ @pytest.mark.parametrize("whitespace", ["\f", "\v", "\N{NO-BREAK SPACE}"])
+ @staticmethod
+ def test_non_indentation_whitespace_is_content(whitespace: str) -> None:
+ string = f" first\n{whitespace}\n second\n "
+ assert (
+ dedent(string, strip="none") # pyright: ignore[reportArgumentType]: runtime contract
+ == string
+ )
+
+ @pytest.mark.parametrize(
+ "string",
+ [
+ "\n first\n \t\n second\n ",
+ "\r\n first\r\n \t\r\n second\r\n ",
+ ],
+ )
+ @staticmethod
+ def test_rejects_incompatible_whitespace_only_lines(string: str) -> None:
with pytest.raises(IndentationError, match="line 3"):
- _ = dedent("\n first\n \t\n second\n ")
+ _ = dedent_f(string)
@staticmethod
def test_removes_common_indent() -> None:
@@ -150,13 +187,6 @@ def test_default_is_smart() -> None:
snapshot("""\
foo
bar\
-"""),
- ),
- (
- "all",
- snapshot("""\
-foo
-bar\
"""),
),
(
@@ -257,7 +287,7 @@ def test_tstring() -> None:
@required_py314
@staticmethod
def test_tstring_format_spec() -> None:
- assert dedent(
+ template_result = dedent(
t(
"""
{header:=^21}
@@ -266,15 +296,24 @@ def test_tstring_format_spec() -> None:
header="Receipt",
total=123,
)
- ) == snapshot("""\
+ )
+ fstring_result = dedent_f(f"""
+ {"Receipt":=^21}
+ - Total: {123: 9d}
+ """)
+ assert (
+ template_result
+ == fstring_result
+ == snapshot("""\
=======Receipt=======
- Total: 123\
""")
+ )
@required_py314
@staticmethod
def test_tstring_empty_format_spec() -> None:
- assert dedent(t("{123:}")) == snapshot("123")
+ assert dedent(t("{123:}")) == dedent_f(f"{123:}") == snapshot("123")
class TestAlign:
@@ -291,15 +330,18 @@ def test_unaligned_multiline_value() -> None:
@staticmethod
def test_align_wrapper() -> None:
+ items = dedent("""
+ - apples
+ - bananas
+ """)
assert dedent_f(f"""
- List:
- {align(ITEMS)}
+ Groceries:
+ {align(items)}
---
""") == snapshot("""\
-List:
+Groceries:
- apples
- bananas
- - cherries
---\
""")
@@ -321,6 +363,20 @@ def test_two_values() -> None:
bar\
""")
+ @staticmethod
+ def test_two_values_on_the_same_line() -> None:
+ a = "a1\na2"
+ b = "b1\nb2"
+ assert dedent_f(f"""
+ Row:
+ {align(a)} + {align(b)}
+ """) == snapshot("""\
+Row:
+ a1
+ a2 + b1
+ b2\
+""")
+
@staticmethod
def test_nested_wrappers() -> None:
inner = "line1\nline2"
@@ -333,6 +389,15 @@ def test_nested_wrappers() -> None:
assert "\x00" not in result
assert "DEDENT_ALIGN" not in result
+ @staticmethod
+ def test_alignment_indent_uses_only_spaces_and_tabs() -> None:
+ value = "line1\nline2"
+ string = f"\N{NO-BREAK SPACE}{align(value)}"
+ assert (
+ dedent(string, strip="none") # pyright: ignore[reportArgumentType]: runtime contract
+ == snapshot("\N{NO-BREAK SPACE}line1\nline2")
+ )
+
@staticmethod
def test_conversions() -> None:
assert dedent_f(f"""
@@ -426,6 +491,27 @@ def test_noalign_overrides_align_argument() -> None:
- bananas
- cherries
---\
+""")
+
+ @required_py314
+ @staticmethod
+ def test_align_wrapper_is_not_applied_twice() -> None:
+ assert dedent(
+ t(
+ """
+ List:
+ {items}
+ ---
+ """,
+ items=align(ITEMS),
+ ),
+ align=True, # pyright: ignore[reportCallIssue]: test is skipped before Python 3.14
+ ) == snapshot("""\
+List:
+ - apples
+ - bananas
+ - cherries
+---\
""")
@required_py314
@@ -434,6 +520,33 @@ def test_align_directive_with_format_spec() -> None:
assert dedent(t("{123:06d:align}")) == snapshot("000123")
assert dedent(t("{123:align:06d}")) == snapshot("000123")
+ @required_py314
+ @staticmethod
+ def test_alignment_after_multiline_formatting() -> None:
+ size = len(ITEMS) + 2
+ assert dedent(
+ t(
+ """
+ List:
+ {items:\n^{size}:align}
+ ---
+ """,
+ items=ITEMS,
+ size=size,
+ )
+ ) == snapshot("List:\n \n - apples\n - bananas\n - cherries\n \n---")
+ assert dedent(
+ t(
+ """
+ List:
+ {items:\n^{size}:noalign}
+ ---
+ """,
+ items=ITEMS,
+ size=size,
+ )
+ ) == snapshot("List:\n \n- apples\n- bananas\n- cherries\n\n---")
+
@required_py314
@staticmethod
def test_unknown_format_spec() -> None:
From 996f3776f6fdf35c18377737475a95876367ef43 Mon Sep 17 00:00:00 2001
From: Graham Preston <16179442+grahamcracker1234@users.noreply.github.com>
Date: Tue, 18 Aug 2026 09:21:27 -0400
Subject: [PATCH 03/15] Align multiline values to interpolation columns
Keep continuation lines directly beneath each interpolation, including nested and same-line values, so rendered output remains structurally aligned.
---
README.md | 20 +++++++++------
src/dedent/__init__.py | 2 +-
src/dedent/_dedent.py | 56 ++++++++++++++----------------------------
tests/test_dedent.py | 10 ++++----
4 files changed, 37 insertions(+), 51 deletions(-)
diff --git a/README.md b/README.md
index 8a6000b..bf76641 100644
--- a/README.md
+++ b/README.md
@@ -124,7 +124,7 @@ When an interpolation evaluates to a multiline string, only its first line is pl
Use format spec directives inside t-strings for per-value control:
-- `{value:align}` - Align this multiline value to the current indentation
+- `{value:align}` - Align this multiline value to its interpolation column
- `{value:noalign}` - Disable alignment for this value [^1]
- `{value:align:06d}` - Combine with other format specs [^2]
@@ -334,11 +334,13 @@ groceries = dedent("""\
""")
# Stripping the newlines
-groceries = dedent("""
+groceries = dedent(
+ """
- apples
- bananas
- cherries
-""".strip("\n"))
+""".strip("\n")
+)
# But the shopping list still comes out wrong:
# Groceries:
@@ -355,19 +357,23 @@ Sure, we could manually adjust the indentation with a bit of string manipulation
```python
from textwrap import dedent
-groceries = dedent("""
+groceries = dedent(
+ """
- apples
- bananas
- cherries
-""".strip("\n"))
+""".strip("\n")
+)
manual_groceries = ("\n" + " " * 8).join(groceries.splitlines())
-shopping_list = dedent(f"""
+shopping_list = dedent(
+ f"""
Groceries:
{manual_groceries}
---
-""".strip("\n"))
+""".strip("\n")
+)
```
`dedent` solves these problems and more:
diff --git a/src/dedent/__init__.py b/src/dedent/__init__.py
index b893c26..b89cbeb 100644
--- a/src/dedent/__init__.py
+++ b/src/dedent/__init__.py
@@ -9,7 +9,7 @@
- Uses PEP 822's exact common-prefix and closing-line indentation rules
- Smart-strips leading/trailing whitespace by default
-- Aligns multiline interpolated values to match surrounding indentation
+- Aligns multiline interpolated values to their interpolation column
- On Python 3.14+: t-strings with per-value format spec directives (`{value:align}`)
- On Python 3.10-3.13: f-strings with the `align` wrapper
"""
diff --git a/src/dedent/_dedent.py b/src/dedent/_dedent.py
index 1aed630..8d9d47b 100644
--- a/src/dedent/_dedent.py
+++ b/src/dedent/_dedent.py
@@ -4,7 +4,7 @@
from dataclasses import dataclass, field
from enum import Enum
from itertools import filterfalse, tee
-from typing import TYPE_CHECKING, Final, Literal, TypeVar, cast, final
+from typing import TYPE_CHECKING, Final, Literal, TypeVar, final
from uuid import uuid4
@@ -28,7 +28,6 @@ class AlignSpec(str, Enum):
Strip = Literal["smart", "all", "none"]
_T = TypeVar("_T")
-_INDENTED: Final = re.compile(r"^([ \t]+)")
_SMART_STRIP_START: Final = re.compile(r"^[^\S\r\n]*(?:\r\n|[\r\n])?")
_SMART_STRIP_END: Final = re.compile(r"(?:\r\n|[\r\n])?[^\S\r\n]*\Z")
@@ -42,41 +41,23 @@ class AlignSpec(str, Enum):
)
-def _safe_match_first_group(pattern: re.Pattern[str], string: str) -> str | None:
- """
- Safely extract the first capture group from a regex match.
-
- Args:
- pattern: The compiled regex pattern to match against.
- string: The string to match.
-
- Returns:
- The first capture group if the pattern matches, None otherwise.
- """
- if m := pattern.match(string):
- return m.group(1)
- return None
-
-
def _align_value(value: str, preceding_text: str) -> str:
"""
- Align multiline value to match the indentation of the current line.
+ Align a multiline value to the column where it is inserted.
- If the value contains newlines, each line after the first is indented to match the indentation
- of the current line in the preceding text.
+ If the value contains newlines, each line after the first starts directly below the first.
Args:
value: The string value to align, potentially containing newlines.
- preceding_text: The text that precedes this value, used to determine the current line's
- indentation.
+ preceding_text: The text that precedes this value, used to determine its insertion column.
Returns:
- The value with subsequent lines indented to match the current line's indentation, or the
- original value if no indentation is found or if the value doesn't contain newlines.
+ The value with subsequent lines aligned to the insertion column.
"""
current_line = preceding_text[preceding_text.rfind("\n") + 1 :]
- if indent := _safe_match_first_group(_INDENTED, current_line):
- return value.replace("\n", "\n" + indent)
+ alignment = "".join(character if character in " \t" else " " for character in current_line)
+ if alignment:
+ return value.replace("\n", "\n" + alignment)
return value
@@ -204,8 +185,7 @@ def align(value: object) -> Aligned:
Mark a value for automatic indentation alignment inside `dedent()`.
Wrap an interpolated value so that, when the surrounding f-string is passed to
- `dedent()`, subsequent lines of the value are indented to match the current
- indentation context.
+ `dedent()`, subsequent lines of the value start in the same column as the first.
Example::
@@ -449,7 +429,7 @@ def _parse_format_spec(format_spec: str) -> tuple[str, bool | None]:
return format_spec, AlignSpec(dedent_spec) == AlignSpec.ALIGN
def _handle_item(
- item: Interpolation,
+ item: Interpolation[object],
*,
preceding_text: str,
align: bool,
@@ -466,7 +446,7 @@ def _handle_item(
Returns:
The processed string representation of the item.
"""
- value = convert(cast("object", item.value), item.conversion)
+ value = convert(item.value, item.conversion)
align_override: bool | None = None
if item.format_spec:
@@ -490,7 +470,7 @@ def _dedent_template(template: Template, *, align: bool, strip: Strip) -> str:
Returns:
The dedented and rendered template.
"""
- interpolations: list[Interpolation] = []
+ interpolations: list[Interpolation[object]] = []
literals = [""]
for item in template:
@@ -501,7 +481,7 @@ def _dedent_template(template: Template, *, align: bool, strip: Strip) -> str:
interpolations.append(item)
literals.append("")
- def render_interpolation(item: Interpolation, preceding_text: str) -> str:
+ def render_interpolation(item: Interpolation[object], preceding_text: str) -> str:
return _handle_item(item, preceding_text=preceding_text, align=align)
return _dedent_and_fill(literals, interpolations, render_interpolation, strip)
@@ -528,9 +508,9 @@ def dedent( # pyright: ignore[reportUnreachable]
Args:
string: Template or literal string to dedent.
- align: Whether to align multiline interpolated values by indenting subsequent lines to
- match the indentation of the current line. Defaults to False. Can be overridden
- per-value using format spec directives.
+ align: Whether multiline interpolated values start each line in the column where the
+ interpolation begins. Defaults to False. Can be overridden per value using format
+ spec directives.
strip: Stripping mode to use.
- "smart" (default): Strips one leading and trailing newline-bounded blank segment.
- "all": Strips all surrounding whitespace.
@@ -552,7 +532,7 @@ def dedent( # pyright: ignore[reportUnreachable]
... """)
>>> print(result)
Hello, World!
- ''' # noqa: DOC502
+ ''' # ruff: ignore[docstring-extraneous-exception]
align = align if not isinstance(align, Missing) else DEFAULT_ALIGN
strip = strip if not isinstance(strip, Missing) else DEFAULT_STRIP
@@ -607,7 +587,7 @@ def dedent( # pyright: ignore[reportUnreachable]
List:
- a
- b
- ''' # noqa: DOC502
+ ''' # ruff: ignore[docstring-extraneous-exception]
strip = strip if not isinstance(strip, Missing) else DEFAULT_STRIP
if not isinstance(string, str): # pyright: ignore[reportUnnecessaryIsInstance]
diff --git a/tests/test_dedent.py b/tests/test_dedent.py
index a06186b..ff67334 100644
--- a/tests/test_dedent.py
+++ b/tests/test_dedent.py
@@ -28,7 +28,7 @@
def t(source: str, /, **ns: object) -> _T:
code = compile(f"t'''{source}'''", "", "eval")
- return cast("_T", eval(code, ns)) # noqa: S307
+ return cast("_T", eval(code, ns)) # ruff: ignore[suspicious-eval-usage]
def dedent_f(string: str) -> str:
@@ -374,7 +374,7 @@ def test_two_values_on_the_same_line() -> None:
Row:
a1
a2 + b1
- b2\
+ b2\
""")
@staticmethod
@@ -384,18 +384,18 @@ def test_nested_wrappers() -> None:
result = dedent_f(f" {align(outer)}")
assert result == snapshot("""\
outer line1
-line2\
+ line2\
""")
assert "\x00" not in result
assert "DEDENT_ALIGN" not in result
@staticmethod
- def test_alignment_indent_uses_only_spaces_and_tabs() -> None:
+ def test_alignment_uses_interpolation_column_with_non_space_prefix() -> None:
value = "line1\nline2"
string = f"\N{NO-BREAK SPACE}{align(value)}"
assert (
dedent(string, strip="none") # pyright: ignore[reportArgumentType]: runtime contract
- == snapshot("\N{NO-BREAK SPACE}line1\nline2")
+ == snapshot("\N{NO-BREAK SPACE}line1\n line2")
)
@staticmethod
From 082e3699c0707bad39e1d7207eaf6c48202e124c Mon Sep 17 00:00:00 2001
From: Graham Preston <16179442+grahamcracker1234@users.noreply.github.com>
Date: Tue, 18 Aug 2026 09:21:42 -0400
Subject: [PATCH 04/15] Update development tooling
Adopt current lint rule names and refresh test, type-checking, formatting, and lockfile dependencies.
---
pyproject.toml | 48 +++---
uv.lock | 414 ++++++++++++++++++++++++++-----------------------
2 files changed, 245 insertions(+), 217 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index 5448ca1..5d3e743 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -61,11 +61,11 @@ Changelog = "https://github.com/grahamcracker1234/dedent/releases"
[dependency-groups]
dev = [
- "basedpyright>=1.37.1",
+ "basedpyright>=1.39.10",
"inline-snapshot>=0.35.4",
- "pytest>=9.0.2",
- "pytest-cov>=7.0.0",
- "ruff>=0.14.11",
+ "pytest>=9.1.1",
+ "pytest-cov>=7.1.0",
+ "ruff>=0.16.3",
]
[build-system]
@@ -100,22 +100,22 @@ docstring-code-format = true
[tool.ruff.lint]
ignore = [
- "D203", # Conflicts with D211
- "D212", # Conflicts with D213
- "SLF001", # Conflicts with basedpyright[reportPrivateUsage]
+ "incorrect-blank-line-before-class", # Conflicts with D211
+ "multi-line-summary-first-line", # Conflicts with D213
+ "private-member-access", # Conflicts with basedpyright[reportPrivateUsage]
# Formatter conflicting rules
- "W191",
- "E111",
- "E114",
- "E117",
- "D206",
- "D300",
- "Q000",
- "Q001",
- "Q002",
- "Q003",
- "COM812",
- "COM819",
+ "tab-indentation",
+ "indentation-with-invalid-multiple",
+ "indentation-with-invalid-multiple-comment",
+ "over-indented",
+ "docstring-tab-indentation",
+ "triple-single-quotes",
+ "bad-quotes-inline-string",
+ "bad-quotes-multiline-string",
+ "bad-quotes-docstring",
+ "avoidable-escaped-quote",
+ "missing-trailing-comma",
+ "prohibited-trailing-comma",
]
select = [
"FAST", # FastAPI
@@ -186,11 +186,11 @@ convention = "pep257"
[tool.ruff.lint.per-file-ignores]
"*.py" = ["PYI"]
"**/tests/**/*.py" = [
- "S101", # assert
- "PLR2004", # magic-value-comparison
- "INP", # flake8-no-pep420 (implicit-namespace-package)
- "D", # pydocstyle
- "DOC", # pydoclint
+ "assert",
+ "magic-value-comparison",
+ "INP", # flake8-no-pep420 (implicit-namespace-package)
+ "D", # pydocstyle
+ "DOC", # pydoclint
]
"**/scripts/*.py" = [
"INP", # flake8-no-pep420 (implicit-namespace-package)
diff --git a/uv.lock b/uv.lock
index 1d05bfb..051cde4 100644
--- a/uv.lock
+++ b/uv.lock
@@ -13,14 +13,14 @@ wheels = [
[[package]]
name = "basedpyright"
-version = "1.37.3"
+version = "1.39.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodejs-wheel-binaries" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/e2/9f/aaaa202f2d7eaefaf5a4f297c80943d060c64db4753acb8003111d575c94/basedpyright-1.37.3.tar.gz", hash = "sha256:4cb4717a5686fba0c1e3156fe25e729d07e6912b68f9f67c33ea490d3f0d2a46", size = 25245208, upload-time = "2026-02-01T08:45:42.355Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/68/43/ad2999f3b09eb2b1e59931d88fac0f7bcc9c17fc18c903268779bd10cc97/basedpyright-1.39.10.tar.gz", hash = "sha256:c8eaf5302f3265e275c7df4fba194d7afa7c1cb53fbfd448e90098360aca2c2e", size = 24740347, upload-time = "2026-08-13T17:09:02.51Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f3/4f/b8fd24714cda878fd6b95e7ffc6051a864ac04bf2520d4622252df0abc9c/basedpyright-1.37.3-py3-none-any.whl", hash = "sha256:7064b2c811d9d79ebaa60ef65a2cdf292a9eb3c32dba9dee32660de2dbdd71e1", size = 12299378, upload-time = "2026-02-01T08:45:45.947Z" },
+ { url = "https://files.pythonhosted.org/packages/be/2a/a224054d75a58786c482f63b8ff2a09fc3362268bd1ebd0a61fb3f982153/basedpyright-1.39.10-py3-none-any.whl", hash = "sha256:cbd75d83c0be841329bcfef2d2f1182f152a6d975b8eb199e75cf5b8e9a3de78", size = 13482322, upload-time = "2026-08-13T17:08:59.074Z" },
]
[[package]]
@@ -34,101 +34,130 @@ wheels = [
[[package]]
name = "coverage"
-version = "7.13.3"
+version = "7.15.4"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/11/43/3e4ac666cc35f231fa70c94e9f38459299de1a152813f9d2f60fc5f3ecaf/coverage-7.13.3.tar.gz", hash = "sha256:f7f6182d3dfb8802c1747eacbfe611b669455b69b7c037484bb1efbbb56711ac", size = 826832, upload-time = "2026-02-03T14:02:30.944Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ab/07/1c8099563a8a6c389a31c2d0aa1497cee86d6248bb4b9ba5e779215db9f9/coverage-7.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b4f345f7265cdbdb5ec2521ffff15fa49de6d6c39abf89fc7ad68aa9e3a55f0", size = 219143, upload-time = "2026-02-03T13:59:40.459Z" },
- { url = "https://files.pythonhosted.org/packages/69/39/a892d44af7aa092cab70e0cc5cdbba18eeccfe1d6930695dab1742eef9e9/coverage-7.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:96c3be8bae9d0333e403cc1a8eb078a7f928b5650bae94a18fb4820cc993fb9b", size = 219663, upload-time = "2026-02-03T13:59:41.951Z" },
- { url = "https://files.pythonhosted.org/packages/9a/25/9669dcf4c2bb4c3861469e6db20e52e8c11908cf53c14ec9b12e9fd4d602/coverage-7.13.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d6f4a21328ea49d38565b55599e1c02834e76583a6953e5586d65cb1efebd8f8", size = 246424, upload-time = "2026-02-03T13:59:43.418Z" },
- { url = "https://files.pythonhosted.org/packages/f3/68/d9766c4e298aca62ea5d9543e1dd1e4e1439d7284815244d8b7db1840bfb/coverage-7.13.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fc970575799a9d17d5c3fafd83a0f6ccf5d5117cdc9ad6fbd791e9ead82418b0", size = 248228, upload-time = "2026-02-03T13:59:44.816Z" },
- { url = "https://files.pythonhosted.org/packages/f0/e2/eea6cb4a4bd443741adf008d4cccec83a1f75401df59b6559aca2bdd9710/coverage-7.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87ff33b652b3556b05e204ae20793d1f872161b0fa5ec8a9ac76f8430e152ed6", size = 250103, upload-time = "2026-02-03T13:59:46.271Z" },
- { url = "https://files.pythonhosted.org/packages/db/77/664280ecd666c2191610842177e2fab9e5dbdeef97178e2078fed46a3d2c/coverage-7.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7df8759ee57b9f3f7b66799b7660c282f4375bef620ade1686d6a7b03699e75f", size = 247107, upload-time = "2026-02-03T13:59:48.53Z" },
- { url = "https://files.pythonhosted.org/packages/2b/df/2a672eab99e0d0eba52d8a63e47dc92245eee26954d1b2d3c8f7d372151f/coverage-7.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f45c9bcb16bee25a798ccba8a2f6a1251b19de6a0d617bb365d7d2f386c4e20e", size = 248143, upload-time = "2026-02-03T13:59:50.027Z" },
- { url = "https://files.pythonhosted.org/packages/a5/dc/a104e7a87c13e57a358b8b9199a8955676e1703bb372d79722b54978ae45/coverage-7.13.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:318b2e4753cbf611061e01b6cc81477e1cdfeb69c36c4a14e6595e674caadb56", size = 246148, upload-time = "2026-02-03T13:59:52.025Z" },
- { url = "https://files.pythonhosted.org/packages/2b/89/e113d3a58dc20b03b7e59aed1e53ebc9ca6167f961876443e002b10e3ae9/coverage-7.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:24db3959de8ee394eeeca89ccb8ba25305c2da9a668dd44173394cbd5aa0777f", size = 246414, upload-time = "2026-02-03T13:59:53.859Z" },
- { url = "https://files.pythonhosted.org/packages/3f/60/a3fd0a6e8d89b488396019a2268b6a1f25ab56d6d18f3be50f35d77b47dc/coverage-7.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:be14d0622125edef21b3a4d8cd2d138c4872bf6e38adc90fd92385e3312f406a", size = 247023, upload-time = "2026-02-03T13:59:55.454Z" },
- { url = "https://files.pythonhosted.org/packages/19/fa/de4840bb939dbb22ba0648a6d8069fa91c9cf3b3fca8b0d1df461e885b3d/coverage-7.13.3-cp310-cp310-win32.whl", hash = "sha256:53be4aab8ddef18beb6188f3a3fdbf4d1af2277d098d4e618be3a8e6c88e74be", size = 221751, upload-time = "2026-02-03T13:59:57.383Z" },
- { url = "https://files.pythonhosted.org/packages/de/87/233ff8b7ef62fb63f58c78623b50bef69681111e0c4d43504f422d88cda4/coverage-7.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:bfeee64ad8b4aae3233abb77eb6b52b51b05fa89da9645518671b9939a78732b", size = 222686, upload-time = "2026-02-03T13:59:58.825Z" },
- { url = "https://files.pythonhosted.org/packages/ec/09/1ac74e37cf45f17eb41e11a21854f7f92a4c2d6c6098ef4a1becb0c6d8d3/coverage-7.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5907605ee20e126eeee2abe14aae137043c2c8af2fa9b38d2ab3b7a6b8137f73", size = 219276, upload-time = "2026-02-03T14:00:00.296Z" },
- { url = "https://files.pythonhosted.org/packages/2e/cb/71908b08b21beb2c437d0d5870c4ec129c570ca1b386a8427fcdb11cf89c/coverage-7.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a88705500988c8acad8b8fd86c2a933d3aa96bec1ddc4bc5cb256360db7bbd00", size = 219776, upload-time = "2026-02-03T14:00:02.414Z" },
- { url = "https://files.pythonhosted.org/packages/09/85/c4f3dd69232887666a2c0394d4be21c60ea934d404db068e6c96aa59cd87/coverage-7.13.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bbb5aa9016c4c29e3432e087aa29ebee3f8fda089cfbfb4e6d64bd292dcd1c2", size = 250196, upload-time = "2026-02-03T14:00:04.197Z" },
- { url = "https://files.pythonhosted.org/packages/9c/cc/560ad6f12010344d0778e268df5ba9aa990aacccc310d478bf82bf3d302c/coverage-7.13.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c2be202a83dde768937a61cdc5d06bf9fb204048ca199d93479488e6247656c", size = 252111, upload-time = "2026-02-03T14:00:05.639Z" },
- { url = "https://files.pythonhosted.org/packages/f0/66/3193985fb2c58e91f94cfbe9e21a6fdf941e9301fe2be9e92c072e9c8f8c/coverage-7.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f45e32ef383ce56e0ca099b2e02fcdf7950be4b1b56afaab27b4ad790befe5b", size = 254217, upload-time = "2026-02-03T14:00:07.738Z" },
- { url = "https://files.pythonhosted.org/packages/c5/78/f0f91556bf1faa416792e537c523c5ef9db9b1d32a50572c102b3d7c45b3/coverage-7.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ed2e787249b922a93cd95c671cc9f4c9797a106e81b455c83a9ddb9d34590c0", size = 250318, upload-time = "2026-02-03T14:00:09.224Z" },
- { url = "https://files.pythonhosted.org/packages/6f/aa/fc654e45e837d137b2c1f3a2cc09b4aea1e8b015acd2f774fa0f3d2ddeba/coverage-7.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:05dd25b21afffe545e808265897c35f32d3e4437663923e0d256d9ab5031fb14", size = 251909, upload-time = "2026-02-03T14:00:10.712Z" },
- { url = "https://files.pythonhosted.org/packages/73/4d/ab53063992add8a9ca0463c9d92cce5994a29e17affd1c2daa091b922a93/coverage-7.13.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:46d29926349b5c4f1ea4fca95e8c892835515f3600995a383fa9a923b5739ea4", size = 249971, upload-time = "2026-02-03T14:00:12.402Z" },
- { url = "https://files.pythonhosted.org/packages/29/25/83694b81e46fcff9899694a1b6f57573429cdd82b57932f09a698f03eea5/coverage-7.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:fae6a21537519c2af00245e834e5bf2884699cc7c1055738fd0f9dc37a3644ad", size = 249692, upload-time = "2026-02-03T14:00:13.868Z" },
- { url = "https://files.pythonhosted.org/packages/d4/ef/d68fc304301f4cb4bf6aefa0045310520789ca38dabdfba9dbecd3f37919/coverage-7.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c672d4e2f0575a4ca2bf2aa0c5ced5188220ab806c1bb6d7179f70a11a017222", size = 250597, upload-time = "2026-02-03T14:00:15.461Z" },
- { url = "https://files.pythonhosted.org/packages/8d/85/240ad396f914df361d0f71e912ddcedb48130c71b88dc4193fe3c0306f00/coverage-7.13.3-cp311-cp311-win32.whl", hash = "sha256:fcda51c918c7a13ad93b5f89a58d56e3a072c9e0ba5c231b0ed81404bf2648fb", size = 221773, upload-time = "2026-02-03T14:00:17.462Z" },
- { url = "https://files.pythonhosted.org/packages/2f/71/165b3a6d3d052704a9ab52d11ea64ef3426745de517dda44d872716213a7/coverage-7.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:d1a049b5c51b3b679928dd35e47c4a2235e0b6128b479a7596d0ef5b42fa6301", size = 222711, upload-time = "2026-02-03T14:00:19.449Z" },
- { url = "https://files.pythonhosted.org/packages/51/d0/0ddc9c5934cdd52639c5df1f1eb0fdab51bb52348f3a8d1c7db9c600d93a/coverage-7.13.3-cp311-cp311-win_arm64.whl", hash = "sha256:79f2670c7e772f4917895c3d89aad59e01f3dbe68a4ed2d0373b431fad1dcfba", size = 221377, upload-time = "2026-02-03T14:00:20.968Z" },
- { url = "https://files.pythonhosted.org/packages/94/44/330f8e83b143f6668778ed61d17ece9dc48459e9e74669177de02f45fec5/coverage-7.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ed48b4170caa2c4420e0cd27dc977caaffc7eecc317355751df8373dddcef595", size = 219441, upload-time = "2026-02-03T14:00:22.585Z" },
- { url = "https://files.pythonhosted.org/packages/08/e7/29db05693562c2e65bdf6910c0af2fd6f9325b8f43caf7a258413f369e30/coverage-7.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8f2adf4bcffbbec41f366f2e6dffb9d24e8172d16e91da5799c9b7ed6b5716e6", size = 219801, upload-time = "2026-02-03T14:00:24.186Z" },
- { url = "https://files.pythonhosted.org/packages/90/ae/7f8a78249b02b0818db46220795f8ac8312ea4abd1d37d79ea81db5cae81/coverage-7.13.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01119735c690786b6966a1e9f098da4cd7ca9174c4cfe076d04e653105488395", size = 251306, upload-time = "2026-02-03T14:00:25.798Z" },
- { url = "https://files.pythonhosted.org/packages/62/71/a18a53d1808e09b2e9ebd6b47dad5e92daf4c38b0686b4c4d1b2f3e42b7f/coverage-7.13.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8bb09e83c603f152d855f666d70a71765ca8e67332e5829e62cb9466c176af23", size = 254051, upload-time = "2026-02-03T14:00:27.474Z" },
- { url = "https://files.pythonhosted.org/packages/4a/0a/eb30f6455d04c5a3396d0696cad2df0269ae7444bb322f86ffe3376f7bf9/coverage-7.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b607a40cba795cfac6d130220d25962931ce101f2f478a29822b19755377fb34", size = 255160, upload-time = "2026-02-03T14:00:29.024Z" },
- { url = "https://files.pythonhosted.org/packages/7b/7e/a45baac86274ce3ed842dbb84f14560c673ad30535f397d89164ec56c5df/coverage-7.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44f14a62f5da2e9aedf9080e01d2cda61df39197d48e323538ec037336d68da8", size = 251709, upload-time = "2026-02-03T14:00:30.641Z" },
- { url = "https://files.pythonhosted.org/packages/c0/df/dd0dc12f30da11349993f3e218901fdf82f45ee44773596050c8f5a1fb25/coverage-7.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:debf29e0b157769843dff0981cc76f79e0ed04e36bb773c6cac5f6029054bd8a", size = 253083, upload-time = "2026-02-03T14:00:32.14Z" },
- { url = "https://files.pythonhosted.org/packages/ab/32/fc764c8389a8ce95cb90eb97af4c32f392ab0ac23ec57cadeefb887188d3/coverage-7.13.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:824bb95cd71604031ae9a48edb91fd6effde669522f960375668ed21b36e3ec4", size = 251227, upload-time = "2026-02-03T14:00:34.721Z" },
- { url = "https://files.pythonhosted.org/packages/dd/ca/d025e9da8f06f24c34d2da9873957cfc5f7e0d67802c3e34d0caa8452130/coverage-7.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8f1010029a5b52dc427c8e2a8dbddb2303ddd180b806687d1acd1bb1d06649e7", size = 250794, upload-time = "2026-02-03T14:00:36.278Z" },
- { url = "https://files.pythonhosted.org/packages/45/c7/76bf35d5d488ec8f68682eb8e7671acc50a6d2d1c1182de1d2b6d4ffad3b/coverage-7.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cd5dee4fd7659d8306ffa79eeaaafd91fa30a302dac3af723b9b469e549247e0", size = 252671, upload-time = "2026-02-03T14:00:38.368Z" },
- { url = "https://files.pythonhosted.org/packages/bf/10/1921f1a03a7c209e1cb374f81a6b9b68b03cdb3ecc3433c189bc90e2a3d5/coverage-7.13.3-cp312-cp312-win32.whl", hash = "sha256:f7f153d0184d45f3873b3ad3ad22694fd73aadcb8cdbc4337ab4b41ea6b4dff1", size = 221986, upload-time = "2026-02-03T14:00:40.442Z" },
- { url = "https://files.pythonhosted.org/packages/3c/7c/f5d93297f8e125a80c15545edc754d93e0ed8ba255b65e609b185296af01/coverage-7.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:03a6e5e1e50819d6d7436f5bc40c92ded7e484e400716886ac921e35c133149d", size = 222793, upload-time = "2026-02-03T14:00:42.106Z" },
- { url = "https://files.pythonhosted.org/packages/43/59/c86b84170015b4555ebabca8649bdf9f4a1f737a73168088385ed0f947c4/coverage-7.13.3-cp312-cp312-win_arm64.whl", hash = "sha256:51c4c42c0e7d09a822b08b6cf79b3c4db8333fffde7450da946719ba0d45730f", size = 221410, upload-time = "2026-02-03T14:00:43.726Z" },
- { url = "https://files.pythonhosted.org/packages/81/f3/4c333da7b373e8c8bfb62517e8174a01dcc373d7a9083698e3b39d50d59c/coverage-7.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:853c3d3c79ff0db65797aad79dee6be020efd218ac4510f15a205f1e8d13ce25", size = 219468, upload-time = "2026-02-03T14:00:45.829Z" },
- { url = "https://files.pythonhosted.org/packages/d6/31/0714337b7d23630c8de2f4d56acf43c65f8728a45ed529b34410683f7217/coverage-7.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f75695e157c83d374f88dcc646a60cb94173304a9258b2e74ba5a66b7614a51a", size = 219839, upload-time = "2026-02-03T14:00:47.407Z" },
- { url = "https://files.pythonhosted.org/packages/12/99/bd6f2a2738144c98945666f90cae446ed870cecf0421c767475fcf42cdbe/coverage-7.13.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d098709621d0819039f3f1e471ee554f55a0b2ac0d816883c765b14129b5627", size = 250828, upload-time = "2026-02-03T14:00:49.029Z" },
- { url = "https://files.pythonhosted.org/packages/6f/99/97b600225fbf631e6f5bfd3ad5bcaf87fbb9e34ff87492e5a572ff01bbe2/coverage-7.13.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16d23d6579cf80a474ad160ca14d8b319abaa6db62759d6eef53b2fc979b58c8", size = 253432, upload-time = "2026-02-03T14:00:50.655Z" },
- { url = "https://files.pythonhosted.org/packages/5f/5c/abe2b3490bda26bd4f5e3e799be0bdf00bd81edebedc2c9da8d3ef288fa8/coverage-7.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00d34b29a59d2076e6f318b30a00a69bf63687e30cd882984ed444e753990cc1", size = 254672, upload-time = "2026-02-03T14:00:52.757Z" },
- { url = "https://files.pythonhosted.org/packages/31/ba/5d1957c76b40daff53971fe0adb84d9c2162b614280031d1d0653dd010c1/coverage-7.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab6d72bffac9deb6e6cb0f61042e748de3f9f8e98afb0375a8e64b0b6e11746b", size = 251050, upload-time = "2026-02-03T14:00:54.332Z" },
- { url = "https://files.pythonhosted.org/packages/69/dc/dffdf3bfe9d32090f047d3c3085378558cb4eb6778cda7de414ad74581ed/coverage-7.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e129328ad1258e49cae0123a3b5fcb93d6c2fa90d540f0b4c7cdcdc019aaa3dc", size = 252801, upload-time = "2026-02-03T14:00:56.121Z" },
- { url = "https://files.pythonhosted.org/packages/87/51/cdf6198b0f2746e04511a30dc9185d7b8cdd895276c07bdb538e37f1cd50/coverage-7.13.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2213a8d88ed35459bda71597599d4eec7c2ebad201c88f0bfc2c26fd9b0dd2ea", size = 250763, upload-time = "2026-02-03T14:00:58.719Z" },
- { url = "https://files.pythonhosted.org/packages/d7/1a/596b7d62218c1d69f2475b69cc6b211e33c83c902f38ee6ae9766dd422da/coverage-7.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:00dd3f02de6d5f5c9c3d95e3e036c3c2e2a669f8bf2d3ceb92505c4ce7838f67", size = 250587, upload-time = "2026-02-03T14:01:01.197Z" },
- { url = "https://files.pythonhosted.org/packages/f7/46/52330d5841ff660f22c130b75f5e1dd3e352c8e7baef5e5fef6b14e3e991/coverage-7.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9bada7bc660d20b23d7d312ebe29e927b655cf414dadcdb6335a2075695bd86", size = 252358, upload-time = "2026-02-03T14:01:02.824Z" },
- { url = "https://files.pythonhosted.org/packages/36/8a/e69a5be51923097ba7d5cff9724466e74fe486e9232020ba97c809a8b42b/coverage-7.13.3-cp313-cp313-win32.whl", hash = "sha256:75b3c0300f3fa15809bd62d9ca8b170eb21fcf0100eb4b4154d6dc8b3a5bbd43", size = 222007, upload-time = "2026-02-03T14:01:04.876Z" },
- { url = "https://files.pythonhosted.org/packages/0a/09/a5a069bcee0d613bdd48ee7637fa73bc09e7ed4342b26890f2df97cc9682/coverage-7.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:a2f7589c6132c44c53f6e705e1a6677e2b7821378c22f7703b2cf5388d0d4587", size = 222812, upload-time = "2026-02-03T14:01:07.296Z" },
- { url = "https://files.pythonhosted.org/packages/3d/4f/d62ad7dfe32f9e3d4a10c178bb6f98b10b083d6e0530ca202b399371f6c1/coverage-7.13.3-cp313-cp313-win_arm64.whl", hash = "sha256:123ceaf2b9d8c614f01110f908a341e05b1b305d6b2ada98763b9a5a59756051", size = 221433, upload-time = "2026-02-03T14:01:09.156Z" },
- { url = "https://files.pythonhosted.org/packages/04/b2/4876c46d723d80b9c5b695f1a11bf5f7c3dabf540ec00d6edc076ff025e6/coverage-7.13.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:cc7fd0f726795420f3678ac82ff882c7fc33770bd0074463b5aef7293285ace9", size = 220162, upload-time = "2026-02-03T14:01:11.409Z" },
- { url = "https://files.pythonhosted.org/packages/fc/04/9942b64a0e0bdda2c109f56bda42b2a59d9d3df4c94b85a323c1cae9fc77/coverage-7.13.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d358dc408edc28730aed5477a69338e444e62fba0b7e9e4a131c505fadad691e", size = 220510, upload-time = "2026-02-03T14:01:13.038Z" },
- { url = "https://files.pythonhosted.org/packages/5a/82/5cfe1e81eae525b74669f9795f37eb3edd4679b873d79d1e6c1c14ee6c1c/coverage-7.13.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5d67b9ed6f7b5527b209b24b3df9f2e5bf0198c1bbf99c6971b0e2dcb7e2a107", size = 261801, upload-time = "2026-02-03T14:01:14.674Z" },
- { url = "https://files.pythonhosted.org/packages/0b/ec/a553d7f742fd2cd12e36a16a7b4b3582d5934b496ef2b5ea8abeb10903d4/coverage-7.13.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59224bfb2e9b37c1335ae35d00daa3a5b4e0b1a20f530be208fff1ecfa436f43", size = 263882, upload-time = "2026-02-03T14:01:16.343Z" },
- { url = "https://files.pythonhosted.org/packages/e1/58/8f54a2a93e3d675635bc406de1c9ac8d551312142ff52c9d71b5e533ad45/coverage-7.13.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9306b5299e31e31e0d3b908c66bcb6e7e3ddca143dea0266e9ce6c667346d3", size = 266306, upload-time = "2026-02-03T14:01:18.02Z" },
- { url = "https://files.pythonhosted.org/packages/1a/be/e593399fd6ea1f00aee79ebd7cc401021f218d34e96682a92e1bae092ff6/coverage-7.13.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:343aaeb5f8bb7bcd38620fd7bc56e6ee8207847d8c6103a1e7b72322d381ba4a", size = 261051, upload-time = "2026-02-03T14:01:19.757Z" },
- { url = "https://files.pythonhosted.org/packages/5c/e5/e9e0f6138b21bcdebccac36fbfde9cf15eb1bbcea9f5b1f35cd1f465fb91/coverage-7.13.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2182129f4c101272ff5f2f18038d7b698db1bf8e7aa9e615cb48440899ad32e", size = 263868, upload-time = "2026-02-03T14:01:21.487Z" },
- { url = "https://files.pythonhosted.org/packages/9a/bf/de72cfebb69756f2d4a2dde35efcc33c47d85cd3ebdf844b3914aac2ef28/coverage-7.13.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:94d2ac94bd0cc57c5626f52f8c2fffed1444b5ae8c9fc68320306cc2b255e155", size = 261498, upload-time = "2026-02-03T14:01:23.097Z" },
- { url = "https://files.pythonhosted.org/packages/f2/91/4a2d313a70fc2e98ca53afd1c8ce67a89b1944cd996589a5b1fe7fbb3e5c/coverage-7.13.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:65436cde5ecabe26fb2f0bf598962f0a054d3f23ad529361326ac002c61a2a1e", size = 260394, upload-time = "2026-02-03T14:01:24.949Z" },
- { url = "https://files.pythonhosted.org/packages/40/83/25113af7cf6941e779eb7ed8de2a677865b859a07ccee9146d4cc06a03e3/coverage-7.13.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db83b77f97129813dbd463a67e5335adc6a6a91db652cc085d60c2d512746f96", size = 262579, upload-time = "2026-02-03T14:01:26.703Z" },
- { url = "https://files.pythonhosted.org/packages/1e/19/a5f2b96262977e82fb9aabbe19b4d83561f5d063f18dde3e72f34ffc3b2f/coverage-7.13.3-cp313-cp313t-win32.whl", hash = "sha256:dfb428e41377e6b9ba1b0a32df6db5409cb089a0ed1d0a672dc4953ec110d84f", size = 222679, upload-time = "2026-02-03T14:01:28.553Z" },
- { url = "https://files.pythonhosted.org/packages/81/82/ef1747b88c87a5c7d7edc3704799ebd650189a9158e680a063308b6125ef/coverage-7.13.3-cp313-cp313t-win_amd64.whl", hash = "sha256:5badd7e596e6b0c89aa8ec6d37f4473e4357f982ce57f9a2942b0221cd9cf60c", size = 223740, upload-time = "2026-02-03T14:01:30.776Z" },
- { url = "https://files.pythonhosted.org/packages/1c/4c/a67c7bb5b560241c22736a9cb2f14c5034149ffae18630323fde787339e4/coverage-7.13.3-cp313-cp313t-win_arm64.whl", hash = "sha256:989aa158c0eb19d83c76c26f4ba00dbb272485c56e452010a3450bdbc9daafd9", size = 221996, upload-time = "2026-02-03T14:01:32.495Z" },
- { url = "https://files.pythonhosted.org/packages/5e/b3/677bb43427fed9298905106f39c6520ac75f746f81b8f01104526a8026e4/coverage-7.13.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c6f6169bbdbdb85aab8ac0392d776948907267fcc91deeacf6f9d55f7a83ae3b", size = 219513, upload-time = "2026-02-03T14:01:34.29Z" },
- { url = "https://files.pythonhosted.org/packages/42/53/290046e3bbf8986cdb7366a42dab3440b9983711eaff044a51b11006c67b/coverage-7.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2f5e731627a3d5ef11a2a35aa0c6f7c435867c7ccbc391268eb4f2ca5dbdcc10", size = 219850, upload-time = "2026-02-03T14:01:35.984Z" },
- { url = "https://files.pythonhosted.org/packages/ea/2b/ab41f10345ba2e49d5e299be8663be2b7db33e77ac1b85cd0af985ea6406/coverage-7.13.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9db3a3285d91c0b70fab9f39f0a4aa37d375873677efe4e71e58d8321e8c5d39", size = 250886, upload-time = "2026-02-03T14:01:38.287Z" },
- { url = "https://files.pythonhosted.org/packages/72/2d/b3f6913ee5a1d5cdd04106f257e5fac5d048992ffc2d9995d07b0f17739f/coverage-7.13.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06e49c5897cb12e3f7ecdc111d44e97c4f6d0557b81a7a0204ed70a8b038f86f", size = 253393, upload-time = "2026-02-03T14:01:40.118Z" },
- { url = "https://files.pythonhosted.org/packages/f0/f6/b1f48810ffc6accf49a35b9943636560768f0812330f7456aa87dc39aff5/coverage-7.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb25061a66802df9fc13a9ba1967d25faa4dae0418db469264fd9860a921dde4", size = 254740, upload-time = "2026-02-03T14:01:42.413Z" },
- { url = "https://files.pythonhosted.org/packages/57/d0/e59c54f9be0b61808f6bc4c8c4346bd79f02dd6bbc3f476ef26124661f20/coverage-7.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:99fee45adbb1caeb914da16f70e557fb7ff6ddc9e4b14de665bd41af631367ef", size = 250905, upload-time = "2026-02-03T14:01:44.163Z" },
- { url = "https://files.pythonhosted.org/packages/d5/f7/5291bcdf498bafbee3796bb32ef6966e9915aebd4d0954123c8eae921c32/coverage-7.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:318002f1fd819bdc1651c619268aa5bc853c35fa5cc6d1e8c96bd9cd6c828b75", size = 252753, upload-time = "2026-02-03T14:01:45.974Z" },
- { url = "https://files.pythonhosted.org/packages/a0/a9/1dcafa918c281554dae6e10ece88c1add82db685be123e1b05c2056ff3fb/coverage-7.13.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:71295f2d1d170b9977dc386d46a7a1b7cbb30e5405492529b4c930113a33f895", size = 250716, upload-time = "2026-02-03T14:01:48.844Z" },
- { url = "https://files.pythonhosted.org/packages/44/bb/4ea4eabcce8c4f6235df6e059fbc5db49107b24c4bdffc44aee81aeca5a8/coverage-7.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5b1ad2e0dc672625c44bc4fe34514602a9fd8b10d52ddc414dc585f74453516c", size = 250530, upload-time = "2026-02-03T14:01:50.793Z" },
- { url = "https://files.pythonhosted.org/packages/6d/31/4a6c9e6a71367e6f923b27b528448c37f4e959b7e4029330523014691007/coverage-7.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b2beb64c145593a50d90db5c7178f55daeae129123b0d265bdb3cbec83e5194a", size = 252186, upload-time = "2026-02-03T14:01:52.607Z" },
- { url = "https://files.pythonhosted.org/packages/27/92/e1451ef6390a4f655dc42da35d9971212f7abbbcad0bdb7af4407897eb76/coverage-7.13.3-cp314-cp314-win32.whl", hash = "sha256:3d1aed4f4e837a832df2f3b4f68a690eede0de4560a2dbc214ea0bc55aabcdb4", size = 222253, upload-time = "2026-02-03T14:01:55.071Z" },
- { url = "https://files.pythonhosted.org/packages/8a/98/78885a861a88de020c32a2693487c37d15a9873372953f0c3c159d575a43/coverage-7.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f9efbbaf79f935d5fbe3ad814825cbce4f6cdb3054384cb49f0c0f496125fa0", size = 223069, upload-time = "2026-02-03T14:01:56.95Z" },
- { url = "https://files.pythonhosted.org/packages/eb/fb/3784753a48da58a5337972abf7ca58b1fb0f1bda21bc7b4fae992fd28e47/coverage-7.13.3-cp314-cp314-win_arm64.whl", hash = "sha256:31b6e889c53d4e6687ca63706148049494aace140cffece1c4dc6acadb70a7b3", size = 221633, upload-time = "2026-02-03T14:01:58.758Z" },
- { url = "https://files.pythonhosted.org/packages/40/f9/75b732d9674d32cdbffe801ed5f770786dd1c97eecedef2125b0d25102dc/coverage-7.13.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c5e9787cec750793a19a28df7edd85ac4e49d3fb91721afcdc3b86f6c08d9aa8", size = 220243, upload-time = "2026-02-03T14:02:01.109Z" },
- { url = "https://files.pythonhosted.org/packages/cf/7e/2868ec95de5a65703e6f0c87407ea822d1feb3619600fbc3c1c4fa986090/coverage-7.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5b86db331c682fd0e4be7098e6acee5e8a293f824d41487c667a93705d415ca", size = 220515, upload-time = "2026-02-03T14:02:02.862Z" },
- { url = "https://files.pythonhosted.org/packages/7d/eb/9f0d349652fced20bcaea0f67fc5777bd097c92369f267975732f3dc5f45/coverage-7.13.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:edc7754932682d52cf6e7a71806e529ecd5ce660e630e8bd1d37109a2e5f63ba", size = 261874, upload-time = "2026-02-03T14:02:04.727Z" },
- { url = "https://files.pythonhosted.org/packages/ee/a5/6619bc4a6c7b139b16818149a3e74ab2e21599ff9a7b6811b6afde99f8ec/coverage-7.13.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3a16d6398666510a6886f67f43d9537bfd0e13aca299688a19daa84f543122f", size = 264004, upload-time = "2026-02-03T14:02:06.634Z" },
- { url = "https://files.pythonhosted.org/packages/29/b7/90aa3fc645a50c6f07881fca4fd0ba21e3bfb6ce3a7078424ea3a35c74c9/coverage-7.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:303d38b19626c1981e1bb067a9928236d88eb0e4479b18a74812f05a82071508", size = 266408, upload-time = "2026-02-03T14:02:09.037Z" },
- { url = "https://files.pythonhosted.org/packages/62/55/08bb2a1e4dcbae384e638f0effef486ba5987b06700e481691891427d879/coverage-7.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:284e06eadfe15ddfee2f4ee56631f164ef897a7d7d5a15bca5f0bb88889fc5ba", size = 260977, upload-time = "2026-02-03T14:02:11.755Z" },
- { url = "https://files.pythonhosted.org/packages/9b/76/8bd4ae055a42d8fb5dd2230e5cf36ff2e05f85f2427e91b11a27fea52ed7/coverage-7.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d401f0864a1d3198422816878e4e84ca89ec1c1bf166ecc0ae01380a39b888cd", size = 263868, upload-time = "2026-02-03T14:02:13.565Z" },
- { url = "https://files.pythonhosted.org/packages/e3/f9/ba000560f11e9e32ec03df5aa8477242c2d95b379c99ac9a7b2e7fbacb1a/coverage-7.13.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3f379b02c18a64de78c4ccdddf1c81c2c5ae1956c72dacb9133d7dd7809794ab", size = 261474, upload-time = "2026-02-03T14:02:16.069Z" },
- { url = "https://files.pythonhosted.org/packages/90/4b/4de4de8f9ca7af4733bfcf4baa440121b7dbb3856daf8428ce91481ff63b/coverage-7.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7a482f2da9086971efb12daca1d6547007ede3674ea06e16d7663414445c683e", size = 260317, upload-time = "2026-02-03T14:02:17.996Z" },
- { url = "https://files.pythonhosted.org/packages/05/71/5cd8436e2c21410ff70be81f738c0dddea91bcc3189b1517d26e0102ccb3/coverage-7.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:562136b0d401992118d9b49fbee5454e16f95f85b120a4226a04d816e33fe024", size = 262635, upload-time = "2026-02-03T14:02:20.405Z" },
- { url = "https://files.pythonhosted.org/packages/e7/f8/2834bb45bdd70b55a33ec354b8b5f6062fc90e5bb787e14385903a979503/coverage-7.13.3-cp314-cp314t-win32.whl", hash = "sha256:ca46e5c3be3b195098dd88711890b8011a9fa4feca942292bb84714ce5eab5d3", size = 223035, upload-time = "2026-02-03T14:02:22.323Z" },
- { url = "https://files.pythonhosted.org/packages/26/75/f8290f0073c00d9ae14056d2b84ab92dff21d5370e464cb6cb06f52bf580/coverage-7.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:06d316dbb3d9fd44cca05b2dbcfbef22948493d63a1f28e828d43e6cc505fed8", size = 224142, upload-time = "2026-02-03T14:02:24.143Z" },
- { url = "https://files.pythonhosted.org/packages/03/01/43ac78dfea8946c4a9161bbc034b5549115cb2b56781a4b574927f0d141a/coverage-7.13.3-cp314-cp314t-win_arm64.whl", hash = "sha256:299d66e9218193f9dc6e4880629ed7c4cd23486005166247c283fb98531656c3", size = 222166, upload-time = "2026-02-03T14:02:26.005Z" },
- { url = "https://files.pythonhosted.org/packages/7d/fb/70af542d2d938c778c9373ce253aa4116dbe7c0a5672f78b2b2ae0e1b94b/coverage-7.13.3-py3-none-any.whl", hash = "sha256:90a8af9dba6429b2573199622d72e0ebf024d6276f16abce394ad4d181bb0910", size = 211237, upload-time = "2026-02-03T14:02:27.986Z" },
+ { url = "https://files.pythonhosted.org/packages/30/70/b052a519a584663a7bd052841a2debe11c8309ec49a7786340003f9c0a02/coverage-7.15.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d0be6daac4cce6b8c8dc65886bae1b082ddbca4da8e5cbb5e15166acf253e264", size = 222245, upload-time = "2026-08-06T13:46:55.253Z" },
+ { url = "https://files.pythonhosted.org/packages/67/39/892fa511aba3d1c3c8f49509a0ff5c71eab9f9f88d08e1a38da395821660/coverage-7.15.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b24e078eabcd6a9caa8b0713f9bc1eeb310bcc960a29d45a3b4fcd4b16d5b11d", size = 222762, upload-time = "2026-08-06T13:46:57.848Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/95/b2c724ce1e64bc23cb5b1d7eeffa9548dc3d811f7a6297b2d01607f4e062/coverage-7.15.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe20cc8cf8821d4fe54f89106cbf06aa27f37b5bbe3535568065a81539b4150", size = 249498, upload-time = "2026-08-06T13:46:59.012Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/4f/b1973f67a1382af65b572a31ed692f8e490a6ad707191eab59148376832a/coverage-7.15.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:83cf06cdd687677742caff1a9134833b7a8b75f111519d2cb0e0ba1b9a851e15", size = 251328, upload-time = "2026-08-06T13:47:00.764Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/09/03efa6722a132abcac91b32a60b64b240dd707c189c64eee697e48992c96/coverage-7.15.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8fa4de68e2a752468ff14b4e15db7def689a71be759e826a31ccecbef69c5fd0", size = 253194, upload-time = "2026-08-06T13:47:01.976Z" },
+ { url = "https://files.pythonhosted.org/packages/45/63/8299201d9c80fb65551ce99c966cab83d706ec4066ac999bef08201346de/coverage-7.15.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4dff9daa47d83120c3ec38ce921214242944a832aa04e903e50b5b7ebac8972d", size = 255106, upload-time = "2026-08-06T13:47:03.281Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/16/26fd8a691eb8d9a230128685f6d23309d7402cb030aa553001788c8c50fc/coverage-7.15.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a093fd37229918976f602aa07aa59e0973cde82186f220c8e197f721f5be0ce4", size = 250177, upload-time = "2026-08-06T13:47:04.713Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/ef/3c7556f33783a0a566e01443ca62bd8eb2cdfe22d271efdc02e08beb5654/coverage-7.15.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:317db01a2cb02552fd67e2b1cca77a4b528a2a277176c5e0bf2cecbb639d3f54", size = 251234, upload-time = "2026-08-06T13:47:06.104Z" },
+ { url = "https://files.pythonhosted.org/packages/29/49/640a34043edac950738f36a3567832db5731d4cb2ed84b59cdb89c6bccbf/coverage-7.15.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:8ee3838dcb656602c3b51e16aed9bfb0822f8d8d6d1c5966d32ec8c104be8e20", size = 249237, upload-time = "2026-08-06T13:47:07.467Z" },
+ { url = "https://files.pythonhosted.org/packages/48/f5/e80f212669dd1be954ff844f883ef11a437ef4fd0089c6e0effc7b66b15d/coverage-7.15.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:425920379052ff1fe465268f3361d35804a241bbdd5a1b592c8cb60df4c52325", size = 253050, upload-time = "2026-08-06T13:47:08.748Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/e9/e5da0fe39f7fde1bca9edc09c60921bb5fdba4cec7db5bbad41ddfd8c230/coverage-7.15.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:69bb2400abef928e365ea7d4d9925169ada78ed2295546780002d4b65de3df88", size = 249508, upload-time = "2026-08-06T13:47:10.072Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/38/41bf25774a0c8bba6b467f917cb1c9a0a2605e02dc93aad489fc7050ed59/coverage-7.15.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:81661f82d302484e3119e7c80c519c02fa9bcc2a6b339baf67d67bc89c580f04", size = 250110, upload-time = "2026-08-06T13:47:11.35Z" },
+ { url = "https://files.pythonhosted.org/packages/89/6e/26f2e54b79acc29d179ee4272922625aedb69198c4eb61f7ff4f098f3c78/coverage-7.15.4-cp310-cp310-win32.whl", hash = "sha256:cb476b2e828ecb71cb6b6a928d23fd20a7ddb501188022dae1c37499149cc338", size = 224294, upload-time = "2026-08-06T13:47:12.753Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/06/9a318fc3ae040d4d6cb2d86101c6aa963fab20899a5c58666adf52cde0ca/coverage-7.15.4-cp310-cp310-win_amd64.whl", hash = "sha256:3fc2130bf37df31852a8384f12601563a45a0024bccc6624f38355cba7a8d360", size = 224919, upload-time = "2026-08-06T13:47:14.17Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/66/edcec7d7a0b524aa8923e22925fde6fe50ce005a113dca13ae1581455c4c/coverage-7.15.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bbac5abad70df71019988f83f26ac7092ff2642975def4429e98dc7585ef3490", size = 222367, upload-time = "2026-08-06T13:47:15.578Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/c6/ab8de429e2e8548faf58ec7e1674a4ce00414b4113942d3fe87109cf0f68/coverage-7.15.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:357a173465c7ce028d07a95cc2b63b5bf59f50ecdd5ad75c5cbb78ada984048e", size = 222874, upload-time = "2026-08-06T13:47:16.961Z" },
+ { url = "https://files.pythonhosted.org/packages/be/c4/3b7b49587e8a6b9af79b3eb468d443d6042b6d65b47aa26586846a0d6566/coverage-7.15.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21b803935e2efc3acebe9697197a294fccf5dc4e5382bd6369542ff7a7d2a1d7", size = 253287, upload-time = "2026-08-06T13:47:18.291Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/65/ec03b743a2a229c72cc1eff3e57be9d3564e9c6b4d5aba2d70744a3fc0d8/coverage-7.15.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a2b580774a4786c1053157c0165e04476e03ff293993d7c148eee784a94bae6", size = 255199, upload-time = "2026-08-06T13:47:19.765Z" },
+ { url = "https://files.pythonhosted.org/packages/41/4b/5163729e4b6582d61975cfd3ccab45b4ec53e21cf156d9941cb025188468/coverage-7.15.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9464451c4efffe8d47ace5a540b10b0dc10e879066290f8600872b7f54a419d", size = 257308, upload-time = "2026-08-06T13:47:21.206Z" },
+ { url = "https://files.pythonhosted.org/packages/86/08/2167a0f08fb87d702fa423a48578a32865464b7c9e1db3911ad7812ab414/coverage-7.15.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de602f34123c2f4af1c1869c6dbbbd60da6d5983bf01937367295d135cccbfce", size = 259268, upload-time = "2026-08-06T13:47:22.503Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/e5/68eebae3053dbd48508edea559c21b23fbdf3460784f91370c83a86a6acd/coverage-7.15.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6879ded16a27f3eeca19b900c147e81616e7054db451471a611b2755ee5249f7", size = 253392, upload-time = "2026-08-06T13:47:23.88Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/46/fd4ced40a2b691c774e515c9b69500bfa64c7960b67fcee4b2f6fad97fc3/coverage-7.15.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:986be58c3ab54aae8d3496a6225eea74f760fdbe739b38bd442c7e8d133aa53b", size = 255001, upload-time = "2026-08-06T13:47:25.469Z" },
+ { url = "https://files.pythonhosted.org/packages/53/25/ae2e5fa710bb6957a9aadeb9e3598d3b3e4af6587ce857ad42e8639a3f30/coverage-7.15.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6103639613fe6c1e989082948419bc77a2d26b6c825c99d7fad25f7d3d87afc", size = 253061, upload-time = "2026-08-06T13:47:26.845Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/31/67ddc0365db2c6e93ac8580bc4bbc50f65273262f973f63ebcdbc15c0495/coverage-7.15.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d3af93dddb5659276c63bc16ac6466ac2033a70ca816097bbc06345b8ccdf571", size = 256831, upload-time = "2026-08-06T13:47:28.217Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/78/82b8fd18f57fb13f12d98fe874995bb2c4f9f17be8aff762c426323fdb96/coverage-7.15.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b10075e5421d04265766a6d1dac809bbeb8a946fbb23c8f82c227409b2190719", size = 252781, upload-time = "2026-08-06T13:47:29.712Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/eb/6c74ef4dd12b252e573c49bdef9e2ac265bf3dbb79b8d7feb3266e084e9e/coverage-7.15.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a67a9f78b2942d87ba8ce3059c642164d2aedd65337377fb52fe9803656bc5c7", size = 253692, upload-time = "2026-08-06T13:47:31.192Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/66/eb9aed1c3fd2d36ee00eb173f434b14fa607fc056739c9a89ff4244010ea/coverage-7.15.4-cp311-cp311-win32.whl", hash = "sha256:69484d1aca26e322e1c3ce03f09341e84524ababad2d7202161738d83cc9f82e", size = 224461, upload-time = "2026-08-06T13:47:32.572Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/6d/81fa4161dfb3ed9d74e40d58647eff83a56b7612e78352581280fce2f477/coverage-7.15.4-cp311-cp311-win_amd64.whl", hash = "sha256:63fd6fcd1dd6e158f7eb78606e72933b3f6d01e7b747f99c6c12d764307a0fdc", size = 224937, upload-time = "2026-08-06T13:47:34.205Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/c1/d8dacf683c6cad3cf85ce68fd3774a6774ec402128822fdfaed920f11e6a/coverage-7.15.4-cp311-cp311-win_arm64.whl", hash = "sha256:ea82116c9893fa89e929b7f197ee5a1950a76e91cc5c85ba503fc02379d04890", size = 224479, upload-time = "2026-08-06T13:47:36.118Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" },
+ { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" },
+ { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" },
+ { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" },
+ { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" },
+ { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" },
+ { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" },
+ { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" },
+ { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" },
+ { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" },
+ { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" },
+ { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" },
+ { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" },
+ { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" },
+ { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" },
+ { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" },
+ { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" },
+ { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" },
+ { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" },
+ { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" },
+ { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" },
+ { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" },
+ { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" },
+ { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" },
+ { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" },
+ { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" },
+ { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" },
+ { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" },
+ { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z" },
+ { url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z" },
+ { url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z" },
+ { url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z" },
+ { url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z" },
+ { url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z" },
+ { url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z" },
+ { url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z" },
+ { url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z" },
+ { url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z" },
+ { url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z" },
+ { url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z" },
+ { url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" },
]
[package.optional-dependencies]
@@ -153,11 +182,11 @@ dev = [
[package.metadata.requires-dev]
dev = [
- { name = "basedpyright", specifier = ">=1.37.1" },
+ { name = "basedpyright", specifier = ">=1.39.10" },
{ name = "inline-snapshot", specifier = ">=0.35.4" },
- { name = "pytest", specifier = ">=9.0.2" },
- { name = "pytest-cov", specifier = ">=7.0.0" },
- { name = "ruff", specifier = ">=0.14.11" },
+ { name = "pytest", specifier = ">=9.1.1" },
+ { name = "pytest-cov", specifier = ">=7.1.0" },
+ { name = "ruff", specifier = ">=0.16.3" },
]
[[package]]
@@ -230,27 +259,27 @@ wheels = [
[[package]]
name = "nodejs-wheel-binaries"
-version = "24.13.0"
+version = "24.16.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b7/f1/73182280e2c05f49a7c2c8dbd46144efe3f74f03f798fb90da67b4a93bbf/nodejs_wheel_binaries-24.13.0.tar.gz", hash = "sha256:766aed076e900061b83d3e76ad48bfec32a035ef0d41bd09c55e832eb93ef7a4", size = 8056, upload-time = "2026-01-14T11:05:33.653Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/22/2a5beb4e21417c73233d9f65cf6f3e96e891b80d2f550a8f630ebc6b88c6/nodejs_wheel_binaries-24.16.0.tar.gz", hash = "sha256:c973cb69dc5fd16e6f6dc6e579e2c3d5534e2a1f57619dddf5ba070efa7dde37", size = 8056, upload-time = "2026-05-30T16:52:09.807Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c4/dc/4d7548aa74a5b446d093f03aff4fb236b570959d793f21c9c42ab6ad870a/nodejs_wheel_binaries-24.13.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:356654baa37bfd894e447e7e00268db403ea1d223863963459a0fbcaaa1d9d48", size = 55133268, upload-time = "2026-01-14T11:05:05.335Z" },
- { url = "https://files.pythonhosted.org/packages/24/8a/8a4454d28339487240dd2232f42f1090e4a58544c581792d427f6239798c/nodejs_wheel_binaries-24.13.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:92fdef7376120e575f8b397789bafcb13bbd22a1b4d21b060d200b14910f22a5", size = 55314800, upload-time = "2026-01-14T11:05:09.121Z" },
- { url = "https://files.pythonhosted.org/packages/e7/fb/46c600fcc748bd13bc536a735f11532a003b14f5c4dfd6865f5911672175/nodejs_wheel_binaries-24.13.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:3f619ac140e039ecd25f2f71d6e83ad1414017a24608531851b7c31dc140cdfd", size = 59666320, upload-time = "2026-01-14T11:05:12.369Z" },
- { url = "https://files.pythonhosted.org/packages/85/47/d48f11fc5d1541ace5d806c62a45738a1db9ce33e85a06fe4cd3d9ce83f6/nodejs_wheel_binaries-24.13.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:dfb31ebc2c129538192ddb5bedd3d63d6de5d271437cd39ea26bf3fe229ba430", size = 60162447, upload-time = "2026-01-14T11:05:16.003Z" },
- { url = "https://files.pythonhosted.org/packages/b1/74/d285c579ae8157c925b577dde429543963b845e69cd006549e062d1cf5b6/nodejs_wheel_binaries-24.13.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fdd720d7b378d5bb9b2710457bbc880d4c4d1270a94f13fbe257198ac707f358", size = 61659994, upload-time = "2026-01-14T11:05:19.68Z" },
- { url = "https://files.pythonhosted.org/packages/ba/97/88b4254a2ff93ed2eaed725f77b7d3d2d8d7973bf134359ce786db894faf/nodejs_wheel_binaries-24.13.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ad6383613f3485a75b054647a09f1cd56d12380d7459184eebcf4a5d403f35c", size = 62244373, upload-time = "2026-01-14T11:05:23.987Z" },
- { url = "https://files.pythonhosted.org/packages/4e/c3/0e13a3da78f08cb58650971a6957ac7bfef84164b405176e53ab1e3584e2/nodejs_wheel_binaries-24.13.0-py2.py3-none-win_amd64.whl", hash = "sha256:605be4763e3ef427a3385a55da5a1bcf0a659aa2716eebbf23f332926d7e5f23", size = 41345528, upload-time = "2026-01-14T11:05:27.67Z" },
- { url = "https://files.pythonhosted.org/packages/a3/f1/0578d65b4e3dc572967fd702221ea1f42e1e60accfb6b0dd8d8f15410139/nodejs_wheel_binaries-24.13.0-py2.py3-none-win_arm64.whl", hash = "sha256:2e3431d869d6b2dbeef1d469ad0090babbdcc8baaa72c01dd3cc2c6121c96af5", size = 39054688, upload-time = "2026-01-14T11:05:30.739Z" },
+ { url = "https://files.pythonhosted.org/packages/83/d1/68b43b53cd0fa83ae6fd406705023ca988d9e0ca41c724d82e66fbeb2ef6/nodejs_wheel_binaries-24.16.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:d9f8f677dcf30e37ac244f07869726abe043f01eb0f45722b1df31cc2af7093c", size = 55666374, upload-time = "2026-05-30T16:51:39.588Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/b2/40a989159599080da485de966c4c2d207e852ac7aa7864702626d96c8bf5/nodejs_wheel_binaries-24.16.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:3d0370fe7120ce9697a4f60d40480d2bd8808d9f30131458d5afc0040d4e5a51", size = 55838487, upload-time = "2026-05-30T16:51:43.383Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/a7/cd42174fb5ff6faff7fa8d326a18914d8f232098ab5de055b57c16fa13ca/nodejs_wheel_binaries-24.16.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:85dc92bbb79c851569c5925dcc2a4c915a034efab375f99e4e7e6bbe9cca8342", size = 60179540, upload-time = "2026-05-30T16:51:47.036Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/95/c8a1f9ae140aa28df8744d984d01d4b3af7cdd6555af12127f40ceb45a7d/nodejs_wheel_binaries-24.16.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:2f3036292811514ba847b3708492644764f88a833ac425c5f55007014308ddfd", size = 60716262, upload-time = "2026-05-30T16:51:50.711Z" },
+ { url = "https://files.pythonhosted.org/packages/64/c9/7c35b3737f59e36d0249c265397b7bff570519b95301d6e16ea361e904ad/nodejs_wheel_binaries-24.16.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:db8a8a76ebd2b28ecbfc9ad464baa3707241b9e050a30e2efdf6f60c0f886502", size = 62230592, upload-time = "2026-05-30T16:51:55Z" },
+ { url = "https://files.pythonhosted.org/packages/04/96/d931255cf9d11a84d6b54d882dba7434646467d568ccf070ea3418638df3/nodejs_wheel_binaries-24.16.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f1a3d8f7b4491cbbd023ba3fc4e901fcca2d9fb80d57f24ba3890de8b1dbac03", size = 62841759, upload-time = "2026-05-30T16:51:59.407Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/7b/8b7a3f41bc255411be30b6d7d288aab8ffd9ea2055db8555ced3548007b9/nodejs_wheel_binaries-24.16.0-py2.py3-none-win_amd64.whl", hash = "sha256:bb136be9944f0662dcf1120f45193a6b75b13fac378971a95cc42c9f879a81aa", size = 42027734, upload-time = "2026-05-30T16:52:03.348Z" },
+ { url = "https://files.pythonhosted.org/packages/17/66/1ed71f1f529b8ca727d42c7ceb9db0bef145ce4a13dfc86fb50aa44f3be6/nodejs_wheel_binaries-24.16.0-py2.py3-none-win_arm64.whl", hash = "sha256:8308940b5edd0a50dc5267ea36ba21c9f668e83fe0d9f293937174d3a7e31c36", size = 39714528, upload-time = "2026-05-30T16:52:06.421Z" },
]
[[package]]
name = "packaging"
-version = "26.0"
+version = "26.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
+ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
@@ -264,16 +293,16 @@ wheels = [
[[package]]
name = "pygments"
-version = "2.19.2"
+version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
+ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
-version = "9.0.2"
+version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -284,23 +313,23 @@ dependencies = [
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
+ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-cov"
-version = "7.0.0"
+version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage", extra = ["toml"] },
{ name = "pluggy" },
{ name = "pytest" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
@@ -318,89 +347,88 @@ wheels = [
[[package]]
name = "ruff"
-version = "0.14.14"
+version = "0.16.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" },
- { url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" },
- { url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" },
- { url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" },
- { url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" },
- { url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" },
- { url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" },
- { url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" },
- { url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" },
- { url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" },
- { url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" },
- { url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" },
- { url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" },
- { url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" },
- { url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" },
- { url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" },
- { url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" },
- { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" },
+ { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" },
+ { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" },
+ { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" },
+ { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" },
+ { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" },
+ { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" },
+ { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" },
+ { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" },
+ { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" },
]
[[package]]
name = "tomli"
-version = "2.4.0"
+version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" },
- { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" },
- { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" },
- { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" },
- { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" },
- { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" },
- { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" },
- { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" },
- { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" },
- { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" },
- { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" },
- { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" },
- { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" },
- { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" },
- { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" },
- { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" },
- { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" },
- { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" },
- { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" },
- { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" },
- { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" },
- { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" },
- { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" },
- { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" },
- { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" },
- { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" },
- { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" },
- { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" },
- { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" },
- { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" },
- { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" },
- { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" },
- { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" },
- { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" },
- { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" },
- { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" },
- { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" },
- { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" },
- { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" },
- { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" },
- { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" },
- { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" },
- { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" },
- { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" },
- { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" },
- { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
+ { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
+ { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
+ { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
+ { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
+ { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
+ { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
+ { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
+ { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
+ { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
+ { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
+ { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
+ { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
+ { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
+ { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
+ { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
+ { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
+ { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
+ { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
+ { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
+ { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
+ { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
+ { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
+ { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
+ { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
+ { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
]
[[package]]
name = "typing-extensions"
-version = "4.15.0"
+version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
From eddcd6a22d969d11938c6dd95d8a96a1d4d0c062 Mon Sep 17 00:00:00 2001
From: Graham Preston <16179442+grahamcracker1234@users.noreply.github.com>
Date: Tue, 18 Aug 2026 09:38:48 -0400
Subject: [PATCH 05/15] Let closing quotes control trailing newlines
Remove the strip modes so dedent omits only the opening newline and preserves all remaining whitespace, matching PEP 822 quote-placement semantics.
---
README.md | 114 +++++++++---------------------
src/dedent/__init__.py | 6 +-
src/dedent/_dedent.py | 82 ++++++++--------------
tests/test_dedent.py | 155 ++++++++++++++++++-----------------------
4 files changed, 133 insertions(+), 224 deletions(-)
diff --git a/README.md b/README.md
index bf76641..688fae4 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
What [`textwrap.dedent`](https://docs.python.org/3/library/textwrap.html#textwrap.dedent) should have been.
-Dedent and strip multiline strings, and keep interpolated values alignedβwithout manual whitespace wrangling. Supports t-strings (Python 3.14+) and f-strings (Python 3.10+).
+Dedent multiline strings and keep interpolated values aligned. Supports t-strings (Python 3.14+) and f-strings (Python 3.10+).
> For documentation on how to use dedent with f-strings in Python 3.10-3.13, see [Legacy Support](#legacy-support-python-310-313).
@@ -13,7 +13,6 @@ Dedent and strip multiline strings, and keep interpolated values alignedβwitho
- [Installation](#installation)
- [Options](#options)
- [`align`](#align)
- - [`strip`](#strip)
- [Legacy Support (Python 3.10-3.13)](#legacy-support-python-310-313)
- [Why `textwrap.dedent` Falls Short](#why-textwrapdedent-falls-short)
@@ -34,8 +33,7 @@ print(greeting)
# Nested multiline strings align correctly with :align
items = dedent("""
- apples
- - bananas
- """)
+ - bananas""")
shopping_list = dedent(t"""
Groceries:
{items:align}
@@ -66,15 +64,30 @@ result = dedent("""
World!
""")
-assert result == " Hello\n World!"
+assert result == " Hello\n World!\n"
+```
+
+Keep the closing quotes at the indentation you want removed. Their placement also controls the
+final newline:
+
+```python
+with_newline = dedent("""
+ Hello
+ World!
+ """)
+without_newline = dedent("""
+ Hello
+ World!""")
+
+assert with_newline == "Hello\nWorld!\n"
+assert without_newline == "Hello\nWorld!"
```
-Keep the closing quotes at the indentation you want removed. A trailing `\n` creates an empty
-closing line at column zero, so it preserves the content indentation:
+A final `\n` creates an empty closing line at column zero. This line preserves content indentation:
```python
-assert dedent("\n Hello\n") == " Hello"
-assert dedent("\n Hello\n ") == "Hello"
+assert dedent("\n Hello\n") == " Hello\n"
+assert dedent("\n Hello\n ") == "Hello\n"
```
### Why `IndentationError`?
@@ -93,10 +106,12 @@ mixed or malformed indentation instead of silently changing or preserving ambigu
A shorter space/tab-only line is valid when it matches the beginning of the prefix; it simply
becomes empty. This mirrors PEP 822.
-With the default `strip="smart"`, the usual opening newline and closing-quotes line are removed
-from the result. Unlike native d-strings, `dedent()` runs after Python parses a string, so escape
-sequences have already been processed. For t-strings, literal text is dedented before
-interpolations are rendered.
+`dedent()` omits one opening newline, as PEP 822 requires. It preserves all other whitespace.
+Use the closing-quote placement when you do not want a final newline.
+
+Unlike native d-strings, `dedent()` runs after Python parses a string. Thus, Python has already
+processed escape sequences. For t-strings, `dedent()` processes literal text before it renders
+interpolations.
For runtime strings that use CRLF, `dedent` preserves each `\r\n` pair and excludes the terminal
`\r` from indentation checks. Other whitespace characters, such as form feed and non-breaking
@@ -133,8 +148,7 @@ from dedent import dedent
items = dedent("""
- one
- - two
- """)
+ - two""")
result = dedent(t"""
Aligned:
@@ -167,8 +181,7 @@ from dedent import dedent
items = dedent("""
- one
- - two
- """)
+ - two""")
result = dedent(
t"""
@@ -191,64 +204,6 @@ print(result)
# ---
```
-### `strip`
-
-The `strip` parameter controls how leading and trailing whitespace is removed after dedenting. It accepts three modes:
-
-#### `"smart"` (default)
-
-Strips one leading and trailing newline-bounded blank segment. Handles the common case of triple-quoted strings that start and end with a newline.
-
-```python
-from dedent import dedent
-
-result = dedent("""
- hello!
- """)
-
-print(repr(result))
-# 'hello!'
-```
-
-#### `"all"`
-
-Strips all surrounding whitespace, equivalent to calling `.strip()` on the result. Use when the string may have extra blank lines you want removed.
-
-```python
-from dedent import dedent
-
-result = dedent(
- """
-
-
- hello!
-
-
- """,
- strip="all",
-)
-print(repr(result))
-# 'hello!'
-```
-
-#### `"none"`
-
-Leaves whitespace exactly as-is after dedenting. Use when you need to preserve exact whitespace, e.g. for diff output or tests.
-
-```python
-from dedent import dedent
-
-result = dedent(
- """
- hello!
- """,
- strip="none",
-)
-
-print(repr(result))
-# '\nhello!\n'
-```
-
## Legacy Support (Python 3.10-3.13)
On Python 3.10-3.13, t-strings are not available. Use `dedent()` on plain strings and f-strings, and wrap interpolated values with `align()` to get multiline indentation alignment.
@@ -268,8 +223,7 @@ print(message)
# Use align() inside f-strings for multiline value alignment
items = dedent("""
- apples
- - bananas
- """)
+ - bananas""")
shopping_list = dedent(f"""
Groceries:
{align(items)}
@@ -376,7 +330,8 @@ shopping_list = dedent(
)
```
-`dedent` solves these problems and more:
+`dedent` uses closing-quote placement to control the final newline. Put the closing quotes after
+the final item when nested content must not end with a newline:
```python
from dedent import dedent
@@ -384,8 +339,7 @@ from dedent import dedent
groceries = dedent("""
- apples
- bananas
- - cherries
- """)
+ - cherries""")
shopping_list = dedent(t"""
Groceries:
diff --git a/src/dedent/__init__.py b/src/dedent/__init__.py
index b89cbeb..36c6652 100644
--- a/src/dedent/__init__.py
+++ b/src/dedent/__init__.py
@@ -2,13 +2,13 @@
A library for dedenting text with support for interpolated values.
This module provides a `dedent` function that removes common leading whitespace from
-multiline strings, similar to `textwrap.dedent` but with automatic smart stripping and
-multiline-value alignment. Works with Python 3.10+.
+multiline strings, follows PEP 822's newline behavior, and aligns multiline values.
+Works with Python 3.10+.
Key features:
- Uses PEP 822's exact common-prefix and closing-line indentation rules
-- Smart-strips leading/trailing whitespace by default
+- Omits the opening newline and preserves all other whitespace
- Aligns multiline interpolated values to their interpolation column
- On Python 3.14+: t-strings with per-value format spec directives (`{value:align}`)
- On Python 3.10-3.13: f-strings with the `align` wrapper
diff --git a/src/dedent/_dedent.py b/src/dedent/_dedent.py
index 8d9d47b..bf5b41f 100644
--- a/src/dedent/_dedent.py
+++ b/src/dedent/_dedent.py
@@ -4,7 +4,7 @@
from dataclasses import dataclass, field
from enum import Enum
from itertools import filterfalse, tee
-from typing import TYPE_CHECKING, Final, Literal, TypeVar, final
+from typing import TYPE_CHECKING, Final, TypeVar, final
from uuid import uuid4
@@ -14,7 +14,6 @@ class Missing:
MISSING: Final = Missing()
-DEFAULT_STRIP: Final = "smart"
DEFAULT_ALIGN: Final = False
@@ -25,12 +24,8 @@ class AlignSpec(str, Enum):
NOALIGN = "noalign"
-Strip = Literal["smart", "all", "none"]
_T = TypeVar("_T")
-_SMART_STRIP_START: Final = re.compile(r"^[^\S\r\n]*(?:\r\n|[\r\n])?")
-_SMART_STRIP_END: Final = re.compile(r"(?:\r\n|[\r\n])?[^\S\r\n]*\Z")
-
_ALIGN_MARKER_PREFIX: Final = "DEDENT_ALIGN"
_HOLE_MARKER_PREFIX: Final = "DEDENT_HOLE"
_SEP: Final = "\x00"
@@ -62,25 +57,21 @@ def _align_value(value: str, preceding_text: str) -> str:
return value
-def _strip_string(string: str, strip: Strip) -> str:
+def _omit_opening_newline(string: str) -> str:
"""
- Strip leading and trailing whitespace from a string.
+ Omit the newline immediately following opening triple quotes.
Args:
- string: The string to strip.
- strip: The strip mode to use.
+ string: The dedented string.
Returns:
- The stripped string.
+ The string with one initial line ending removed, if present.
"""
- match strip:
- case "smart":
- string = _SMART_STRIP_START.sub("", string, count=1)
- return _SMART_STRIP_END.sub("", string, count=1)
- case "all":
- return string.strip()
- case "none":
- return string
+ if string.startswith("\r\n"):
+ return string[2:]
+ if string.startswith(("\r", "\n")):
+ return string[1:]
+ return string
def _dedent_string(string: str) -> str:
@@ -193,8 +184,7 @@ def align(value: object) -> Aligned:
items = dedent(\"\"\"
- apples
- - bananas
- \"\"\")
+ - bananas\"\"\")
shopping_list = dedent(f\"\"\"
Groceries:
{align(items)}
@@ -323,7 +313,6 @@ def _dedent_and_fill(
literals: list[str],
holes: list[_T],
render: Callable[[_T, str], str],
- strip: Strip,
) -> str:
"""
Dedent literal structure, then render its opaque holes in one linear pass.
@@ -332,14 +321,14 @@ def _dedent_and_fill(
RuntimeError: If hole metadata is inconsistent with the literal framework.
Returns:
- The dedented, rendered, and stripped string.
+ The dedented and rendered string.
"""
if len(literals) != len(holes) + 1:
message = "literal and hole counts are inconsistent"
raise RuntimeError(message)
if not holes:
- return _strip_string(_dedent_string(literals[0]), strip)
+ return _omit_opening_newline(_dedent_string(literals[0]))
marker_id = uuid4().hex
framework_parts = [literals[0]]
@@ -348,7 +337,7 @@ def _dedent_and_fill(
token = f"{_SEP}{_HOLE_MARKER_PREFIX}:{marker_id}:{index}{_SEP}"
framework_parts.extend((token, literal))
- framework = _dedent_string("".join(framework_parts))
+ framework = _omit_opening_newline(_dedent_string("".join(framework_parts)))
hole_marker = re.compile(rf"{_SEP}{_HOLE_MARKER_PREFIX}:{marker_id}:(\d+){_SEP}")
dedented_literals: list[str] = []
last_end = 0
@@ -361,10 +350,10 @@ def _dedent_and_fill(
last_end = match.end()
dedented_literals.append(framework[last_end:])
- return _strip_string(_fill_parts(dedented_literals, holes, render), strip)
+ return _fill_parts(dedented_literals, holes, render)
-def _dedent_marked_string(string: str, strip: Strip) -> str:
+def _dedent_marked_string(string: str) -> str:
"""
Dedent static text without letting aligned values affect its indentation.
@@ -372,7 +361,7 @@ def _dedent_marked_string(string: str, strip: Strip) -> str:
The dedented string with deferred values restored and aligned.
"""
literals, values = _split_align_markers(string)
- return _dedent_and_fill(literals, values, _render_align_marker, strip)
+ return _dedent_and_fill(literals, values, _render_align_marker)
if sys.version_info >= (3, 14):
@@ -463,7 +452,7 @@ def _handle_item(
return value
- def _dedent_template(template: Template, *, align: bool, strip: Strip) -> str:
+ def _dedent_template(template: Template, *, align: bool) -> str:
"""
Dedent template literals before rendering their interpolations.
@@ -484,17 +473,16 @@ def _dedent_template(template: Template, *, align: bool, strip: Strip) -> str:
def render_interpolation(item: Interpolation[object], preceding_text: str) -> str:
return _handle_item(item, preceding_text=preceding_text, align=align)
- return _dedent_and_fill(literals, interpolations, render_interpolation, strip)
+ return _dedent_and_fill(literals, interpolations, render_interpolation)
def dedent( # pyright: ignore[reportUnreachable]
string: Template | LiteralString,
/,
*,
align: bool | Missing = MISSING,
- strip: Strip | Missing = MISSING,
) -> str:
r'''
- Dedent, strip, and align a template string.
+ Dedent and align a template string.
This function removes the longest exact indentation prefix shared by all nonblank lines,
preserving relative indentation. A final whitespace-only line constrains the prefix like
@@ -511,18 +499,13 @@ def dedent( # pyright: ignore[reportUnreachable]
align: Whether multiline interpolated values start each line in the column where the
interpolation begins. Defaults to False. Can be overridden per value using format
spec directives.
- strip: Stripping mode to use.
- - "smart" (default): Strips one leading and trailing newline-bounded blank segment.
- - "all": Strips all surrounding whitespace.
- - "none": Leaves whitespace exactly as-is after dedenting.
-
Raises:
IndentationError: If a line is incompatible with the common indentation prefix.
TypeError: If the input is not a string or Template object.
Returns:
- The dedented string with common leading whitespace removed, stripped according to
- the `strip` mode.
+ The string with common leading whitespace and its opening newline removed. All other
+ whitespace is preserved.
Example::
@@ -534,13 +517,12 @@ def dedent( # pyright: ignore[reportUnreachable]
Hello, World!
''' # ruff: ignore[docstring-extraneous-exception]
align = align if not isinstance(align, Missing) else DEFAULT_ALIGN
- strip = strip if not isinstance(strip, Missing) else DEFAULT_STRIP
match string:
case str() as formatted_string:
- return _dedent_marked_string(formatted_string, strip)
+ return _dedent_marked_string(formatted_string)
case Template() as template:
- return _dedent_template(template, align=align, strip=strip)
+ return _dedent_template(template, align=align)
case unknown if not TYPE_CHECKING: # pyright: ignore[reportUnnecessaryComparison]
message = f"expected str or Template, not {type(unknown).__qualname__!r}" # pyright: ignore[reportUnreachable]
raise TypeError(message)
@@ -550,11 +532,9 @@ def dedent( # pyright: ignore[reportUnreachable]
def dedent( # pyright: ignore[reportUnreachable]
string: str,
/,
- *,
- strip: Strip | Missing = MISSING,
) -> str:
r'''
- Dedent and strip a string, with optional multiline-value alignment.
+ Dedent a string and align marked multiline values.
This function removes the longest exact indentation prefix shared by all nonblank lines,
preserving relative indentation. A final whitespace-only line constrains the prefix like
@@ -563,17 +543,13 @@ def dedent( # pyright: ignore[reportUnreachable]
Args:
string: String to dedent.
- strip: Stripping mode to use.
- - "smart" (default): Strips one leading and trailing newline-bounded blank segment.
- - "all": Strips all surrounding whitespace.
- - "none": Leaves whitespace exactly as-is after dedenting.
-
Raises:
IndentationError: If a line is incompatible with the common indentation prefix.
TypeError: If the input is not a string.
Returns:
- The dedented string with common leading whitespace removed.
+ The string with common leading whitespace and its opening newline removed. All other
+ whitespace is preserved.
Example::
@@ -588,10 +564,8 @@ def dedent( # pyright: ignore[reportUnreachable]
- a
- b
''' # ruff: ignore[docstring-extraneous-exception]
- strip = strip if not isinstance(strip, Missing) else DEFAULT_STRIP
-
if not isinstance(string, str): # pyright: ignore[reportUnnecessaryIsInstance]
message = f"expected str, not {type(string).__qualname__!r}"
raise TypeError(message) # pyright: ignore[reportUnreachable]
- return _dedent_marked_string(string, strip)
+ return _dedent_marked_string(string)
diff --git a/tests/test_dedent.py b/tests/test_dedent.py
index ff67334..72243c1 100644
--- a/tests/test_dedent.py
+++ b/tests/test_dedent.py
@@ -1,15 +1,13 @@
"""Tests for `dedent`."""
import sys
-from typing import Literal, cast
+from typing import cast
import pytest
from inline_snapshot import snapshot
from dedent import align, dedent
-StripMode = Literal["smart", "all", "none"]
-
required_py314 = pytest.mark.skipif(sys.version_info < (3, 14), reason="requires Python 3.14+")
if sys.version_info >= (3, 14):
@@ -39,25 +37,25 @@ def dedent_f(string: str) -> str:
class TestDedent:
@staticmethod
def test_uses_exact_common_indentation_prefix() -> None:
- assert dedent(" \tfirst\n \t\tsecond", strip="none") == snapshot("""\
+ assert dedent(" \tfirst\n \t\tsecond") == snapshot("""\
first
second\
""")
- assert dedent(" first\n\tsecond", strip="none") == snapshot("""\
+ assert dedent(" first\n\tsecond") == snapshot("""\
first
second\
""")
@staticmethod
def test_closing_line_constrains_indentation() -> None:
- assert dedent("\n first\n ") == snapshot(" first")
- assert dedent("\n first\n ") == snapshot("first")
+ assert dedent("\n first\n ") == snapshot(" first\n")
+ assert dedent("\n first\n ") == snapshot("first\n")
@staticmethod
def test_column_zero_closer_preserves_indentation() -> None:
assert dedent("\n first\n second\n") == snapshot("""\
first
- second\
+ second
""")
assert dedent("\n first\n second") == snapshot("""\
first
@@ -69,27 +67,22 @@ def test_ignores_compatible_whitespace_only_lines() -> None:
assert dedent("\n first\n \n second\n ") == snapshot("""\
first
-second\
+second
""")
@staticmethod
def test_preserves_crlf_when_ignoring_blank_lines() -> None:
- assert dedent(" first\r\n\r\n second\r\n ", strip="none") == snapshot(
- "first\r\n\r\nsecond\r\n"
- )
+ assert dedent(" first\r\n\r\n second\r\n ") == snapshot("first\r\n\r\nsecond\r\n")
@staticmethod
- def test_smart_strip_removes_complete_crlf_boundary() -> None:
- assert dedent(" first\r\n ") == snapshot("first")
+ def test_preserves_complete_crlf_boundary() -> None:
+ assert dedent("\r\n first\r\n ") == snapshot("first\r\n")
@pytest.mark.parametrize("whitespace", ["\f", "\v", "\N{NO-BREAK SPACE}"])
@staticmethod
def test_non_indentation_whitespace_is_content(whitespace: str) -> None:
string = f" first\n{whitespace}\n second\n "
- assert (
- dedent(string, strip="none") # pyright: ignore[reportArgumentType]: runtime contract
- == string
- )
+ assert dedent(string) == string # pyright: ignore[reportArgumentType]: runtime contract
@pytest.mark.parametrize(
"string",
@@ -112,7 +105,7 @@ def test_removes_common_indent() -> None:
""") == snapshot("""\
first
second
- third\
+ third
""")
@staticmethod
@@ -124,7 +117,7 @@ def test_min_indent_from_least_indented_line() -> None:
""") == snapshot("""\
first
second
- third\
+ third
""")
@staticmethod
@@ -132,7 +125,7 @@ def test_tabs() -> None:
assert dedent("\n\t\tfirst\n\t\t\tsecond\n\t\t\t\tthird\n\t\t") == snapshot("""\
first
second
- third\
+ third
""")
@staticmethod
@@ -151,7 +144,10 @@ def test_escaped_newlines_are_literal() -> None:
def test_explicit_newline_is_kept() -> None:
assert dedent("""
Hello world!
\n
- """) == snapshot("Hello world!
\n")
+ """) == snapshot("""\
+Hello world!
+
+""")
@staticmethod
def test_source_line_continuation() -> None:
@@ -161,7 +157,7 @@ def test_source_line_continuation() -> None:
third
""") == snapshot("""\
first second
-third\
+third
""")
@staticmethod
@@ -174,37 +170,19 @@ def test_rejects_non_string() -> None:
_ = dedent(123) # pyright: ignore[reportArgumentType]
-class TestStrip:
+class TestBoundaries:
@staticmethod
- def test_default_is_smart() -> None:
- assert dedent(TYPICAL) == dedent(TYPICAL, strip="smart")
+ def test_closing_quotes_on_own_line_preserve_trailing_newline() -> None:
+ assert dedent(TYPICAL) == snapshot("foo\nbar\n")
- @pytest.mark.parametrize(
- ("strip", "expected"),
- [
- (
- "smart",
- snapshot("""\
-foo
-bar\
-"""),
- ),
- (
- "none",
- snapshot("""\
-
-foo
-bar
-"""),
- ),
- ],
- )
@staticmethod
- def test_typical(strip: StripMode, expected: str) -> None:
- assert dedent(TYPICAL, strip=strip) == expected
+ def test_closing_quotes_after_content_omit_trailing_newline() -> None:
+ assert dedent("""
+ foo
+ bar""") == snapshot("foo\nbar")
@staticmethod
- def test_smart_strips_one_blank_segment() -> None:
+ def test_omits_only_opening_newline() -> None:
assert dedent("""
@@ -214,11 +192,11 @@ def test_smart_strips_one_blank_segment() -> None:
foo
-bar\
+bar
""")
@staticmethod
- def test_smart_keeps_trailing_spaces_on_content() -> None:
+ def test_keeps_trailing_spaces_on_content() -> None:
assert dedent(
"""
foo---
@@ -226,24 +204,11 @@ def test_smart_keeps_trailing_spaces_on_content() -> None:
""".replace("-", " ")
) == snapshot("""\
foo \n\
-bar \
+bar \n\
""")
@staticmethod
- def test_all_strips_surrounding_whitespace() -> None:
- padded = """---
-
- foo
- bar
-
- ---""".replace("-", " ")
- assert dedent(padded, strip="all") == snapshot("""\
-foo
-bar\
-""")
-
- @staticmethod
- def test_smart_keeps_whitespace_only_lines() -> None:
+ def test_keeps_whitespace_only_lines() -> None:
assert dedent(
"""
---
@@ -255,7 +220,7 @@ def test_smart_keeps_whitespace_only_lines() -> None:
\n\
foo
bar
- \
+ \n\
""")
@@ -268,7 +233,7 @@ def test_fstring() -> None:
second
""") == snapshot("""\
first line
-second\
+second
""")
@required_py314
@@ -281,7 +246,7 @@ def test_tstring() -> None:
""")
) == snapshot("""\
first line
-second\
+second
""")
@required_py314
@@ -306,7 +271,7 @@ def test_tstring_format_spec() -> None:
== fstring_result
== snapshot("""\
=======Receipt=======
-- Total: 123\
+- Total: 123
""")
)
@@ -325,15 +290,15 @@ def test_unaligned_multiline_value() -> None:
- apples
- bananas
- cherries
- ---\
+ ---
+ \
""")
@staticmethod
def test_align_wrapper() -> None:
items = dedent("""
- apples
- - bananas
- """)
+ - bananas""")
assert dedent_f(f"""
Groceries:
{align(items)}
@@ -342,7 +307,7 @@ def test_align_wrapper() -> None:
Groceries:
- apples
- bananas
----\
+---
""")
@staticmethod
@@ -360,7 +325,7 @@ def test_two_values() -> None:
line2
B:
foo
- bar\
+ bar
""")
@staticmethod
@@ -374,7 +339,7 @@ def test_two_values_on_the_same_line() -> None:
Row:
a1
a2 + b1
- b2\
+ b2
""")
@staticmethod
@@ -394,7 +359,7 @@ def test_alignment_uses_interpolation_column_with_non_space_prefix() -> None:
value = "line1\nline2"
string = f"\N{NO-BREAK SPACE}{align(value)}"
assert (
- dedent(string, strip="none") # pyright: ignore[reportArgumentType]: runtime contract
+ dedent(string) # pyright: ignore[reportArgumentType]: runtime contract
== snapshot("\N{NO-BREAK SPACE}line1\n line2")
)
@@ -407,28 +372,28 @@ def test_conversions() -> None:
List:
- apples
- bananas
- - cherries\
+ - cherries
""")
assert dedent_f(f"""
Value:
{align("hello")!r}
""") == snapshot("""\
Value:
- 'hello'\
+ 'hello'
""")
assert dedent_f(f"""
Value:
{align(1.23456):.2f}
""") == snapshot("""\
Value:
- 1.23\
+ 1.23
""")
assert dedent_f(f"""
Header:
{align("hi"):>10}
""") == snapshot("""\
Header:
- hi\
+ hi
""")
@required_py314
@@ -449,7 +414,7 @@ def test_align_argument() -> None:
- apples
- bananas
- cherries
----\
+---
""")
@required_py314
@@ -469,7 +434,7 @@ def test_align_format_spec() -> None:
- apples
- bananas
- cherries
----\
+---
""")
@required_py314
@@ -490,7 +455,7 @@ def test_noalign_overrides_align_argument() -> None:
- apples
- bananas
- cherries
----\
+---
""")
@required_py314
@@ -511,7 +476,7 @@ def test_align_wrapper_is_not_applied_twice() -> None:
- apples
- bananas
- cherries
----\
+---
""")
@required_py314
@@ -534,7 +499,15 @@ def test_alignment_after_multiline_formatting() -> None:
items=ITEMS,
size=size,
)
- ) == snapshot("List:\n \n - apples\n - bananas\n - cherries\n \n---")
+ ) == snapshot("""\
+List:
+ \n\
+ - apples
+ - bananas
+ - cherries
+ \n\
+---
+""")
assert dedent(
t(
"""
@@ -545,7 +518,15 @@ def test_alignment_after_multiline_formatting() -> None:
items=ITEMS,
size=size,
)
- ) == snapshot("List:\n \n- apples\n- bananas\n- cherries\n\n---")
+ ) == snapshot("""\
+List:
+ \n\
+- apples
+- bananas
+- cherries
+
+---
+""")
@required_py314
@staticmethod
From 490e5170b2b436ed68c63cf277bdfba29705abec Mon Sep 17 00:00:00 2001
From: Graham Preston <16179442+grahamcracker1234@users.noreply.github.com>
Date: Tue, 18 Aug 2026 10:35:27 -0400
Subject: [PATCH 06/15] Default t-string interpolations to column alignment
Remove redundant global and opt-in controls so only explicit per-value noalign exceptions remain.
---
README.md | 73 ++++++-----------------------
src/dedent/__init__.py | 2 +-
src/dedent/_dedent.py | 102 ++++++++---------------------------------
tests/test_dedent.py | 43 ++++-------------
4 files changed, 44 insertions(+), 176 deletions(-)
diff --git a/README.md b/README.md
index 688fae4..8013425 100644
--- a/README.md
+++ b/README.md
@@ -11,8 +11,7 @@ Dedent multiline strings and keep interpolated values aligned. Supports t-string
- [Usage](#usage)
- [Dedentation Rules](#dedentation-rules)
- [Installation](#installation)
-- [Options](#options)
- - [`align`](#align)
+- [Alignment](#alignment)
- [Legacy Support (Python 3.10-3.13)](#legacy-support-python-310-313)
- [Why `textwrap.dedent` Falls Short](#why-textwrapdedent-falls-short)
@@ -30,13 +29,13 @@ print(greeting)
# Hello, Alice!
# Welcome to the party.
-# Nested multiline strings align correctly with :align
+# Nested multiline strings align to the interpolation column by default
items = dedent("""
- apples
- bananas""")
shopping_list = dedent(t"""
Groceries:
- {items:align}
+ {items}
---
""")
print(shopping_list)
@@ -127,21 +126,17 @@ uv add dedent
pip install dedent
```
-## Options
-
-### `align`
+## Alignment
-When an interpolation evaluates to a multiline string, only its first line is placed where the `{...}` appears. Subsequent lines keep whatever indentation they already had (often none), so they can appear "shifted left". Alignment fixes this by indenting subsequent lines to match the first.
-
-#### Format Spec Directives
+When a t-string interpolation evaluates to a multiline string, `dedent` aligns each subsequent line
+to the column where the interpolation begins. Column alignment is enabled by default.
> Requires Python 3.14+ (t-strings).
-Use format spec directives inside t-strings for per-value control:
+Use the `noalign` format spec directive to disable alignment for an individual value:
-- `{value:align}` - Align this multiline value to its interpolation column
-- `{value:noalign}` - Disable alignment for this value [^1]
-- `{value:align:06d}` - Combine with other format specs [^2]
+- `{value:noalign}` - Disable column alignment for this value
+- `{value:06d:noalign}` - Combine with other format specs
```python
from dedent import dedent
@@ -151,14 +146,14 @@ items = dedent("""
- two""")
result = dedent(t"""
- Aligned:
- {items:align}
- Not aligned:
+ Aligned by default:
{items}
+ Not aligned:
+ {items:noalign}
""")
print(result)
-# Aligned:
+# Aligned by default:
# - one
# - two
# Not aligned:
@@ -166,44 +161,6 @@ print(result)
# - two
```
-[^1]: Only has an effect when using the [`align=True` argument](#align-argument).
-
-[^2]: This rarely makes sense, unless you are also using custom format specifications, but nonetheless works.
-
-#### `align` Argument
-
-> Requires Python 3.14+ (t-strings).
-
-Pass `align=True` to enable alignment globally for all t-string interpolations. Format spec directives override this.
-
-```python
-from dedent import dedent
-
-items = dedent("""
- - one
- - two""")
-
-result = dedent(
- t"""
- List 1:
- {items}
- List 2:
- {items}
- ---
- """,
- align=True,
-)
-
-print(result)
-# List 1:
-# - one
-# - two
-# List 2:
-# - one
-# - two
-# ---
-```
-
## Legacy Support (Python 3.10-3.13)
On Python 3.10-3.13, t-strings are not available. Use `dedent()` on plain strings and f-strings, and wrap interpolated values with `align()` to get multiline indentation alignment.
@@ -240,7 +197,7 @@ Because Python renders an f-string before calling `dedent()`, wrap every multili
with `align()`. Unwrapped continuation lines at column zero correctly reduce the common indentation
prefix to zero. T-strings retain interpolation boundaries, so they do not have this limitation.
-> There is no equivalent of the [`align` argument](#align-argument) in Python 3.10-3.13. There is no way to automatically align multiline values when using f-strings.
+> There is no way to automatically align multiline values when using f-strings.
## Why `textwrap.dedent` Falls Short
@@ -343,7 +300,7 @@ groceries = dedent("""
shopping_list = dedent(t"""
Groceries:
- {groceries:align}
+ {groceries}
---
""")
diff --git a/src/dedent/__init__.py b/src/dedent/__init__.py
index 36c6652..cdbee92 100644
--- a/src/dedent/__init__.py
+++ b/src/dedent/__init__.py
@@ -10,7 +10,7 @@
- Uses PEP 822's exact common-prefix and closing-line indentation rules
- Omits the opening newline and preserves all other whitespace
- Aligns multiline interpolated values to their interpolation column
-- On Python 3.14+: t-strings with per-value format spec directives (`{value:align}`)
+- On Python 3.14+: t-strings with per-value alignment opt-out (`{value:noalign}`)
- On Python 3.10-3.13: f-strings with the `align` wrapper
"""
diff --git a/src/dedent/_dedent.py b/src/dedent/_dedent.py
index bf5b41f..1ff1eb8 100644
--- a/src/dedent/_dedent.py
+++ b/src/dedent/_dedent.py
@@ -1,33 +1,15 @@
import re
import sys
-from collections.abc import Callable, Iterable
+from collections.abc import Callable
from dataclasses import dataclass, field
-from enum import Enum
-from itertools import filterfalse, tee
from typing import TYPE_CHECKING, Final, TypeVar, final
from uuid import uuid4
-
-class Missing:
- """Placeholder for missing values."""
-
-
-MISSING: Final = Missing()
-
-DEFAULT_ALIGN: Final = False
-
-
-class AlignSpec(str, Enum):
- """Enumeration of alignment-specific format spec directives."""
-
- ALIGN = "align"
- NOALIGN = "noalign"
-
-
_T = TypeVar("_T")
_ALIGN_MARKER_PREFIX: Final = "DEDENT_ALIGN"
_HOLE_MARKER_PREFIX: Final = "DEDENT_HOLE"
+_NOALIGN_SPEC: Final = "noalign"
_SEP: Final = "\x00"
_ALIGN_MARKER: Final = re.compile(
@@ -368,91 +350,51 @@ def _dedent_marked_string(string: str) -> str:
from string.templatelib import Interpolation, Template, convert
from typing import LiteralString
- def _partition(
- it: Iterable[_T], pred: Callable[[_T], bool]
- ) -> tuple[filter[_T], filterfalse[_T]]:
+ def _parse_format_spec(format_spec: str) -> tuple[str, bool]:
"""
- Partition an iterable into two filters based on a predicate.
+ Remove the `noalign` directive from a format specification.
Args:
- it: The iterable to partition.
- pred: A function that returns True for items in the first partition.
+ format_spec: The format specification, potentially containing `noalign`.
Returns:
- A tuple of (matching_filter, non_matching_filter) where matching_filter contains items
- for which predicate returns True, and non_matching_filter contains items for which
- predicate returns False.
- """
- it1, it2 = tee(it)
- return filter(pred, it1), filterfalse(pred, it2)
-
- def _parse_format_spec(format_spec: str) -> tuple[str, bool | None]:
- """
- Parse format spec to extract alignment-specific directives.
-
- Extracts 'align' and 'noalign' directives from the format spec and returns the remaining
- format spec along with the alignment override.
-
- Args:
- format_spec: The format specification string, potentially containing alignment
- directives separated by colons.
-
- Returns:
- A tuple of (remaining_format_spec, align_override) where:
- - remaining_format_spec: The format spec with alignment directives removed.
- - align_override: True if 'align' was found, False if 'noalign' was found, or None if
- neither was present. If multiple alignment specs are present, the last one takes
- precedence.
+ The remaining format specification and whether column alignment is disabled.
"""
specs = format_spec.split(":")
-
- pred = set(AlignSpec).__contains__
- dedent_specs, other_specs = _partition(specs, pred)
- *_, dedent_spec = list(dedent_specs) or [None]
-
- format_spec = ":".join(other_specs)
-
- if dedent_spec is None:
- return format_spec, None
-
- return format_spec, AlignSpec(dedent_spec) == AlignSpec.ALIGN
+ return ":".join(spec for spec in specs if spec != _NOALIGN_SPEC), _NOALIGN_SPEC in specs
def _handle_item(
item: Interpolation[object],
*,
preceding_text: str,
- align: bool,
) -> str:
"""
- Convert and format one interpolation, then apply requested alignment.
+ Convert, format, and column-align one interpolation.
Args:
item: The interpolation to render.
preceding_text: The text that precedes this item, used for alignment.
- align: Whether to align multiline values by default (can be overridden by format spec
- directives).
Returns:
The processed string representation of the item.
"""
value = convert(item.value, item.conversion)
- align_override: bool | None = None
+ disable_alignment = False
if item.format_spec:
- format_spec, align_override = _parse_format_spec(item.format_spec)
+ format_spec, disable_alignment = _parse_format_spec(item.format_spec)
if format_spec:
value = format(value, format_spec)
value = str(value)
- should_align = align_override if align_override is not None else align
if _ALIGN_MARKER.search(value):
return process_align_markers(value, preceding_text=preceding_text)
- if should_align:
+ if not disable_alignment:
value = _align_value(value, preceding_text)
return value
- def _dedent_template(template: Template, *, align: bool) -> str:
+ def _dedent_template(template: Template) -> str:
"""
Dedent template literals before rendering their interpolations.
@@ -471,34 +413,28 @@ def _dedent_template(template: Template, *, align: bool) -> str:
literals.append("")
def render_interpolation(item: Interpolation[object], preceding_text: str) -> str:
- return _handle_item(item, preceding_text=preceding_text, align=align)
+ return _handle_item(item, preceding_text=preceding_text)
return _dedent_and_fill(literals, interpolations, render_interpolation)
def dedent( # pyright: ignore[reportUnreachable]
string: Template | LiteralString,
/,
- *,
- align: bool | Missing = MISSING,
) -> str:
r'''
- Dedent and align a template string.
+ Dedent a template string and column-align its interpolated values.
This function removes the longest exact indentation prefix shared by all nonblank lines,
preserving relative indentation. A final whitespace-only line constrains the prefix like
the closing-quotes line in PEP 822. It supports both literal strings and t-strings
(`Template` objects) with interpolations.
- For t-strings, interpolated values can include format spec directives:
- - `{value:align}` - Enable alignment for this value
- - `{value:noalign}` - Disable alignment for this value
- - `{value:align:06d}` - Combine alignment directive with other format specs
+ For t-strings, interpolated values use column alignment by default. Use the format spec
+ directive:
+ - `{value:noalign}` - Disable column alignment for this value
Args:
string: Template or literal string to dedent.
- align: Whether multiline interpolated values start each line in the column where the
- interpolation begins. Defaults to False. Can be overridden per value using format
- spec directives.
Raises:
IndentationError: If a line is incompatible with the common indentation prefix.
TypeError: If the input is not a string or Template object.
@@ -516,13 +452,11 @@ def dedent( # pyright: ignore[reportUnreachable]
>>> print(result)
Hello, World!
''' # ruff: ignore[docstring-extraneous-exception]
- align = align if not isinstance(align, Missing) else DEFAULT_ALIGN
-
match string:
case str() as formatted_string:
return _dedent_marked_string(formatted_string)
case Template() as template:
- return _dedent_template(template, align=align)
+ return _dedent_template(template)
case unknown if not TYPE_CHECKING: # pyright: ignore[reportUnnecessaryComparison]
message = f"expected str or Template, not {type(unknown).__qualname__!r}" # pyright: ignore[reportUnreachable]
raise TypeError(message)
diff --git a/tests/test_dedent.py b/tests/test_dedent.py
index 72243c1..15ffc3c 100644
--- a/tests/test_dedent.py
+++ b/tests/test_dedent.py
@@ -398,7 +398,7 @@ def test_conversions() -> None:
@required_py314
@staticmethod
- def test_align_argument() -> None:
+ def test_tstring_aligns_by_default() -> None:
assert dedent(
t(
"""
@@ -407,27 +407,6 @@ def test_align_argument() -> None:
---
""",
items=ITEMS,
- ),
- align=True, # pyright: ignore[reportCallIssue]: test is skipped before Python 3.14
- ) == snapshot("""\
-List:
- - apples
- - bananas
- - cherries
----
-""")
-
- @required_py314
- @staticmethod
- def test_align_format_spec() -> None:
- assert dedent(
- t(
- """
- List:
- {items:align}
- ---
- """,
- items=ITEMS,
)
) == snapshot("""\
List:
@@ -439,7 +418,7 @@ def test_align_format_spec() -> None:
@required_py314
@staticmethod
- def test_noalign_overrides_align_argument() -> None:
+ def test_noalign_disables_alignment() -> None:
assert dedent(
t(
"""
@@ -448,8 +427,7 @@ def test_noalign_overrides_align_argument() -> None:
---
""",
items=ITEMS,
- ),
- align=True, # pyright: ignore[reportCallIssue]: test is skipped before Python 3.14
+ )
) == snapshot("""\
List:
- apples
@@ -469,8 +447,7 @@ def test_align_wrapper_is_not_applied_twice() -> None:
---
""",
items=align(ITEMS),
- ),
- align=True, # pyright: ignore[reportCallIssue]: test is skipped before Python 3.14
+ )
) == snapshot("""\
List:
- apples
@@ -481,9 +458,9 @@ def test_align_wrapper_is_not_applied_twice() -> None:
@required_py314
@staticmethod
- def test_align_directive_with_format_spec() -> None:
- assert dedent(t("{123:06d:align}")) == snapshot("000123")
- assert dedent(t("{123:align:06d}")) == snapshot("000123")
+ def test_noalign_directive_with_format_spec() -> None:
+ assert dedent(t("{123:06d:noalign}")) == snapshot("000123")
+ assert dedent(t("{123:noalign:06d}")) == snapshot("000123")
@required_py314
@staticmethod
@@ -493,7 +470,7 @@ def test_alignment_after_multiline_formatting() -> None:
t(
"""
List:
- {items:\n^{size}:align}
+ {items:\n^{size}}
---
""",
items=ITEMS,
@@ -530,9 +507,9 @@ def test_alignment_after_multiline_formatting() -> None:
@required_py314
@staticmethod
- def test_unknown_format_spec() -> None:
+ def test_align_format_spec_is_not_supported() -> None:
with pytest.raises(ValueError, match=r"(?i)invalid format spec"):
- _ = dedent(t("{123:algn}"))
+ _ = dedent(t("{123:align}"))
@staticmethod
def test_wrapper_embeds_markers() -> None:
From 2fa206b91566775eacebd4d099cdf17096aea0a0 Mon Sep 17 00:00:00 2001
From: Graham Preston <16179442+grahamcracker1234@users.noreply.github.com>
Date: Tue, 18 Aug 2026 10:47:05 -0400
Subject: [PATCH 07/15] Split test classes into focused modules
---
tests/_support.py | 25 ++
tests/test_align.py | 246 ++++++++++++++++
tests/test_boundaries.py | 60 ++++
tests/test_dedent.py | 543 ++++++------------------------------
tests/test_interpolation.py | 60 ++++
5 files changed, 472 insertions(+), 462 deletions(-)
create mode 100644 tests/_support.py
create mode 100644 tests/test_align.py
create mode 100644 tests/test_boundaries.py
create mode 100644 tests/test_interpolation.py
diff --git a/tests/_support.py b/tests/_support.py
new file mode 100644
index 0000000..a84de4d
--- /dev/null
+++ b/tests/_support.py
@@ -0,0 +1,25 @@
+import sys
+from typing import cast
+
+import pytest
+
+from dedent import dedent
+
+required_py314 = pytest.mark.skipif(sys.version_info < (3, 14), reason="requires Python 3.14+")
+
+if sys.version_info >= (3, 14):
+ from string.templatelib import Template
+
+ _T = Template
+else:
+ _T = str
+
+
+def t(source: str, /, **ns: object) -> _T:
+ code = compile(f"t'''{source}'''", "", "eval")
+ return cast("_T", eval(code, ns)) # ruff: ignore[suspicious-eval-usage]
+
+
+def dedent_f(string: str) -> str:
+ """Call `dedent` on an interpolated f-string (not `LiteralString` on 3.14+)."""
+ return dedent(string) # pyright: ignore[reportArgumentType]
diff --git a/tests/test_align.py b/tests/test_align.py
new file mode 100644
index 0000000..5de8660
--- /dev/null
+++ b/tests/test_align.py
@@ -0,0 +1,246 @@
+import pytest
+from inline_snapshot import snapshot
+
+from dedent import align, dedent
+
+from ._support import dedent_f, required_py314, t
+
+ITEMS = "- apples\n- bananas\n- cherries"
+
+
+def test_unaligned_multiline_value() -> None:
+ # Column-0 lines from interpolation prevent a common indent prefix.
+ assert dedent_f(f"\n List:\n {ITEMS}\n ---\n ") == snapshot("""\
+ List:
+ - apples
+- bananas
+- cherries
+ ---
+ \
+""")
+
+
+def test_align_wrapper() -> None:
+ items = dedent("""
+ - apples
+ - bananas""")
+ assert dedent_f(f"""
+ Groceries:
+ {align(items)}
+ ---
+ """) == snapshot("""\
+Groceries:
+ - apples
+ - bananas
+---
+""")
+
+
+def test_two_values() -> None:
+ a = "line1\nline2"
+ b = "foo\nbar"
+ assert dedent_f(f"""
+ A:
+ {align(a)}
+ B:
+ {align(b)}
+ """) == snapshot("""\
+A:
+ line1
+ line2
+B:
+ foo
+ bar
+""")
+
+
+def test_two_values_on_the_same_line() -> None:
+ a = "a1\na2"
+ b = "b1\nb2"
+ assert dedent_f(f"""
+ Row:
+ {align(a)} + {align(b)}
+ """) == snapshot("""\
+Row:
+ a1
+ a2 + b1
+ b2
+""")
+
+
+def test_nested_wrappers() -> None:
+ inner = "line1\nline2"
+ outer = f"outer {align(inner)}"
+ result = dedent_f(f" {align(outer)}")
+ assert result == snapshot("""\
+outer line1
+ line2\
+""")
+ assert "\x00" not in result
+ assert "DEDENT_ALIGN" not in result
+
+
+def test_alignment_uses_interpolation_column_with_non_space_prefix() -> None:
+ value = "line1\nline2"
+ string = f"\N{NO-BREAK SPACE}{align(value)}"
+ assert (
+ dedent(string) # pyright: ignore[reportArgumentType]: runtime contract
+ == snapshot("\N{NO-BREAK SPACE}line1\n line2")
+ )
+
+
+def test_conversions() -> None:
+ assert dedent_f(f"""
+ List:
+ {align(ITEMS)!s}
+ """) == snapshot("""\
+List:
+ - apples
+ - bananas
+ - cherries
+""")
+ assert dedent_f(f"""
+ Value:
+ {align("hello")!r}
+ """) == snapshot("""\
+Value:
+ 'hello'
+""")
+ assert dedent_f(f"""
+ Value:
+ {align(1.23456):.2f}
+ """) == snapshot("""\
+Value:
+ 1.23
+""")
+ assert dedent_f(f"""
+ Header:
+ {align("hi"):>10}
+ """) == snapshot("""\
+Header:
+ hi
+""")
+
+
+@required_py314
+def test_tstring_aligns_by_default() -> None:
+ assert dedent(
+ t(
+ """
+ List:
+ {items}
+ ---
+ """,
+ items=ITEMS,
+ )
+ ) == snapshot("""\
+List:
+ - apples
+ - bananas
+ - cherries
+---
+""")
+
+
+@required_py314
+def test_noalign_disables_alignment() -> None:
+ assert dedent(
+ t(
+ """
+ List:
+ {items:noalign}
+ ---
+ """,
+ items=ITEMS,
+ )
+ ) == snapshot("""\
+List:
+ - apples
+- bananas
+- cherries
+---
+""")
+
+
+@required_py314
+def test_align_wrapper_is_not_applied_twice() -> None:
+ assert dedent(
+ t(
+ """
+ List:
+ {items}
+ ---
+ """,
+ items=align(ITEMS),
+ )
+ ) == snapshot("""\
+List:
+ - apples
+ - bananas
+ - cherries
+---
+""")
+
+
+@required_py314
+def test_noalign_directive_with_format_spec() -> None:
+ assert dedent(t("{123:06d:noalign}")) == snapshot("000123")
+ assert dedent(t("{123:noalign:06d}")) == snapshot("000123")
+
+
+@required_py314
+def test_alignment_after_multiline_formatting() -> None:
+ size = len(ITEMS) + 2
+ assert dedent(
+ t(
+ """
+ List:
+ {items:\n^{size}}
+ ---
+ """,
+ items=ITEMS,
+ size=size,
+ )
+ ) == snapshot("""\
+List:
+ \n\
+ - apples
+ - bananas
+ - cherries
+ \n\
+---
+""")
+ assert dedent(
+ t(
+ """
+ List:
+ {items:\n^{size}:noalign}
+ ---
+ """,
+ items=ITEMS,
+ size=size,
+ )
+ ) == snapshot("""\
+List:
+ \n\
+- apples
+- bananas
+- cherries
+
+---
+""")
+
+
+@required_py314
+def test_align_format_spec_is_not_supported() -> None:
+ with pytest.raises(ValueError, match=r"(?i)invalid format spec"):
+ _ = dedent(t("{123:align}"))
+
+
+def test_wrapper_embeds_markers() -> None:
+ text = str(align("hello"))
+ assert "hello" in text
+ assert "\x00" in text
+ assert repr("hello") in repr(align("hello"))
+ assert "1.2" in format(align(1.23), ".1f")
+ assert "\x00" in format(align(1.23), ".1f")
diff --git a/tests/test_boundaries.py b/tests/test_boundaries.py
new file mode 100644
index 0000000..cd790c6
--- /dev/null
+++ b/tests/test_boundaries.py
@@ -0,0 +1,60 @@
+from inline_snapshot import snapshot
+
+from dedent import dedent
+
+TYPICAL = """
+ foo
+ bar
+ """
+
+
+def test_closing_quotes_on_own_line_preserve_trailing_newline() -> None:
+ assert dedent(TYPICAL) == snapshot("foo\nbar\n")
+
+
+def test_closing_quotes_after_content_omit_trailing_newline() -> None:
+ assert dedent("""
+ foo
+ bar""") == snapshot("foo\nbar")
+
+
+def test_omits_only_opening_newline() -> None:
+ assert dedent("""
+
+
+ foo
+ bar
+ """) == snapshot("""\
+
+
+foo
+bar
+""")
+
+
+def test_keeps_trailing_spaces_on_content() -> None:
+ assert dedent(
+ """
+ foo---
+ bar---
+ """.replace("-", " ")
+ ) == snapshot("""\
+foo \n\
+bar \n\
+""")
+
+
+def test_keeps_whitespace_only_lines() -> None:
+ assert dedent(
+ """
+ ---
+ foo
+ bar
+ ---
+ """.replace("-", " ")
+ ) == snapshot("""\
+ \n\
+foo
+bar
+ \n\
+""")
diff --git a/tests/test_dedent.py b/tests/test_dedent.py
index 15ffc3c..e60d806 100644
--- a/tests/test_dedent.py
+++ b/tests/test_dedent.py
@@ -1,521 +1,140 @@
-"""Tests for `dedent`."""
-
-import sys
-from typing import cast
-
import pytest
from inline_snapshot import snapshot
-from dedent import align, dedent
-
-required_py314 = pytest.mark.skipif(sys.version_info < (3, 14), reason="requires Python 3.14+")
-
-if sys.version_info >= (3, 14):
- from string.templatelib import Template
-
- _T = Template
-else:
- _T = str
-
-ITEMS = "- apples\n- bananas\n- cherries"
-TYPICAL = """
- foo
- bar
- """
+from dedent import dedent
+from ._support import dedent_f
-def t(source: str, /, **ns: object) -> _T:
- code = compile(f"t'''{source}'''", "", "eval")
- return cast("_T", eval(code, ns)) # ruff: ignore[suspicious-eval-usage]
-
-def dedent_f(string: str) -> str:
- """Call `dedent` on an interpolated f-string (not `LiteralString` on 3.14+)."""
- return dedent(string) # pyright: ignore[reportArgumentType]
-
-
-class TestDedent:
- @staticmethod
- def test_uses_exact_common_indentation_prefix() -> None:
- assert dedent(" \tfirst\n \t\tsecond") == snapshot("""\
+def test_uses_exact_common_indentation_prefix() -> None:
+ assert dedent(" \tfirst\n \t\tsecond") == snapshot("""\
first
second\
""")
- assert dedent(" first\n\tsecond") == snapshot("""\
+ assert dedent(" first\n\tsecond") == snapshot("""\
first
second\
""")
- @staticmethod
- def test_closing_line_constrains_indentation() -> None:
- assert dedent("\n first\n ") == snapshot(" first\n")
- assert dedent("\n first\n ") == snapshot("first\n")
- @staticmethod
- def test_column_zero_closer_preserves_indentation() -> None:
- assert dedent("\n first\n second\n") == snapshot("""\
+def test_closing_line_constrains_indentation() -> None:
+ assert dedent("\n first\n ") == snapshot(" first\n")
+ assert dedent("\n first\n ") == snapshot("first\n")
+
+
+def test_column_zero_closer_preserves_indentation() -> None:
+ assert dedent("\n first\n second\n") == snapshot("""\
first
second
""")
- assert dedent("\n first\n second") == snapshot("""\
+ assert dedent("\n first\n second") == snapshot("""\
first
second\
""")
- @staticmethod
- def test_ignores_compatible_whitespace_only_lines() -> None:
- assert dedent("\n first\n \n second\n ") == snapshot("""\
+
+def test_ignores_compatible_whitespace_only_lines() -> None:
+ assert dedent("\n first\n \n second\n ") == snapshot("""\
first
second
""")
- @staticmethod
- def test_preserves_crlf_when_ignoring_blank_lines() -> None:
- assert dedent(" first\r\n\r\n second\r\n ") == snapshot("first\r\n\r\nsecond\r\n")
-
- @staticmethod
- def test_preserves_complete_crlf_boundary() -> None:
- assert dedent("\r\n first\r\n ") == snapshot("first\r\n")
-
- @pytest.mark.parametrize("whitespace", ["\f", "\v", "\N{NO-BREAK SPACE}"])
- @staticmethod
- def test_non_indentation_whitespace_is_content(whitespace: str) -> None:
- string = f" first\n{whitespace}\n second\n "
- assert dedent(string) == string # pyright: ignore[reportArgumentType]: runtime contract
-
- @pytest.mark.parametrize(
- "string",
- [
- "\n first\n \t\n second\n ",
- "\r\n first\r\n \t\r\n second\r\n ",
- ],
- )
- @staticmethod
- def test_rejects_incompatible_whitespace_only_lines(string: str) -> None:
- with pytest.raises(IndentationError, match="line 3"):
- _ = dedent_f(string)
-
- @staticmethod
- def test_removes_common_indent() -> None:
- assert dedent("""
- first
- second
- third
- """) == snapshot("""\
+
+def test_preserves_crlf_when_ignoring_blank_lines() -> None:
+ assert dedent(" first\r\n\r\n second\r\n ") == snapshot("first\r\n\r\nsecond\r\n")
+
+
+def test_preserves_complete_crlf_boundary() -> None:
+ assert dedent("\r\n first\r\n ") == snapshot("first\r\n")
+
+
+@pytest.mark.parametrize("whitespace", ["\f", "\v", "\N{NO-BREAK SPACE}"])
+def test_non_indentation_whitespace_is_content(whitespace: str) -> None:
+ string = f" first\n{whitespace}\n second\n "
+ assert dedent(string) == string # pyright: ignore[reportArgumentType]: runtime contract
+
+
+@pytest.mark.parametrize(
+ "string",
+ [
+ "\n first\n \t\n second\n ",
+ "\r\n first\r\n \t\r\n second\r\n ",
+ ],
+)
+def test_rejects_incompatible_whitespace_only_lines(string: str) -> None:
+ with pytest.raises(IndentationError, match="line 3"):
+ _ = dedent_f(string)
+
+
+def test_removes_common_indent() -> None:
+ assert dedent("""
+ first
+ second
+ third
+ """) == snapshot("""\
first
second
third
""")
- @staticmethod
- def test_min_indent_from_least_indented_line() -> None:
- assert dedent("""
- first
- second
- third
- """) == snapshot("""\
+
+def test_min_indent_from_least_indented_line() -> None:
+ assert dedent("""
+ first
+ second
+ third
+ """) == snapshot("""\
first
second
third
""")
- @staticmethod
- def test_tabs() -> None:
- assert dedent("\n\t\tfirst\n\t\t\tsecond\n\t\t\t\tthird\n\t\t") == snapshot("""\
+
+def test_tabs() -> None:
+ assert dedent("\n\t\tfirst\n\t\t\tsecond\n\t\t\t\tthird\n\t\t") == snapshot("""\
first
second
third
""")
- @staticmethod
- def test_empty() -> None:
- assert dedent("") == snapshot("")
-
- @staticmethod
- def test_single_line() -> None:
- assert dedent("A single line of input.") == snapshot("A single line of input.")
-
- @staticmethod
- def test_escaped_newlines_are_literal() -> None:
- assert dedent("\\n\\tfirst\\n") == snapshot("\\n\\tfirst\\n")
-
- @staticmethod
- def test_explicit_newline_is_kept() -> None:
- assert dedent("""
- Hello world!
\n
- """) == snapshot("""\
-Hello world!
-
-""")
-
- @staticmethod
- def test_source_line_continuation() -> None:
- assert dedent("""
- first \
- second
- third
- """) == snapshot("""\
-first second
-third
-""")
-
- @staticmethod
- def test_unicode() -> None:
- assert dedent(" π εΌζ°") == snapshot("π εΌζ°")
-
- @staticmethod
- def test_rejects_non_string() -> None:
- with pytest.raises(TypeError, match="expected str"):
- _ = dedent(123) # pyright: ignore[reportArgumentType]
+def test_empty() -> None:
+ assert dedent("") == snapshot("")
-class TestBoundaries:
- @staticmethod
- def test_closing_quotes_on_own_line_preserve_trailing_newline() -> None:
- assert dedent(TYPICAL) == snapshot("foo\nbar\n")
- @staticmethod
- def test_closing_quotes_after_content_omit_trailing_newline() -> None:
- assert dedent("""
- foo
- bar""") == snapshot("foo\nbar")
+def test_single_line() -> None:
+ assert dedent("A single line of input.") == snapshot("A single line of input.")
- @staticmethod
- def test_omits_only_opening_newline() -> None:
- assert dedent("""
+def test_escaped_newlines_are_literal() -> None:
+ assert dedent("\\n\\tfirst\\n") == snapshot("\\n\\tfirst\\n")
- foo
- bar
- """) == snapshot("""\
-
-
-foo
-bar
-""")
- @staticmethod
- def test_keeps_trailing_spaces_on_content() -> None:
- assert dedent(
- """
- foo---
- bar---
- """.replace("-", " ")
- ) == snapshot("""\
-foo \n\
-bar \n\
-""")
+def test_explicit_newline_is_kept() -> None:
+ assert dedent("""
+ Hello world!
\n
+ """) == snapshot("""\
+Hello world!
- @staticmethod
- def test_keeps_whitespace_only_lines() -> None:
- assert dedent(
- """
- ---
- foo
- bar
- ---
- """.replace("-", " ")
- ) == snapshot("""\
- \n\
-foo
-bar
- \n\
""")
-class TestInterpolation:
- @staticmethod
- def test_fstring() -> None:
- line = "line"
- assert dedent(f"""
- first {line}
- second
- """) == snapshot("""\
-first line
-second
-""")
-
- @required_py314
- @staticmethod
- def test_tstring() -> None:
- assert dedent(
- t("""
- first {"line"}
+def test_source_line_continuation() -> None:
+ assert dedent("""
+ first \
second
- """)
- ) == snapshot("""\
-first line
-second
-""")
-
- @required_py314
- @staticmethod
- def test_tstring_format_spec() -> None:
- template_result = dedent(
- t(
- """
- {header:=^21}
- - Total: {total: 9d}
- """,
- header="Receipt",
- total=123,
- )
- )
- fstring_result = dedent_f(f"""
- {"Receipt":=^21}
- - Total: {123: 9d}
- """)
- assert (
- template_result
- == fstring_result
- == snapshot("""\
-=======Receipt=======
-- Total: 123
-""")
- )
-
- @required_py314
- @staticmethod
- def test_tstring_empty_format_spec() -> None:
- assert dedent(t("{123:}")) == dedent_f(f"{123:}") == snapshot("123")
-
-
-class TestAlign:
- @staticmethod
- def test_unaligned_multiline_value() -> None:
- # Column-0 lines from interpolation prevent a common indent prefix.
- assert dedent_f(f"\n List:\n {ITEMS}\n ---\n ") == snapshot("""\
- List:
- - apples
-- bananas
-- cherries
- ---
- \
-""")
-
- @staticmethod
- def test_align_wrapper() -> None:
- items = dedent("""
- - apples
- - bananas""")
- assert dedent_f(f"""
- Groceries:
- {align(items)}
- ---
- """) == snapshot("""\
-Groceries:
- - apples
- - bananas
----
-""")
-
- @staticmethod
- def test_two_values() -> None:
- a = "line1\nline2"
- b = "foo\nbar"
- assert dedent_f(f"""
- A:
- {align(a)}
- B:
- {align(b)}
- """) == snapshot("""\
-A:
- line1
- line2
-B:
- foo
- bar
-""")
-
- @staticmethod
- def test_two_values_on_the_same_line() -> None:
- a = "a1\na2"
- b = "b1\nb2"
- assert dedent_f(f"""
- Row:
- {align(a)} + {align(b)}
- """) == snapshot("""\
-Row:
- a1
- a2 + b1
- b2
-""")
-
- @staticmethod
- def test_nested_wrappers() -> None:
- inner = "line1\nline2"
- outer = f"outer {align(inner)}"
- result = dedent_f(f" {align(outer)}")
- assert result == snapshot("""\
-outer line1
- line2\
-""")
- assert "\x00" not in result
- assert "DEDENT_ALIGN" not in result
-
- @staticmethod
- def test_alignment_uses_interpolation_column_with_non_space_prefix() -> None:
- value = "line1\nline2"
- string = f"\N{NO-BREAK SPACE}{align(value)}"
- assert (
- dedent(string) # pyright: ignore[reportArgumentType]: runtime contract
- == snapshot("\N{NO-BREAK SPACE}line1\n line2")
- )
-
- @staticmethod
- def test_conversions() -> None:
- assert dedent_f(f"""
- List:
- {align(ITEMS)!s}
- """) == snapshot("""\
-List:
- - apples
- - bananas
- - cherries
-""")
- assert dedent_f(f"""
- Value:
- {align("hello")!r}
- """) == snapshot("""\
-Value:
- 'hello'
-""")
- assert dedent_f(f"""
- Value:
- {align(1.23456):.2f}
- """) == snapshot("""\
-Value:
- 1.23
-""")
- assert dedent_f(f"""
- Header:
- {align("hi"):>10}
+ third
""") == snapshot("""\
-Header:
- hi
-""")
-
- @required_py314
- @staticmethod
- def test_tstring_aligns_by_default() -> None:
- assert dedent(
- t(
- """
- List:
- {items}
- ---
- """,
- items=ITEMS,
- )
- ) == snapshot("""\
-List:
- - apples
- - bananas
- - cherries
----
+first second
+third
""")
- @required_py314
- @staticmethod
- def test_noalign_disables_alignment() -> None:
- assert dedent(
- t(
- """
- List:
- {items:noalign}
- ---
- """,
- items=ITEMS,
- )
- ) == snapshot("""\
-List:
- - apples
-- bananas
-- cherries
----
-""")
- @required_py314
- @staticmethod
- def test_align_wrapper_is_not_applied_twice() -> None:
- assert dedent(
- t(
- """
- List:
- {items}
- ---
- """,
- items=align(ITEMS),
- )
- ) == snapshot("""\
-List:
- - apples
- - bananas
- - cherries
----
-""")
+def test_unicode() -> None:
+ assert dedent(" π εΌζ°") == snapshot("π εΌζ°")
- @required_py314
- @staticmethod
- def test_noalign_directive_with_format_spec() -> None:
- assert dedent(t("{123:06d:noalign}")) == snapshot("000123")
- assert dedent(t("{123:noalign:06d}")) == snapshot("000123")
-
- @required_py314
- @staticmethod
- def test_alignment_after_multiline_formatting() -> None:
- size = len(ITEMS) + 2
- assert dedent(
- t(
- """
- List:
- {items:\n^{size}}
- ---
- """,
- items=ITEMS,
- size=size,
- )
- ) == snapshot("""\
-List:
- \n\
- - apples
- - bananas
- - cherries
- \n\
----
-""")
- assert dedent(
- t(
- """
- List:
- {items:\n^{size}:noalign}
- ---
- """,
- items=ITEMS,
- size=size,
- )
- ) == snapshot("""\
-List:
- \n\
-- apples
-- bananas
-- cherries
-
----
-""")
- @required_py314
- @staticmethod
- def test_align_format_spec_is_not_supported() -> None:
- with pytest.raises(ValueError, match=r"(?i)invalid format spec"):
- _ = dedent(t("{123:align}"))
-
- @staticmethod
- def test_wrapper_embeds_markers() -> None:
- text = str(align("hello"))
- assert "hello" in text
- assert "\x00" in text
- assert repr("hello") in repr(align("hello"))
- assert "1.2" in format(align(1.23), ".1f")
- assert "\x00" in format(align(1.23), ".1f")
+def test_rejects_non_string() -> None:
+ with pytest.raises(TypeError, match="expected str"):
+ _ = dedent(123) # pyright: ignore[reportArgumentType]
diff --git a/tests/test_interpolation.py b/tests/test_interpolation.py
new file mode 100644
index 0000000..11f6a4b
--- /dev/null
+++ b/tests/test_interpolation.py
@@ -0,0 +1,60 @@
+from inline_snapshot import snapshot
+
+from dedent import dedent
+
+from ._support import dedent_f, required_py314, t
+
+
+def test_fstring() -> None:
+ line = "line"
+ assert dedent(f"""
+ first {line}
+ second
+ """) == snapshot("""\
+first line
+second
+""")
+
+
+@required_py314
+def test_tstring() -> None:
+ assert dedent(
+ t("""
+ first {"line"}
+ second
+ """)
+ ) == snapshot("""\
+first line
+second
+""")
+
+
+@required_py314
+def test_tstring_format_spec() -> None:
+ template_result = dedent(
+ t(
+ """
+ {header:=^21}
+ - Total: {total: 9d}
+ """,
+ header="Receipt",
+ total=123,
+ )
+ )
+ fstring_result = dedent_f(f"""
+ {"Receipt":=^21}
+ - Total: {123: 9d}
+ """)
+ assert (
+ template_result
+ == fstring_result
+ == snapshot("""\
+=======Receipt=======
+- Total: 123
+""")
+ )
+
+
+@required_py314
+def test_tstring_empty_format_spec() -> None:
+ assert dedent(t("{123:}")) == dedent_f(f"{123:}") == snapshot("123")
From 2e9c8001f03c5e25edcb14cb4b2c07791ebe6d72 Mon Sep 17 00:00:00 2001
From: Graham Preston <16179442+grahamcracker1234@users.noreply.github.com>
Date: Tue, 18 Aug 2026 11:49:52 -0400
Subject: [PATCH 08/15] Improve README onboarding and examples
---
README.md | 337 +++++++++++++++++++++---------------------------------
1 file changed, 130 insertions(+), 207 deletions(-)
diff --git a/README.md b/README.md
index 8013425..951a64a 100644
--- a/README.md
+++ b/README.md
@@ -1,139 +1,85 @@
# dedent
-What [`textwrap.dedent`](https://docs.python.org/3/library/textwrap.html#textwrap.dedent) should have been.
+Write multiline strings naturally. Keep every interpolation aligned.
-Dedent multiline strings and keep interpolated values aligned. Supports t-strings (Python 3.14+) and f-strings (Python 3.10+).
-
-> For documentation on how to use dedent with f-strings in Python 3.10-3.13, see [Legacy Support](#legacy-support-python-310-313).
-
-## Table of Contents
-
-- [Usage](#usage)
-- [Dedentation Rules](#dedentation-rules)
-- [Installation](#installation)
-- [Alignment](#alignment)
-- [Legacy Support (Python 3.10-3.13)](#legacy-support-python-310-313)
-- [Why `textwrap.dedent` Falls Short](#why-textwrapdedent-falls-short)
-
-## Usage
+Supports both f-strings and t-strings.
```python
from dedent import dedent
-name = "Alice"
-greeting = dedent(t"""
- Hello, {name}!
- Welcome to the party.
- """)
-print(greeting)
-# Hello, Alice!
-# Welcome to the party.
-# Nested multiline strings align to the interpolation column by default
-items = dedent("""
- - apples
- - bananas""")
-shopping_list = dedent(t"""
- Groceries:
- {items}
- ---
- """)
-print(shopping_list)
-# Groceries:
-# - apples
-# - bananas
-# ---
+def showcase():
+ features = dedent("""
+ β¦ clean multiline strings
+ β¦ automatic column alignment
+ β¦ zero dependencies""")
+ install = dedent("""
+ uv add dedent
+ pip install dedent""")
+
+ print(dedent(t"""
+ ββ dedent ββββββββββββββββββββββββββ
+ Features: {features}
+ Install: {install}
+ β°βββββββββββββββββββββββββββββββββββ"""))
+
+
+showcase()
+# ββ dedent ββββββββββββββββββββββββββ
+# Features: β¦ clean multiline strings
+# β¦ automatic column alignment
+# β¦ zero dependencies
+# Install: uv add dedent
+# pip install dedent
+# β°βββββββββββββββββββββββββββββββββββ
```
-## Dedentation Rules
-
-`dedent` follows the indentation model proposed by
-[PEP 822](https://peps.python.org/pep-0822/):
+> This example uses t-strings and requires Python 3.14+.
-1. Indentation is an exact prefix of spaces and tabs; a tab never equals spaces.
-2. The longest indentation prefix shared by every nonblank line is removed.
-3. Lines containing only spaces and tabs do not determine that prefix, except for the final line.
- A final space/tab-only line is treated like the line containing closing triple quotes.
-4. Every line must be compatible with the chosen prefix. Otherwise, `IndentationError` is raised.
-5. The closing-quotes indentation can preserve intentional indentation:
-
-```python
-result = dedent("""
- Hello
- World!
- """)
-
-assert result == " Hello\n World!\n"
-```
-
-Keep the closing quotes at the indentation you want removed. Their placement also controls the
-final newline:
+## Table of Contents
-```python
-with_newline = dedent("""
- Hello
- World!
- """)
-without_newline = dedent("""
- Hello
- World!""")
+- [Installation](#installation)
+- [Usage](#usage)
+ - [t-string alignment](#t-string-alignment)
+ - [f-string alignment](#f-string-alignment)
+- [Why `textwrap.dedent` Falls Short](#why-textwrapdedent-falls-short)
+- [Dedentation Rules](#dedentation-rules)
-assert with_newline == "Hello\nWorld!\n"
-assert without_newline == "Hello\nWorld!"
-```
+## Installation
-A final `\n` creates an empty closing line at column zero. This line preserves content indentation:
+```bash
+# Using uv (Recommended)
+uv add dedent
-```python
-assert dedent("\n Hello\n") == " Hello\n"
-assert dedent("\n Hello\n ") == "Hello\n"
+# Using pip
+pip install dedent
```
-### Why `IndentationError`?
+## Usage
-Space/tab-only lines are ignored when finding the common prefix, but they must still be compatible
-with it when that prefix is removed:
+`dedent()` removes shared indentation and omits the newline after the opening triple quotes:
```python
-dedent("\n hello\n \t\n world\n ")
-# IndentationError: inconsistent indentation in dedented string at line 3
-```
-
-Here the nonblank lines and closing line establish a two-space prefix. The middle line contains
-one space followed by a tab, so that exact prefix cannot be removed. Raising an error catches
-mixed or malformed indentation instead of silently changing or preserving ambiguous whitespace.
-A shorter space/tab-only line is valid when it matches the beginning of the prefix; it simply
-becomes empty. This mirrors PEP 822.
-
-`dedent()` omits one opening newline, as PEP 822 requires. It preserves all other whitespace.
-Use the closing-quote placement when you do not want a final newline.
-
-Unlike native d-strings, `dedent()` runs after Python parses a string. Thus, Python has already
-processed escape sequences. For t-strings, `dedent()` processes literal text before it renders
-interpolations.
-
-For runtime strings that use CRLF, `dedent` preserves each `\r\n` pair and excludes the terminal
-`\r` from indentation checks. Other whitespace characters, such as form feed and non-breaking
-space, remain content.
-
-## Installation
+from dedent import dedent
-```bash
-# Using uv (Recommended)
-uv add dedent
+message = dedent("""
+ Hello,
+ World!""")
-# Using pip
-pip install dedent
+print(message)
+# Hello,
+# World!
```
-## Alignment
+See [Dedentation Rules](#dedentation-rules) for newline, closing-quote, and mixed-indentation behavior.
+
+### t-string alignment
-When a t-string interpolation evaluates to a multiline string, `dedent` aligns each subsequent line
-to the column where the interpolation begins. Column alignment is enabled by default.
+> Requires Python 3.14+.
-> Requires Python 3.14+ (t-strings).
+When a t-string interpolation evaluates to a multiline string, `dedent` aligns each subsequent line to the column where the interpolation begins. Column alignment is enabled by default.
-Use the `noalign` format spec directive to disable alignment for an individual value:
+Use the `dedent`-specific `noalign` format specifier to disable alignment for an individual value:
- `{value:noalign}` - Disable column alignment for this value
- `{value:06d:noalign}` - Combine with other format specs
@@ -149,8 +95,7 @@ result = dedent(t"""
Aligned by default:
{items}
Not aligned:
- {items:noalign}
- """)
+ {items:noalign}""")
print(result)
# Aligned by default:
@@ -161,153 +106,131 @@ print(result)
# - two
```
-## Legacy Support (Python 3.10-3.13)
+### f-string alignment
+
+> For Python 3.10-3.13. Use [t-string alignment](#t-string-alignment) on Python 3.14+.
-On Python 3.10-3.13, t-strings are not available. Use `dedent()` on plain strings and f-strings, and wrap interpolated values with `align()` to get multiline indentation alignment.
+Use `align()` with f-strings because Python 3.10-3.13 do not support t-strings:
```python
from dedent import align, dedent
-# dedent works on regular strings, like textwrap.dedent
-message = dedent("""
- Hello,
- World!
- """)
-print(message)
-# Hello,
-# World!
-
-# Use align() inside f-strings for multiline value alignment
items = dedent("""
- apples
- - bananas""")
+ - bananas
+ - cherries""")
shopping_list = dedent(f"""
Groceries:
- {align(items)}
- ---
- """)
+ {align(items)}""")
print(shopping_list)
# Groceries:
# - apples
# - bananas
-# ---
+# - cherries
```
-Because Python renders an f-string before calling `dedent()`, wrap every multiline interpolation
-with `align()`. Unwrapped continuation lines at column zero correctly reduce the common indentation
-prefix to zero. T-strings retain interpolation boundaries, so they do not have this limitation.
-
-> There is no way to automatically align multiline values when using f-strings.
+Python renders an f-string before calling `dedent()`. The `align()` wrapper marks each multiline value so `dedent()` can align its continuation lines.
## Why `textwrap.dedent` Falls Short
-If you're here, then you're probably already familiar with the shortcomings of `textwrap.dedent`. But regardless, let's spell it out for the sake of completeness. For example, say we want to create a nicely formatted shopping list that includes some groceries:
+`textwrap.dedent` preserves the newline after the opening triple quotes. To keep the source readable without printing a blank first line, you must escape that newline or remove it afterward. It also cannot preserve indentation when an f-string inserts a multiline value:
```python
from textwrap import dedent
-groceries = dedent("""
+items = dedent("""\
- apples
- bananas
- - cherries
-""")
-
-shopping_list = dedent(f"""
+ - cherries""")
+shopping_list = dedent(f"""\
Groceries:
- {groceries}
- ---
-""")
+ {items}""")
print(shopping_list)
-#
# Groceries:
-#
-# - apples
+# - apples
# - bananas
# - cherries
-#
-# ---
```
-Wait, that's not what we wanted. We accidentally included leading and trailing newlines from the groceries string. Now, we *could* do that manually by removing, escaping, or stripping the newlines, but it's either easy to forget, difficult to read, or unnecessarily verbose.
+The escape removes the first newline. It cannot fix the indentation: Python renders the f-string first, so the continuation lines begin at column zero and prevent `textwrap.dedent` from finding shared indentation.
-```python
-# Removing the newlines
-groceries = dedent(""" - apples
- - bananas
- - cherries""")
+`dedent()` receives a t-string's literal segments and interpolations separately:
-# Escaping the newlines
-groceries = dedent("""\
- - apples
- - bananas
- - cherries\
-""")
+```python
+from dedent import dedent
-# Stripping the newlines
-groceries = dedent(
- """
+items = dedent("""
- apples
- bananas
- - cherries
-""".strip("\n")
-)
+ - cherries""")
+shopping_list = dedent(t"""
+ Groceries:
+ {items}""")
-# But the shopping list still comes out wrong:
-# Groceries:
-# - apples
-# - bananas
-# - cherries
-# ---
+print(shopping_list)
+# Groceries:
+# - apples
+# - bananas
+# - cherries
```
-Uh oh, something is still wrong; the indentation is not correct at all. The interpolation happens too early. When we use an f-string with `textwrap.dedent`, the replacement occurs before dedenting can take place. Notice how only the first line of `groceries` is properly indented relative to the surrounding text? The subsequent lines lose their indentation because f-strings interpolate immediately, injecting the `groceries` string before `dedent` can process the overall structure.
+It dedents the literal text, then renders and aligns each continuation line with the interpolation column. On Python 3.10-3.13, [`align()`](#f-string-alignment) provides the same alignment for f-strings.
-Sure, we could manually adjust the indentation with a bit of string manipulation, but that's a pain to read, write, and maintain.
+## Dedentation Rules
+
+`dedent` follows the indentation model proposed by [PEP 822](https://peps.python.org/pep-0822/):
+
+1. Indentation is an exact prefix of spaces and tabs; a tab never equals spaces.
+2. The longest indentation prefix shared by every nonblank line is removed.
+3. Lines containing only spaces and tabs do not determine that prefix, except for the final line. A final space/tab-only line is treated like the line containing closing triple quotes.
+4. Every line must be compatible with the chosen prefix. Otherwise, `IndentationError` is raised.
+5. The closing-quotes indentation can preserve intentional indentation:
```python
-from textwrap import dedent
+result = dedent("""
+ Hello
+ World!
+ """)
-groceries = dedent(
- """
- - apples
- - bananas
- - cherries
-""".strip("\n")
-)
+assert result == " Hello\n World!\n"
+```
-manual_groceries = ("\n" + " " * 8).join(groceries.splitlines())
+Keep the closing quotes at the indentation you want removed. Their placement also controls the final newline:
-shopping_list = dedent(
- f"""
- Groceries:
- {manual_groceries}
- ---
-""".strip("\n")
-)
+```python
+with_newline = dedent("""
+ Hello
+ World!
+ """)
+without_newline = dedent("""
+ Hello
+ World!""")
+
+assert with_newline == "Hello\nWorld!\n"
+assert without_newline == "Hello\nWorld!"
```
-`dedent` uses closing-quote placement to control the final newline. Put the closing quotes after
-the final item when nested content must not end with a newline:
+A final `\n` creates an empty closing line at column zero. This line preserves content indentation:
```python
-from dedent import dedent
+assert dedent("\n Hello\n") == " Hello\n"
+assert dedent("\n Hello\n ") == "Hello\n"
+```
-groceries = dedent("""
- - apples
- - bananas
- - cherries""")
+### Why `IndentationError`?
-shopping_list = dedent(t"""
- Groceries:
- {groceries}
- ---
- """)
+Space/tab-only lines are ignored when finding the common prefix, but they must still be compatible with it when that prefix is removed:
-print(shopping_list)
-# Groceries:
-# - apples
-# - bananas
-# - cherries
-# ---
+```python
+dedent("\n hello\n \t\n world\n ")
+# IndentationError: inconsistent indentation in dedented string at line 3
```
+
+Here the nonblank lines and closing line establish a two-space prefix. The middle line contains one space followed by a tab, so that exact prefix cannot be removed. Raising an error catches mixed or malformed indentation instead of silently changing or preserving ambiguous whitespace. A shorter space/tab-only line is valid when it matches the beginning of the prefix; it simply becomes empty. This mirrors PEP 822.
+
+`dedent()` omits one opening newline, as PEP 822 requires. It preserves all other whitespace. Use the closing-quote placement when you do not want a final newline.
+
+Unlike PEP 822's proposed d-strings, `dedent()` runs after Python parses a string. Python has therefore already processed escape sequences. For t-strings, `dedent()` processes literal text before rendering interpolations.
+
+For runtime strings that use CRLF, `dedent` preserves each `\r\n` pair and excludes the terminal `\r` from indentation checks. Other whitespace characters, such as form feed and non-breaking space, remain content.
From 529809d40d647b4869f24d52a3b4d35cf6b95184 Mon Sep 17 00:00:00 2001
From: Graham Preston <16179442+grahamcracker1234@users.noreply.github.com>
Date: Tue, 18 Aug 2026 12:01:10 -0400
Subject: [PATCH 09/15] Fix README formatting check
---
README.md | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 951a64a..0fd9ba7 100644
--- a/README.md
+++ b/README.md
@@ -17,11 +17,13 @@ def showcase():
uv add dedent
pip install dedent""")
- print(dedent(t"""
+ print(
+ dedent(t"""
ββ dedent ββββββββββββββββββββββββββ
Features: {features}
Install: {install}
- β°βββββββββββββββββββββββββββββββββββ"""))
+ β°βββββββββββββββββββββββββββββββββββ""")
+ )
showcase()
From 1e60c2cbc01bbff4d8958bc324f449c83930b4ad Mon Sep 17 00:00:00 2001
From: Graham Preston <16179442+grahamcracker1234@users.noreply.github.com>
Date: Tue, 18 Aug 2026 12:33:19 -0400
Subject: [PATCH 10/15] Reject align wrappers in t-strings
Fail clearly when the f-string-only wrapper reaches a t-string, avoiding ambiguous alignment precedence.
---
README.md | 4 ++--
src/dedent/_dedent.py | 14 ++++++++++++--
tests/test_align.py | 34 +++++++++++++++-------------------
3 files changed, 29 insertions(+), 23 deletions(-)
diff --git a/README.md b/README.md
index 0fd9ba7..b39a129 100644
--- a/README.md
+++ b/README.md
@@ -79,7 +79,7 @@ See [Dedentation Rules](#dedentation-rules) for newline, closing-quote, and mixe
> Requires Python 3.14+.
-When a t-string interpolation evaluates to a multiline string, `dedent` aligns each subsequent line to the column where the interpolation begins. Column alignment is enabled by default.
+When a t-string interpolation evaluates to a multiline string, `dedent` aligns each subsequent line to the column where the interpolation begins. Column alignment is enabled by default. Do not wrap interpolations with `align()`; that wrapper is only for f-strings and raises `TypeError` here.
Use the `dedent`-specific `noalign` format specifier to disable alignment for an individual value:
@@ -110,7 +110,7 @@ print(result)
### f-string alignment
-> For Python 3.10-3.13. Use [t-string alignment](#t-string-alignment) on Python 3.14+.
+> For f-strings on every supported Python version. Use [t-string alignment](#t-string-alignment) on Python 3.14+ t-strings; `align()` inside a t-string interpolation raises `TypeError`.
Use `align()` with f-strings because Python 3.10-3.13 do not support t-strings:
diff --git a/src/dedent/_dedent.py b/src/dedent/_dedent.py
index 1ff1eb8..fed8762 100644
--- a/src/dedent/_dedent.py
+++ b/src/dedent/_dedent.py
@@ -159,6 +159,8 @@ def align(value: object) -> Aligned:
Wrap an interpolated value so that, when the surrounding f-string is passed to
`dedent()`, subsequent lines of the value start in the same column as the first.
+ t-string interpolations align automatically; an `align()`-marked value there raises
+ `TypeError`.
Example::
@@ -375,6 +377,9 @@ def _handle_item(
item: The interpolation to render.
preceding_text: The text that precedes this item, used for alignment.
+ Raises:
+ TypeError: If the rendered value contains `align()` markers.
+
Returns:
The processed string representation of the item.
"""
@@ -388,7 +393,8 @@ def _handle_item(
value = str(value)
if _ALIGN_MARKER.search(value):
- return process_align_markers(value, preceding_text=preceding_text)
+ message = "align() is only supported in f-strings; t-strings align automatically"
+ raise TypeError(message)
if not disable_alignment:
value = _align_value(value, preceding_text)
@@ -433,11 +439,15 @@ def dedent( # pyright: ignore[reportUnreachable]
directive:
- `{value:noalign}` - Disable column alignment for this value
+ `align()` is only for f-strings. A t-string interpolation whose rendered value contains
+ `align()` markers raises `TypeError`.
+
Args:
string: Template or literal string to dedent.
Raises:
IndentationError: If a line is incompatible with the common indentation prefix.
- TypeError: If the input is not a string or Template object.
+ TypeError: If the input is not a string or Template, or if a t-string interpolation
+ contains `align()` markers.
Returns:
The string with common leading whitespace and its opening newline removed. All other
diff --git a/tests/test_align.py b/tests/test_align.py
index 5de8660..0b7d340 100644
--- a/tests/test_align.py
+++ b/tests/test_align.py
@@ -10,7 +10,11 @@
def test_unaligned_multiline_value() -> None:
# Column-0 lines from interpolation prevent a common indent prefix.
- assert dedent_f(f"\n List:\n {ITEMS}\n ---\n ") == snapshot("""\
+ assert dedent_f(f"""
+ List:
+ {ITEMS}
+ ---
+ """) == snapshot("""\
List:
- apples
- bananas
@@ -71,7 +75,8 @@ def test_two_values_on_the_same_line() -> None:
def test_nested_wrappers() -> None:
inner = "line1\nline2"
outer = f"outer {align(inner)}"
- result = dedent_f(f" {align(outer)}")
+ result = dedent_f(f"""
+ {align(outer)}""")
assert result == snapshot("""\
outer line1
line2\
@@ -163,23 +168,14 @@ def test_noalign_disables_alignment() -> None:
@required_py314
-def test_align_wrapper_is_not_applied_twice() -> None:
- assert dedent(
- t(
- """
- List:
- {items}
- ---
- """,
- items=align(ITEMS),
- )
- ) == snapshot("""\
-List:
- - apples
- - bananas
- - cherries
----
-""")
+def test_align_wrapper_rejected_in_tstring() -> None:
+ message = r"align\(\) is only supported in f-strings; t-strings align automatically"
+ with pytest.raises(TypeError, match=message):
+ _ = dedent(t("{items}", items=align(ITEMS)))
+ with pytest.raises(TypeError, match=message):
+ _ = dedent(t("{items}", items=str(align(ITEMS))))
+ with pytest.raises(TypeError, match=message):
+ _ = dedent(t("{items:noalign}", items=align(ITEMS)))
@required_py314
From 0e26da7ddaad1f63c4c63c5aa0514b0ef3091664 Mon Sep 17 00:00:00 2001
From: Graham Preston <16179442+grahamcracker1234@users.noreply.github.com>
Date: Tue, 18 Aug 2026 12:52:30 -0400
Subject: [PATCH 11/15] Normalize whitespace on opening lines
Treat spaces and tabs before the first line ending as the opener while documenting runtime divergences from PEP 822.
---
README.md | 10 +++++++---
src/dedent/_dedent.py | 22 ++++++++++++++++++++--
tests/test_boundaries.py | 5 +++++
tests/test_interpolation.py | 5 +++++
4 files changed, 37 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index b39a129..8acae98 100644
--- a/README.md
+++ b/README.md
@@ -231,8 +231,12 @@ dedent("\n hello\n \t\n world\n ")
Here the nonblank lines and closing line establish a two-space prefix. The middle line contains one space followed by a tab, so that exact prefix cannot be removed. Raising an error catches mixed or malformed indentation instead of silently changing or preserving ambiguous whitespace. A shorter space/tab-only line is valid when it matches the beginning of the prefix; it simply becomes empty. This mirrors PEP 822.
-`dedent()` omits one opening newline, as PEP 822 requires. It preserves all other whitespace. Use the closing-quote placement when you do not want a final newline.
+### Divergences from PEP 822
-Unlike PEP 822's proposed d-strings, `dedent()` runs after Python parses a string. Python has therefore already processed escape sequences. For t-strings, `dedent()` processes literal text before rendering interpolations.
+PEP 822 defines new literal syntax, while `dedent()` processes runtime values. The differences are:
-For runtime strings that use CRLF, `dedent` preserves each `\r\n` pair and excludes the terminal `\r` from indentation checks. Other whitespace characters, such as form feed and non-breaking space, remain content.
+- PEP 822 requires the opening quotes to be followed immediately by a newline. `dedent()` also accepts spaces or tabs before that first line ending and treats the whole line as the opener. To preserve an intentional leading blank line, put it after the opener line. Values without an opening line ending are also accepted.
+- Python has already processed escape sequences before `dedent()` receives a string. For t-strings, `dedent()` processes literal text before rendering interpolations.
+- Runtime strings can contain CRLF line endings. `dedent()` preserves each `\r\n` pair and excludes the terminal `\r` from indentation checks.
+
+All other whitespace, including form feed and non-breaking space, remains content. Use the closing-quote placement when you do not want a final newline.
diff --git a/src/dedent/_dedent.py b/src/dedent/_dedent.py
index fed8762..3748916 100644
--- a/src/dedent/_dedent.py
+++ b/src/dedent/_dedent.py
@@ -16,6 +16,7 @@
rf"{_SEP}{_ALIGN_MARKER_PREFIX}:([a-f0-9]{{32}}){_SEP}(.*?){_SEP}{_ALIGN_MARKER_PREFIX}:\1{_SEP}",
re.DOTALL,
)
+_OPENING_LINE: Final = re.compile(r"^[ \t]*(\r\n|\r|\n)")
def _align_value(value: str, preceding_text: str) -> str:
@@ -117,6 +118,23 @@ def _dedent_string(string: str) -> str:
return "\n".join(dedented_lines)
+def _dedent_literal_structure(string: str) -> str:
+ """
+ Dedent a literal framework and omit its opening line.
+
+ Returns:
+ The dedented string. An initial line containing only spaces and tabs is
+ omitted when it ends with a line ending.
+ """
+ opening_line = _OPENING_LINE.match(string)
+ if opening_line is None:
+ return _dedent_string(string)
+
+ line_ending = opening_line.group(1)
+ normalized = line_ending + string[opening_line.end() :]
+ return _omit_opening_newline(_dedent_string(normalized))
+
+
@final
@dataclass(frozen=True, kw_only=True)
class Aligned:
@@ -312,7 +330,7 @@ def _dedent_and_fill(
raise RuntimeError(message)
if not holes:
- return _omit_opening_newline(_dedent_string(literals[0]))
+ return _dedent_literal_structure(literals[0])
marker_id = uuid4().hex
framework_parts = [literals[0]]
@@ -321,7 +339,7 @@ def _dedent_and_fill(
token = f"{_SEP}{_HOLE_MARKER_PREFIX}:{marker_id}:{index}{_SEP}"
framework_parts.extend((token, literal))
- framework = _omit_opening_newline(_dedent_string("".join(framework_parts)))
+ framework = _dedent_literal_structure("".join(framework_parts))
hole_marker = re.compile(rf"{_SEP}{_HOLE_MARKER_PREFIX}:{marker_id}:(\d+){_SEP}")
dedented_literals: list[str] = []
last_end = 0
diff --git a/tests/test_boundaries.py b/tests/test_boundaries.py
index cd790c6..c5b5b99 100644
--- a/tests/test_boundaries.py
+++ b/tests/test_boundaries.py
@@ -32,6 +32,11 @@ def test_omits_only_opening_newline() -> None:
""")
+def test_omits_whitespace_before_the_opening_newline() -> None:
+ assert dedent(" \n hello\n ") == snapshot("hello\n")
+ assert dedent("\t\n hello\n ") == snapshot("hello\n")
+
+
def test_keeps_trailing_spaces_on_content() -> None:
assert dedent(
"""
diff --git a/tests/test_interpolation.py b/tests/test_interpolation.py
index 11f6a4b..023a458 100644
--- a/tests/test_interpolation.py
+++ b/tests/test_interpolation.py
@@ -29,6 +29,11 @@ def test_tstring() -> None:
""")
+@required_py314
+def test_tstring_omits_whitespace_before_the_opening_newline() -> None:
+ assert dedent(t(" \n {value}\n ", value="hello")) == snapshot("hello\n")
+
+
@required_py314
def test_tstring_format_spec() -> None:
template_result = dedent(
From cd9e0c49d2dbd3244b1a5eb1990a5048034caa33 Mon Sep 17 00:00:00 2001
From: Graham Preston <16179442+grahamcracker1234@users.noreply.github.com>
Date: Tue, 18 Aug 2026 12:58:05 -0400
Subject: [PATCH 12/15] Expand t-string interpolation coverage
Protect conversion handling and column tracking across consecutive multiline values.
---
tests/test_interpolation.py | 23 +++++++++++++++++++++++
1 file changed, 23 insertions(+)
diff --git a/tests/test_interpolation.py b/tests/test_interpolation.py
index 023a458..0dfe224 100644
--- a/tests/test_interpolation.py
+++ b/tests/test_interpolation.py
@@ -29,6 +29,29 @@ def test_tstring() -> None:
""")
+@required_py314
+def test_tstring_conversions() -> None:
+ assert dedent(t("{value!s}", value="hello")) == snapshot("hello")
+ assert dedent(t("{value!r}", value="hello")) == snapshot("'hello'")
+
+
+@required_py314
+def test_two_tstring_values_on_the_same_line() -> None:
+ assert dedent(
+ t(
+ """
+ {a} + {b}
+ """,
+ a="a1\na2",
+ b="b1\nb2",
+ )
+ ) == snapshot("""\
+a1
+a2 + b1
+ b2
+""")
+
+
@required_py314
def test_tstring_omits_whitespace_before_the_opening_newline() -> None:
assert dedent(t(" \n {value}\n ", value="hello")) == snapshot("hello\n")
From bc5e60c9f36b8b094142802b766440bd59542ef8 Mon Sep 17 00:00:00 2001
From: Graham Preston <16179442+grahamcracker1234@users.noreply.github.com>
Date: Tue, 18 Aug 2026 13:06:17 -0400
Subject: [PATCH 13/15] Simplify interpolation hole dedentation
Keep literal parts structural so dedentation no longer requires UUID markers or regex reconstruction.
---
src/dedent/_dedent.py | 123 +++++++++++++++++++++++-------------------
1 file changed, 69 insertions(+), 54 deletions(-)
diff --git a/src/dedent/_dedent.py b/src/dedent/_dedent.py
index 3748916..450ecb4 100644
--- a/src/dedent/_dedent.py
+++ b/src/dedent/_dedent.py
@@ -8,7 +8,6 @@
_T = TypeVar("_T")
_ALIGN_MARKER_PREFIX: Final = "DEDENT_ALIGN"
-_HOLE_MARKER_PREFIX: Final = "DEDENT_HOLE"
_NOALIGN_SPEC: Final = "noalign"
_SEP: Final = "\x00"
@@ -57,9 +56,9 @@ def _omit_opening_newline(string: str) -> str:
return string
-def _dedent_string(string: str) -> str:
+def _common_indentation(string: str) -> str | None:
"""
- Remove the longest common leading indentation from a string.
+ Find and validate the longest common leading indentation in a string.
This follows the indentation rules proposed by PEP 822: indentation is an
exact prefix of spaces and tabs, space/tab-only lines are ignored, and a
@@ -68,28 +67,25 @@ def _dedent_string(string: str) -> str:
of the line ending rather than treated as content.
Args:
- string: The string to dedent.
+ string: The literal framework to inspect.
Raises:
IndentationError: If a line's leading whitespace is inconsistent with the
common indentation prefix.
Returns:
- The dedented string.
+ The common indentation, or `None` when no line determines it.
"""
- lines = [
- (line.removesuffix("\r"), "\r" if line.endswith("\r") else "")
- for line in string.split("\n")
- ]
+ lines = [line.removesuffix("\r") for line in string.split("\n")]
last_index = len(lines) - 1
indentations = [
line[: len(line) - len(line.lstrip(" \t"))]
- for index, (line, _) in enumerate(lines)
+ for index, line in enumerate(lines)
if line.strip(" \t") or (last_index > 0 and index == last_index)
]
if not indentations:
- return string
+ return None
indentation = indentations[0]
for candidate in indentations[1:]:
@@ -100,39 +96,80 @@ def _dedent_string(string: str) -> str:
common_length += 1
indentation = indentation[:common_length]
if not indentation:
- return string
+ break
- dedented_lines: list[str] = []
- for line_number, (line, line_ending) in enumerate(lines, start=1):
+ for line_number, line in enumerate(lines, start=1):
if len(line) >= len(indentation):
- if not line.startswith(indentation):
- message = f"inconsistent indentation in dedented string at line {line_number}"
- raise IndentationError(message)
- dedented_lines.append(line[len(indentation) :] + line_ending)
- elif indentation.startswith(line):
- dedented_lines.append(line_ending)
+ is_compatible = line.startswith(indentation)
else:
+ is_compatible = indentation.startswith(line)
+ if not is_compatible:
message = f"inconsistent indentation in dedented string at line {line_number}"
raise IndentationError(message)
- return "\n".join(dedented_lines)
+ return indentation
-def _dedent_literal_structure(string: str) -> str:
+def _remove_indentation(literals: list[str], indentation: str) -> list[str]:
"""
- Dedent a literal framework and omit its opening line.
+ Remove indentation from literal parts without crossing their opaque holes.
Returns:
- The dedented string. An initial line containing only spaces and tabs is
- omitted when it ends with a line ending.
+ The dedented literal parts.
+ """
+ if not indentation:
+ return literals
+
+ dedented_literals: list[str] = []
+ at_line_start = True
+ removed = 0
+ last_index = len(literals) - 1
+
+ for literal_index, literal in enumerate(literals):
+ dedented: list[str] = []
+ for character in literal:
+ if character == "\n":
+ dedented.append(character)
+ at_line_start = True
+ removed = 0
+ elif at_line_start and removed < len(indentation) and character in " \t":
+ removed += 1
+ else:
+ dedented.append(character)
+ at_line_start = False
+
+ dedented_literals.append("".join(dedented))
+ if literal_index < last_index:
+ at_line_start = False
+
+ return dedented_literals
+
+
+def _dedent_literal_parts(literals: list[str]) -> list[str]:
"""
- opening_line = _OPENING_LINE.match(string)
- if opening_line is None:
- return _dedent_string(string)
+ Dedent literal parts while treating the holes between them as opaque content.
+
+ Returns:
+ The dedented parts, with a spaces-and-tabs-only opening line omitted.
+ """
+ normalized_literals = literals.copy()
+ opening_line = _OPENING_LINE.match(normalized_literals[0])
+ if opening_line is not None:
+ line_ending = opening_line.group(1)
+ normalized_literals[0] = line_ending + normalized_literals[0][opening_line.end() :]
+
+ framework = _SEP.join(normalized_literals)
+ indentation = _common_indentation(framework)
+ dedented_literals = (
+ normalized_literals
+ if indentation is None
+ else _remove_indentation(normalized_literals, indentation)
+ )
- line_ending = opening_line.group(1)
- normalized = line_ending + string[opening_line.end() :]
- return _omit_opening_newline(_dedent_string(normalized))
+ if opening_line is not None:
+ dedented_literals[0] = _omit_opening_newline(dedented_literals[0])
+
+ return dedented_literals
@final
@@ -329,29 +366,7 @@ def _dedent_and_fill(
message = "literal and hole counts are inconsistent"
raise RuntimeError(message)
- if not holes:
- return _dedent_literal_structure(literals[0])
-
- marker_id = uuid4().hex
- framework_parts = [literals[0]]
-
- for index, literal in enumerate(literals[1:]):
- token = f"{_SEP}{_HOLE_MARKER_PREFIX}:{marker_id}:{index}{_SEP}"
- framework_parts.extend((token, literal))
-
- framework = _dedent_literal_structure("".join(framework_parts))
- hole_marker = re.compile(rf"{_SEP}{_HOLE_MARKER_PREFIX}:{marker_id}:(\d+){_SEP}")
- dedented_literals: list[str] = []
- last_end = 0
-
- for match in hole_marker.finditer(framework):
- if int(match.group(1)) != len(dedented_literals):
- message = "dedent hole markers are out of order"
- raise RuntimeError(message)
- dedented_literals.append(framework[last_end : match.start()])
- last_end = match.end()
-
- dedented_literals.append(framework[last_end:])
+ dedented_literals = _dedent_literal_parts(literals)
return _fill_parts(dedented_literals, holes, render)
From 32ba6bc818d77305eb0401e483833d30328bbded Mon Sep 17 00:00:00 2001
From: Graham Preston <16179442+grahamcracker1234@users.noreply.github.com>
Date: Tue, 18 Aug 2026 13:48:40 -0400
Subject: [PATCH 14/15] Document terminal-newline alignment
---
src/dedent/_dedent.py | 1 +
tests/test_align.py | 8 ++++++++
2 files changed, 9 insertions(+)
diff --git a/src/dedent/_dedent.py b/src/dedent/_dedent.py
index 450ecb4..50cd26b 100644
--- a/src/dedent/_dedent.py
+++ b/src/dedent/_dedent.py
@@ -23,6 +23,7 @@ def _align_value(value: str, preceding_text: str) -> str:
Align a multiline value to the column where it is inserted.
If the value contains newlines, each line after the first starts directly below the first.
+ Alignment is also added after a terminal newline. This keeps later text at the insertion column.
Args:
value: The string value to align, potentially containing newlines.
diff --git a/tests/test_align.py b/tests/test_align.py
index 0b7d340..f8b2020 100644
--- a/tests/test_align.py
+++ b/tests/test_align.py
@@ -40,6 +40,14 @@ def test_align_wrapper() -> None:
""")
+def test_alignment_after_terminal_newline_preserves_interpolation_column() -> None:
+ value = "item\n"
+ assert dedent_f(f"""
+ List:
+ {align(value)}
+ """) == snapshot("List:\n item\n \n")
+
+
def test_two_values() -> None:
a = "line1\nline2"
b = "foo\nbar"
From f57c91d98af80e7d5e13cff375cdc48d6aff7ba5 Mon Sep 17 00:00:00 2001
From: Graham Preston <16179442+grahamcracker1234@users.noreply.github.com>
Date: Tue, 18 Aug 2026 14:14:33 -0400
Subject: [PATCH 15/15] Simplify interpolation rendering paths
---
src/dedent/_dedent.py | 52 +++++++++++--------------------------------
1 file changed, 13 insertions(+), 39 deletions(-)
diff --git a/src/dedent/_dedent.py b/src/dedent/_dedent.py
index 50cd26b..fc8e438 100644
--- a/src/dedent/_dedent.py
+++ b/src/dedent/_dedent.py
@@ -313,40 +313,20 @@ def _split_align_markers(string: str) -> tuple[list[str], list[str]]:
return literals, values
-def _render_align_marker(value: str, preceding_text: str) -> str:
+def _render_align_value(value: str, preceding_text: str) -> str:
"""
- Render one aligned value, processing nested markers first.
+ Render one aligned value after recursively rendering its nested values.
Returns:
The marker-free value aligned to its preceding text.
"""
- return _align_value(process_align_markers(value), preceding_text)
-
-
-def process_align_markers(string: str, *, preceding_text: str = "") -> str:
- """
- Detect alignment markers in `string`, apply indentation alignment, and remove the markers.
-
- This is called by `dedent()` when it receives a plain string (the f-string path).
- Markers are inserted by `Aligned` wrappers created via `align`.
-
- Args:
- string: The string potentially containing alignment markers.
- preceding_text: Text preceding `string`, used to align a marker at its start.
-
- Returns:
- The string with markers removed and aligned values indented appropriately.
- """
- literals, values = _split_align_markers(string)
- if not values:
- return string
-
- return _fill_parts(
+ literals, nested_values = _split_align_markers(value)
+ rendered = _fill_parts(
literals,
- values,
- _render_align_marker,
- preceding_text=preceding_text,
+ nested_values,
+ _render_align_value,
)
+ return _align_value(rendered, preceding_text)
def _dedent_and_fill(
@@ -379,7 +359,7 @@ def _dedent_marked_string(string: str) -> str:
The dedented string with deferred values restored and aligned.
"""
literals, values = _split_align_markers(string)
- return _dedent_and_fill(literals, values, _render_align_marker)
+ return _dedent_and_fill(literals, values, _render_align_value)
if sys.version_info >= (3, 14):
@@ -441,21 +421,15 @@ def _dedent_template(template: Template) -> str:
Returns:
The dedented and rendered template.
"""
- interpolations: list[Interpolation[object]] = []
- literals = [""]
-
- for item in template:
- if isinstance(item, str):
- literals[-1] += item
- continue
-
- interpolations.append(item)
- literals.append("")
def render_interpolation(item: Interpolation[object], preceding_text: str) -> str:
return _handle_item(item, preceding_text=preceding_text)
- return _dedent_and_fill(literals, interpolations, render_interpolation)
+ return _dedent_and_fill(
+ list(template.strings),
+ list(template.interpolations),
+ render_interpolation,
+ )
def dedent( # pyright: ignore[reportUnreachable]
string: Template | LiteralString,