Skip to content

Commit d7857fb

Browse files
authored
Use builtin formatter in CI (#3380)
* Use builtin formatter in CI * Handle PEP 695 aliases in builtin formatter * Cover builtin formatter CI helpers
1 parent 0c49f24 commit d7857fb

10 files changed

Lines changed: 598 additions & 28 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@ markers = [
285285
"perf: marks tests as performance tests (excluded from CI benchmarks)",
286286
"benchmark: marks tests as benchmark tests",
287287
"allow_direct_assert: marks guarded tests where direct assert statements are intentionally allowed",
288+
"isolate_builtin_formatter_config: marks tests that must not inherit repository formatter config",
288289
]
289290

290291
[tool.coverage]

src/datamodel_code_generator/_builtin_formatter.py

Lines changed: 116 additions & 7 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]:
@@ -183,7 +185,76 @@ def _import_category(module: str, level: int, known_first_party: frozenset[str])
183185
def _has_inline_comment(lines: list[str], node: ast.AST) -> bool:
184186
lineno = getattr(node, "lineno", 1)
185187
end_lineno = getattr(node, "end_lineno", None) or lineno
186-
return any("#" in line for line in lines[lineno - 1 : end_lineno])
188+
return any(_has_comment_token(line) for line in lines[lineno - 1 : end_lineno])
189+
190+
191+
def _has_comment_token(line: str) -> bool:
192+
try:
193+
tokens = tokenize.generate_tokens(StringIO(line).readline)
194+
return any(token.type == tokenize.COMMENT for token in tokens)
195+
except tokenize.TokenError:
196+
return "#" in line
197+
198+
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
187258

188259

189260
def _format_import_node_without_reordering(
@@ -1031,7 +1102,7 @@ def _format_generated_class_statement(
10311102
for keyword in config_dict[1].keywords
10321103
)
10331104
)
1034-
if (len(line) <= line_length and not config_dict_needs_formatting) or "#" in line:
1105+
if (len(line) <= line_length and not config_dict_needs_formatting) or _has_comment_token(line):
10351106
return None
10361107

10371108
if isinstance(statement, ast.FunctionDef):
@@ -1289,7 +1360,7 @@ def _format_generated_class_definition(
12891360
line_length: int,
12901361
source: str,
12911362
) -> str | None:
1292-
if len(line) <= line_length or "#" in line:
1363+
if len(line) <= line_length or _has_comment_token(line):
12931364
return None
12941365

12951366
indent = _line_indent(line)
@@ -1321,7 +1392,7 @@ def _format_generated_class_definition(
13211392
def _format_generated_module_statement( # noqa: PLR0911
13221393
statement: ast.stmt, line: str, line_length: int, source: str
13231394
) -> str | None:
1324-
if "#" in line:
1395+
if _has_comment_token(line):
13251396
return None
13261397
if (
13271398
isinstance(statement, ast.AnnAssign)
@@ -1386,6 +1457,43 @@ def _format_type_checking_block(
13861457
_LineReplacement = tuple[int, int, list[str]]
13871458

13881459

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+
13891497
def _collect_builtin_replacements( # noqa: PLR0912, PLR0913
13901498
tree: ast.Module,
13911499
lines: list[str],
@@ -1615,9 +1723,8 @@ def apply_builtin_formatter( # noqa: PLR0913
16151723
if not code:
16161724
return ""
16171725

1618-
try:
1619-
tree = ast.parse(code, feature_version=python_version.version_key if python_version is not None else None)
1620-
except SyntaxError:
1726+
tree = _parse_builtin_code(code, python_version)
1727+
if tree is None:
16211728
return f"{code}\n"
16221729

16231730
replacements = _collect_builtin_replacements(
@@ -1628,6 +1735,8 @@ def apply_builtin_formatter( # noqa: PLR0913
16281735
known_first_party,
16291736
wrap_string_literal=wrap_string_literal,
16301737
)
1738+
if _needs_pep695_type_alias_placeholders(python_version):
1739+
replacements.extend(_collect_pep695_type_alias_replacements(lines, line_length))
16311740
import_nodes = _iter_module_import_nodes(tree)
16321741

16331742
if not import_nodes:

tests/conftest.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import importlib
77
import inspect
88
import json
9+
import os
910
import re
1011
import socket
1112
import sys
@@ -34,6 +35,8 @@
3435

3536
CLI_DOC_COLLECTION_OUTPUT = Path(__file__).parent / "cli_doc" / ".cli_doc_collection.json"
3637
CLI_DOC_SCHEMA_VERSION = 1
38+
TEST_DEFAULT_FORMATTER_ENV = "DATAMODEL_CODE_GENERATOR_TEST_DEFAULT_FORMATTER"
39+
BUILTIN_FORMATTER_VALUE = "builtin"
3740
_VERSION_PATTERN = re.compile(r"^\d+\.\d+$")
3841
_MOCK_PUBLIC_IP = "93.184.216.34"
3942

@@ -1127,6 +1130,24 @@ def assert_generated_file_matches_output(result: object, output_file: Path) -> N
11271130
raise AssertionError(msg)
11281131

11291132

1133+
@pytest.fixture(autouse=True)
1134+
def _isolate_builtin_formatter_config(
1135+
request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory
1136+
) -> None:
1137+
"""Keep marked tests from inheriting the repository Ruff formatter config."""
1138+
if request.node.get_closest_marker("isolate_builtin_formatter_config") is None:
1139+
return
1140+
if os.environ.get(TEST_DEFAULT_FORMATTER_ENV) != BUILTIN_FORMATTER_VALUE:
1141+
return
1142+
1143+
settings_path = tmp_path_factory.mktemp("builtin_formatter_config")
1144+
(settings_path / "pyproject.toml").write_text(
1145+
"[tool.datamodel-codegen]\nbuiltin-format-line-length = 88\n",
1146+
encoding="utf-8",
1147+
)
1148+
monkeypatch.chdir(settings_path)
1149+
1150+
11301151
@pytest.fixture(autouse=True)
11311152
def _inline_snapshot_file_formats() -> None:
11321153
register_format_alias(".py", ".txt")

0 commit comments

Comments
 (0)