Skip to content

Commit 84c8b72

Browse files
authored
Cache builtin formatter source lines (#3552)
* Cache builtin formatter source lines * Satisfy source segment typing * Avoid source lines cache global assignment * Cover builtin formatter location fallbacks
1 parent 15fd1a7 commit 84c8b72

2 files changed

Lines changed: 136 additions & 2 deletions

File tree

src/datamodel_code_generator/_builtin_formatter.py

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from collections import defaultdict
1010
from enum import IntEnum
1111
from io import StringIO
12-
from typing import TYPE_CHECKING, Any, TypeGuard
12+
from typing import TYPE_CHECKING, Any, TypeGuard, cast
1313

1414
from datamodel_code_generator.util import load_toml
1515

@@ -23,6 +23,12 @@ class PythonVersion(Protocol):
2323
def version_key(self) -> tuple[int, int]:
2424
raise NotImplementedError
2525

26+
class _SourceLocationNode(Protocol):
27+
lineno: int
28+
end_lineno: int | None
29+
col_offset: int
30+
end_col_offset: int | None
31+
2632

2733
class _AliasSortCategory(IntEnum):
2834
CONSTANT = 0
@@ -47,6 +53,7 @@ class _ImportCategory(IntEnum):
4753
STRING_PREFIX_PATTERN = re.compile(r"(?i)^([rubf]*)(\"\"\"|'''|\"|')")
4854
PEP695_TYPE_ALIAS_START_PATTERN = re.compile(r"^(?P<indent>\s*)type\s+(?P<target>[A-Za-z_]\w*(?:\[.*?\])?)\s*=")
4955
PEP695_TYPE_ALIAS_PLACEHOLDER = "__datamodel_codegen_builtin_type_alias__"
56+
_SOURCE_LINES_CACHE: list[tuple[str, list[str]]] = []
5057

5158

5259
def _is_valid_builtin_line_length(line_length: Any) -> TypeGuard[int]:
@@ -417,8 +424,70 @@ def _is_type_checking_if(node: ast.AST) -> TypeGuard[ast.If]:
417424
return isinstance(node, ast.If) and _is_name_or_attr(node.test, "TYPE_CHECKING")
418425

419426

427+
def _splitlines_no_ff(source: str) -> list[str]:
428+
"""Split source lines like the Python parser, without form-feed splitting."""
429+
index = 0
430+
lines: list[str] = []
431+
next_line = ""
432+
while index < len(source):
433+
character = source[index]
434+
next_line += character
435+
index += 1
436+
437+
if character == "\r" and index < len(source) and source[index] == "\n":
438+
next_line += "\n"
439+
index += 1
440+
if character in "\r\n":
441+
lines.append(next_line)
442+
next_line = ""
443+
444+
if next_line:
445+
lines.append(next_line)
446+
return lines
447+
448+
449+
def _source_lines(source: str) -> list[str]:
450+
if _SOURCE_LINES_CACHE:
451+
cached_source, cached_lines = _SOURCE_LINES_CACHE[0]
452+
if source is cached_source or source == cached_source:
453+
return cached_lines
454+
455+
lines = _splitlines_no_ff(source)
456+
_SOURCE_LINES_CACHE[:] = [(source, lines)]
457+
return lines
458+
459+
460+
def _source_segment_from_cached_lines(source: str, node: ast.AST) -> str | None:
461+
if not (
462+
hasattr(node, "lineno")
463+
and hasattr(node, "end_lineno")
464+
and hasattr(node, "col_offset")
465+
and hasattr(node, "end_col_offset")
466+
):
467+
return None
468+
469+
location_node = cast("_SourceLocationNode", node)
470+
lineno = location_node.lineno
471+
end_lineno = location_node.end_lineno
472+
col_offset = location_node.col_offset
473+
end_col_offset = location_node.end_col_offset
474+
if end_lineno is None or end_col_offset is None:
475+
return None
476+
477+
lineno -= 1
478+
end_lineno -= 1
479+
480+
lines = _source_lines(source)
481+
if end_lineno == lineno:
482+
return lines[lineno].encode()[col_offset:end_col_offset].decode()
483+
484+
first = lines[lineno].encode()[col_offset:].decode()
485+
last = lines[end_lineno].encode()[:end_col_offset].decode()
486+
return "".join([first, *lines[lineno + 1 : end_lineno], last])
487+
488+
420489
def _source_segment(source: str, node: ast.AST) -> str:
421-
return ast.get_source_segment(source, node) or ast.unparse(node)
490+
return _source_segment_from_cached_lines(source, node) or ast.unparse(node)
422491

423492

424493
def _inline_source_segment(source: str, node: ast.AST) -> str:

tests/test_format.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,6 +1052,71 @@ def test_builtin_formatter_pep695_type_alias_replacement_edges() -> None:
10521052
]
10531053

10541054

1055+
def test_builtin_formatter_source_segment_matches_ast() -> None:
1056+
"""Source extraction should match ast.get_source_segment, including UTF-8 byte offsets."""
1057+
source = (
1058+
"from typing import Annotated\r\n"
1059+
"from pydantic import Field\n"
1060+
"\n"
1061+
"EXTRA = {'emoji': '🍣'}\n"
1062+
"\n"
1063+
"class Model:\n"
1064+
' """説明."""\n'
1065+
" field: Annotated[\n"
1066+
" str,\n"
1067+
" Field(default='é', description='長い説明'),\n"
1068+
" ]\n"
1069+
" data = {\n"
1070+
" 'emoji': '🍣',\n"
1071+
" **EXTRA,\n"
1072+
" }\n"
1073+
"\n"
1074+
"lambda_value = lambda: '空'\n"
1075+
)
1076+
1077+
for node in ast.walk(ast.parse(source)):
1078+
expected = ast.get_source_segment(source, node)
1079+
if not expected:
1080+
continue
1081+
1082+
assert builtin_formatter._source_segment(source, node) == expected
1083+
1084+
1085+
def test_builtin_formatter_source_segment_reuses_split_source_lines(monkeypatch: pytest.MonkeyPatch) -> None:
1086+
"""Repeated source extraction should split one source object once."""
1087+
source = "class Model:\n field: str = 'é'\n other: str = '🍣'\n"
1088+
nodes = [node for node in ast.walk(ast.parse(source)) if ast.get_source_segment(source, node)]
1089+
split_call_count = 0
1090+
original_splitlines = builtin_formatter._splitlines_no_ff
1091+
1092+
def spy_splitlines(value: str) -> list[str]:
1093+
nonlocal split_call_count
1094+
split_call_count += 1
1095+
return original_splitlines(value)
1096+
1097+
monkeypatch.setattr(builtin_formatter, "_SOURCE_LINES_CACHE", [])
1098+
monkeypatch.setattr(builtin_formatter, "_splitlines_no_ff", spy_splitlines)
1099+
1100+
for node in nodes:
1101+
builtin_formatter._source_segment(source, node)
1102+
1103+
assert split_call_count == 1
1104+
1105+
1106+
def test_builtin_formatter_source_segment_handles_missing_locations() -> None:
1107+
"""Source extraction should fall back when a node lacks complete location data."""
1108+
source = "field: str\n"
1109+
node_without_location = ast.Load()
1110+
node_without_end_location = ast.Name(id="field", ctx=ast.Load())
1111+
node_without_end_location.lineno = 1
1112+
node_without_end_location.col_offset = 0
1113+
node_without_end_location.end_lineno = None
1114+
node_without_end_location.end_col_offset = None
1115+
1116+
assert builtin_formatter._source_segment_from_cached_lines(source, node_without_location) is None
1117+
assert builtin_formatter._source_segment_from_cached_lines(source, node_without_end_location) is None
1118+
1119+
10551120
def test_apply_builtin_formatter_parenthesizes_short_annotated_default() -> None:
10561121
"""Test built-in formatter matches black for short overflowing Annotated defaults."""
10571122
code = (

0 commit comments

Comments
 (0)