Skip to content

Commit f7c08eb

Browse files
committed
Handle PEP 695 aliases in builtin formatter
1 parent 148c0da commit f7c08eb

2 files changed

Lines changed: 141 additions & 6 deletions

File tree

src/datamodel_code_generator/_builtin_formatter.py

Lines changed: 106 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ class _ImportCategory(IntEnum):
4545
LONG_TARGET_PREFIX_LENGTH = 30
4646
TYPE_ALIAS_INLINE_ARGUMENT_COUNT = 2
4747
STRING_PREFIX_PATTERN = re.compile(r"(?i)^([rubf]*)(\"\"\"|'''|\"|')")
48+
PEP695_TYPE_ALIAS_START_PATTERN = re.compile(r"^(?P<indent>\s*)type\s+(?P<target>[A-Za-z_]\w*(?:\[.*?\])?)\s*=")
49+
PEP695_TYPE_ALIAS_PLACEHOLDER = "__datamodel_codegen_builtin_type_alias__"
4850

4951

5052
def _is_valid_builtin_line_length(line_length: Any) -> TypeGuard[int]:
@@ -194,6 +196,67 @@ def _has_comment_token(line: str) -> bool:
194196
return "#" in line
195197

196198

199+
def _needs_pep695_type_alias_placeholders(python_version: PythonVersion | None) -> bool:
200+
return (
201+
python_version is not None
202+
and python_version.version_key >= (3, 12)
203+
and (3, 10) <= sys.version_info[:2] < (3, 12)
204+
)
205+
206+
207+
def _pep695_type_alias_statement_end(lines: list[str], start_index: int) -> int:
208+
bracket_depth = 0
209+
try:
210+
tokens = tokenize.generate_tokens(StringIO("\n".join(lines[start_index:])).readline)
211+
for token in tokens:
212+
if token.type == tokenize.OP:
213+
if token.string in {"(", "[", "{"}:
214+
bracket_depth += 1
215+
elif token.string in {")", "]", "}"}:
216+
bracket_depth -= 1
217+
if token.type == tokenize.NEWLINE and bracket_depth <= 0:
218+
return start_index + token.end[0] - 1
219+
except tokenize.TokenError:
220+
return start_index
221+
return start_index
222+
223+
224+
def _replace_pep695_type_aliases_with_placeholders(code: str) -> str:
225+
lines = code.splitlines()
226+
placeholder_lines = list(lines)
227+
line_index = 0
228+
while line_index < len(lines):
229+
line = lines[line_index]
230+
if not PEP695_TYPE_ALIAS_START_PATTERN.match(line):
231+
line_index += 1
232+
continue
233+
234+
indent = _line_indent(line)
235+
end_index = _pep695_type_alias_statement_end(lines, line_index)
236+
placeholder_lines[line_index] = f"{indent}{PEP695_TYPE_ALIAS_PLACEHOLDER} = None"
237+
for continuation_index in range(line_index + 1, end_index + 1):
238+
placeholder_lines[continuation_index] = ""
239+
line_index = end_index + 1
240+
return "\n".join(placeholder_lines)
241+
242+
243+
def _parse_builtin_code(code: str, python_version: PythonVersion | None) -> ast.Module | None:
244+
feature_version = python_version.version_key if python_version is not None else None
245+
try:
246+
return ast.parse(code, feature_version=feature_version)
247+
except SyntaxError:
248+
if not _needs_pep695_type_alias_placeholders(python_version):
249+
return None
250+
251+
placeholder_code = _replace_pep695_type_aliases_with_placeholders(code)
252+
if placeholder_code == code:
253+
return None
254+
try:
255+
return ast.parse(placeholder_code, feature_version=feature_version)
256+
except SyntaxError:
257+
return None
258+
259+
197260
def _format_import_node_without_reordering(
198261
node: ast.Import | ast.ImportFrom,
199262
lines: list[str],
@@ -1297,7 +1360,7 @@ def _format_generated_class_definition(
12971360
line_length: int,
12981361
source: str,
12991362
) -> str | None:
1300-
if len(line) <= line_length or "#" in line:
1363+
if len(line) <= line_length or _has_comment_token(line):
13011364
return None
13021365

13031366
indent = _line_indent(line)
@@ -1329,7 +1392,7 @@ def _format_generated_class_definition(
13291392
def _format_generated_module_statement( # noqa: PLR0911
13301393
statement: ast.stmt, line: str, line_length: int, source: str
13311394
) -> str | None:
1332-
if "#" in line:
1395+
if _has_comment_token(line):
13331396
return None
13341397
if (
13351398
isinstance(statement, ast.AnnAssign)
@@ -1394,6 +1457,43 @@ def _format_type_checking_block(
13941457
_LineReplacement = tuple[int, int, list[str]]
13951458

13961459

1460+
def _pep695_type_alias_replacement(lines: list[str], start_index: int, line_length: int) -> _LineReplacement | None:
1461+
line = lines[start_index]
1462+
if (match := PEP695_TYPE_ALIAS_START_PATTERN.match(line)) is None:
1463+
return None
1464+
1465+
end_index = _pep695_type_alias_statement_end(lines, start_index)
1466+
statement_lines = lines[start_index : end_index + 1]
1467+
if any(_has_comment_token(statement_line) for statement_line in statement_lines):
1468+
return None
1469+
1470+
rhs_first_line = line[match.end() :].lstrip()
1471+
rhs_source = "\n".join([rhs_first_line, *statement_lines[1:]])
1472+
try:
1473+
value = ast.parse(rhs_source, mode="eval").body
1474+
except SyntaxError:
1475+
return None
1476+
if not _is_annotated(value) or len(line) <= line_length:
1477+
return None
1478+
1479+
indent = match.group("indent")
1480+
target = match.group("target")
1481+
formatted_value = _format_annotated(value, indent, line_length, rhs_source)
1482+
return start_index + 1, end_index + 1, f"{indent}type {target} = {formatted_value}".splitlines()
1483+
1484+
1485+
def _collect_pep695_type_alias_replacements(lines: list[str], line_length: int) -> list[_LineReplacement]:
1486+
replacements: list[_LineReplacement] = []
1487+
line_index = 0
1488+
while line_index < len(lines):
1489+
if (replacement := _pep695_type_alias_replacement(lines, line_index, line_length)) is None:
1490+
line_index += 1
1491+
continue
1492+
replacements.append(replacement)
1493+
line_index = replacement[1]
1494+
return replacements
1495+
1496+
13971497
def _collect_builtin_replacements( # noqa: PLR0912, PLR0913
13981498
tree: ast.Module,
13991499
lines: list[str],
@@ -1623,9 +1723,8 @@ def apply_builtin_formatter( # noqa: PLR0913
16231723
if not code:
16241724
return ""
16251725

1626-
try:
1627-
tree = ast.parse(code, feature_version=python_version.version_key if python_version is not None else None)
1628-
except SyntaxError:
1726+
tree = _parse_builtin_code(code, python_version)
1727+
if tree is None:
16291728
return f"{code}\n"
16301729

16311730
replacements = _collect_builtin_replacements(
@@ -1636,6 +1735,8 @@ def apply_builtin_formatter( # noqa: PLR0913
16361735
known_first_party,
16371736
wrap_string_literal=wrap_string_literal,
16381737
)
1738+
if _needs_pep695_type_alias_placeholders(python_version):
1739+
replacements.extend(_collect_pep695_type_alias_replacements(lines, line_length))
16391740
import_nodes = _iter_module_import_nodes(tree)
16401741

16411742
if not import_nodes:

tests/test_format.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -807,7 +807,6 @@ def test_apply_builtin_formatter_normalizes_type_alias_blank_lines_without_impor
807807
assert apply_builtin_formatter(code) == "type Foo = str\n\n\ntype Bar = Foo\n"
808808

809809

810-
@pytest.mark.skipif(sys.version_info < (3, 12), reason="type statements require Python 3.12")
811810
def test_builtin_formatter_respects_target_python_version_for_ast_parse() -> None:
812811
"""Test built-in formatter parses code using the configured target Python version."""
813812
code = "type Foo = str\n\n\n\ntype Bar = Foo\n"
@@ -819,6 +818,41 @@ def test_builtin_formatter_respects_target_python_version_for_ast_parse() -> Non
819818
assert py312_formatter.format_code(code) == "type Foo = str\n\n\ntype Bar = Foo\n"
820819

821820

821+
def test_builtin_formatter_formats_type_alias_modules_on_older_runtimes() -> None:
822+
"""Test built-in formatter handles target Python 3.12 type aliases on older runtimes."""
823+
code = (
824+
"from typing import Annotated\n"
825+
"from pydantic import Field\n"
826+
"\n"
827+
"\n"
828+
"type Alias = Annotated[str | bool, Field(..., "
829+
"description='An annotated union type', title='MyAnnotatedType')]\n"
830+
"\n"
831+
"\n"
832+
"\n"
833+
"class Model:\n"
834+
" x_value: Annotated[Alias | None, Field(validate_default=True)] = {'type': 'b', 'value': 1}\n"
835+
)
836+
837+
assert apply_builtin_formatter(code, line_length=88, python_version=PythonVersion.PY_312) == (
838+
"from typing import Annotated\n"
839+
"\n"
840+
"from pydantic import Field\n"
841+
"\n"
842+
"type Alias = Annotated[\n"
843+
" str | bool,\n"
844+
" Field(..., description='An annotated union type', title='MyAnnotatedType'),\n"
845+
"]\n"
846+
"\n"
847+
"\n"
848+
"class Model:\n"
849+
" x_value: Annotated[Alias | None, Field(validate_default=True)] = {\n"
850+
" 'type': 'b',\n"
851+
" 'value': 1,\n"
852+
" }\n"
853+
)
854+
855+
822856
def test_apply_builtin_formatter_parenthesizes_short_annotated_default() -> None:
823857
"""Test built-in formatter matches black for short overflowing Annotated defaults."""
824858
code = (

0 commit comments

Comments
 (0)