Skip to content

Commit e1fe42e

Browse files
authored
Add force_exec_validation option to catch runtime errors across Python versions (#2719)
1 parent d637b0b commit e1fe42e

2 files changed

Lines changed: 219 additions & 8 deletions

File tree

tests/main/conftest.py

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@
5353
reason=f"Installed black ({black.__version__}) doesn't support Python 3.14",
5454
)
5555

56+
CURRENT_PYTHON_VERSION = f"{sys.version_info[0]}.{sys.version_info[1]}"
57+
"""Current Python version as string (e.g., '3.13')."""
58+
5659
DATA_PATH: Path = Path(__file__).parent.parent / "data"
5760
EXPECTED_MAIN_PATH: Path = DATA_PATH / "expected" / "main"
5861

@@ -102,6 +105,21 @@ def output_dir(tmp_path: Path) -> Path:
102105
return tmp_path / "model"
103106

104107

108+
def get_current_version_args(*extra_args: str) -> list[str]:
109+
"""Create CLI args list with --target-python-version set to current version.
110+
111+
This is a convenience function for tests that want to use the current
112+
Python version to enable exec() validation.
113+
114+
Example:
115+
run_main_and_assert(
116+
...,
117+
extra_args=get_current_version_args("--use-field-description"),
118+
)
119+
"""
120+
return ["--target-python-version", CURRENT_PYTHON_VERSION, *extra_args]
121+
122+
105123
def _copy_files(copy_files: CopyFilesMapping | None) -> None:
106124
"""Copy files from source to destination paths."""
107125
if copy_files is not None:
@@ -223,6 +241,7 @@ def run_main_and_assert( # noqa: PLR0912
223241
monkeypatch: pytest.MonkeyPatch | None = None,
224242
# Code validation options
225243
skip_code_validation: bool = False,
244+
force_exec_validation: bool = False,
226245
) -> None:
227246
"""Execute main() and assert output.
228247
@@ -259,6 +278,13 @@ def run_main_and_assert( # noqa: PLR0912
259278
expected_stderr: Assert exact stderr match
260279
expected_stderr_contains: Assert stderr contains string
261280
assert_no_stderr: Assert stderr is empty
281+
282+
Code validation options:
283+
skip_code_validation: Skip all code validation (compile and exec)
284+
force_exec_validation: Run exec() even when target Python version differs from
285+
the test environment (only effective when target <= runtime). This catches
286+
runtime errors that would otherwise be missed. Has no effect when target >
287+
runtime since compile is skipped in that case.
262288
"""
263289
__tracebackhide__ = True
264290

@@ -349,7 +375,7 @@ def run_main_and_assert( # noqa: PLR0912
349375
assert_func(output_path, expected_file, transform=transform)
350376

351377
if output_path is not None and not skip_code_validation:
352-
_validate_output_files(output_path, extra_args)
378+
_validate_output_files(output_path, extra_args, force_exec_validation=force_exec_validation)
353379

354380

355381
def _get_argument_value(arguments: Sequence[str] | None, argument_name: str) -> str | None:
@@ -380,8 +406,15 @@ def _should_skip_compile(extra_arguments: Sequence[str] | None) -> bool:
380406
return target_version > sys.version_info[:2]
381407

382408

383-
def _should_skip_exec(extra_arguments: Sequence[str] | None) -> bool:
384-
"""Check if exec should be skipped based on model type, pydantic version, and Python version."""
409+
def _should_skip_exec(extra_arguments: Sequence[str] | None, *, force_exec: bool = False) -> bool:
410+
"""Check if exec should be skipped based on model type, pydantic version, and Python version.
411+
412+
Args:
413+
extra_arguments: CLI arguments passed to the test.
414+
force_exec: If True, skip version mismatch check and allow exec on current Python version.
415+
This only works when target version <= runtime version (older target on newer runtime).
416+
When target > runtime, compile will be skipped entirely regardless of this flag.
417+
"""
385418
output_model_type = _get_argument_value(extra_arguments, "--output-model-type")
386419
is_pydantic_v1 = output_model_type is None or output_model_type == DataModelType.PydanticBaseModel.value
387420
if (is_pydantic_v1 and PYDANTIC_V2) or (
@@ -390,16 +423,30 @@ def _should_skip_exec(extra_arguments: Sequence[str] | None) -> bool:
390423
return True
391424
if (target_version := _parse_target_version(extra_arguments)) is None:
392425
return True
393-
if target_version != sys.version_info[:2]:
426+
if not force_exec and target_version != sys.version_info[:2]:
394427
return True
395428
return _get_argument_value(extra_arguments, "--base-class") is not None
396429

397430

398-
def _validate_output_files(output_path: Path, extra_arguments: Sequence[str] | None = None) -> None:
399-
"""Validate generated Python files by compiling/executing them."""
431+
def _validate_output_files(
432+
output_path: Path,
433+
extra_arguments: Sequence[str] | None = None,
434+
*,
435+
force_exec_validation: bool = False,
436+
) -> None:
437+
"""Validate generated Python files by compiling/executing them.
438+
439+
Args:
440+
output_path: Path to output file or directory to validate.
441+
extra_arguments: CLI arguments passed to the test.
442+
force_exec_validation: If True, run exec even when target Python version differs from
443+
the test environment (only when target <= runtime). This helps catch runtime errors
444+
that would otherwise be missed. Has no effect when target > runtime since compile
445+
is skipped in that case.
446+
"""
400447
if _should_skip_compile(extra_arguments):
401448
return
402-
should_exec = not _should_skip_exec(extra_arguments)
449+
should_exec = not _should_skip_exec(extra_arguments, force_exec=force_exec_validation)
403450
if output_path.is_file() and output_path.suffix == ".py":
404451
validate_generated_code(output_path.read_text(encoding="utf-8"), str(output_path), do_exec=should_exec)
405452
elif output_path.is_dir(): # pragma: no cover
@@ -471,6 +518,7 @@ def run_main_url_and_assert(
471518
expected_file: str | Path,
472519
extra_args: Sequence[str] | None = None,
473520
transform: Callable[[str], str] | None = None,
521+
force_exec_validation: bool = False,
474522
) -> None:
475523
"""Execute main() with URL input and assert output.
476524
@@ -482,10 +530,12 @@ def run_main_url_and_assert(
482530
expected_file: Expected output filename
483531
extra_args: Additional CLI arguments
484532
transform: Optional function to transform output before comparison
533+
force_exec_validation: Run exec() even when target Python version differs from
534+
the test environment (only effective when target <= runtime).
485535
"""
486536
__tracebackhide__ = True
487537
return_code = _run_main_url(url, output_path, input_file_type, extra_args=extra_args)
488538
_assert_exit_code(return_code, Exit.OK, f"URL: {url}")
489539
assert_func(output_path, expected_file, transform=transform)
490540

491-
_validate_output_files(output_path, extra_args)
541+
_validate_output_files(output_path, extra_args, force_exec_validation=force_exec_validation)

tests/main/test_exec_validation.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""Tests that validate generated code execution on the current Python version.
2+
3+
These tests specifically target the current Python runtime to catch runtime errors
4+
that may not be caught when --target-python-version differs from the test environment.
5+
6+
See: _should_skip_exec() in conftest.py for the skip logic that these tests bypass.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import sys
12+
from typing import TYPE_CHECKING
13+
14+
import pytest
15+
16+
from datamodel_code_generator.format import PythonVersion, is_supported_in_black
17+
from datamodel_code_generator.util import PYDANTIC_V2
18+
19+
from .conftest import (
20+
CURRENT_PYTHON_VERSION,
21+
JSON_SCHEMA_DATA_PATH,
22+
OPEN_API_DATA_PATH,
23+
get_current_version_args,
24+
run_main_and_assert,
25+
)
26+
27+
if TYPE_CHECKING:
28+
from pathlib import Path
29+
30+
_CURRENT_PY_VERSION = PythonVersion(CURRENT_PYTHON_VERSION)
31+
_SKIP_PYDANTIC = pytest.mark.skipif(not PYDANTIC_V2, reason="Pydantic v2 required")
32+
_SKIP_BLACK = pytest.mark.skipif(
33+
not is_supported_in_black(_CURRENT_PY_VERSION),
34+
reason=f"Installed black doesn't support Python {CURRENT_PYTHON_VERSION}",
35+
)
36+
37+
38+
@_SKIP_PYDANTIC
39+
@_SKIP_BLACK
40+
def test_openapi_api_exec_current_version(output_file: Path) -> None:
41+
"""Test that api.yaml schema generates executable code on current Python."""
42+
run_main_and_assert(
43+
input_path=OPEN_API_DATA_PATH / "api.yaml",
44+
output_path=output_file,
45+
extra_args=get_current_version_args("--output-model-type", "pydantic_v2.BaseModel"),
46+
skip_code_validation=False,
47+
force_exec_validation=True,
48+
)
49+
50+
51+
@_SKIP_PYDANTIC
52+
@_SKIP_BLACK
53+
def test_openapi_with_refs_exec_current_version(output_file: Path) -> None:
54+
"""Test that OpenAPI schema with $ref generates executable code."""
55+
run_main_and_assert(
56+
input_path=OPEN_API_DATA_PATH / "body_and_parameters.yaml",
57+
output_path=output_file,
58+
extra_args=get_current_version_args("--output-model-type", "pydantic_v2.BaseModel"),
59+
skip_code_validation=False,
60+
force_exec_validation=True,
61+
)
62+
63+
64+
@_SKIP_PYDANTIC
65+
@_SKIP_BLACK
66+
def test_openapi_allof_exec_current_version(output_file: Path) -> None:
67+
"""Test that OpenAPI schema with allOf generates executable code."""
68+
run_main_and_assert(
69+
input_path=OPEN_API_DATA_PATH / "allof.yaml",
70+
output_path=output_file,
71+
extra_args=get_current_version_args("--output-model-type", "pydantic_v2.BaseModel"),
72+
skip_code_validation=False,
73+
force_exec_validation=True,
74+
)
75+
76+
77+
@_SKIP_PYDANTIC
78+
@_SKIP_BLACK
79+
def test_jsonschema_person_exec_current_version(output_file: Path) -> None:
80+
"""Test that person.json schema generates executable code on current Python."""
81+
run_main_and_assert(
82+
input_path=JSON_SCHEMA_DATA_PATH / "person.json",
83+
output_path=output_file,
84+
extra_args=get_current_version_args("--output-model-type", "pydantic_v2.BaseModel"),
85+
skip_code_validation=False,
86+
force_exec_validation=True,
87+
)
88+
89+
90+
@_SKIP_PYDANTIC
91+
@_SKIP_BLACK
92+
def test_jsonschema_nested_array_exec_current_version(output_file: Path) -> None:
93+
"""Test that nested array JSON Schema generates executable code."""
94+
run_main_and_assert(
95+
input_path=JSON_SCHEMA_DATA_PATH / "nested_array.json",
96+
output_path=output_file,
97+
extra_args=get_current_version_args("--output-model-type", "pydantic_v2.BaseModel"),
98+
skip_code_validation=False,
99+
force_exec_validation=True,
100+
)
101+
102+
103+
@_SKIP_PYDANTIC
104+
@_SKIP_BLACK
105+
def test_jsonschema_circular_reference_exec_current_version(output_file: Path) -> None:
106+
"""Test that circular reference JSON Schema generates executable code."""
107+
run_main_and_assert(
108+
input_path=JSON_SCHEMA_DATA_PATH / "circular_reference.json",
109+
output_path=output_file,
110+
extra_args=get_current_version_args("--output-model-type", "pydantic_v2.BaseModel"),
111+
skip_code_validation=False,
112+
force_exec_validation=True,
113+
)
114+
115+
116+
@_SKIP_PYDANTIC
117+
@_SKIP_BLACK
118+
@pytest.mark.skipif(
119+
sys.version_info[:2] <= (3, 10),
120+
reason="Need runtime > target (3.10) to test force_exec_validation behavior",
121+
)
122+
def test_force_exec_with_different_target_version(output_file: Path) -> None:
123+
"""Test that force_exec_validation runs exec even with different target version.
124+
125+
This test uses --target-python-version 3.10 but force_exec_validation=True
126+
to verify that exec still runs on the current Python version.
127+
128+
Requirements for this test to be meaningful:
129+
- Runtime must be > 3.10 (so target < runtime, allowing compile to proceed)
130+
- This ensures force_exec_validation can bypass the version mismatch skip
131+
"""
132+
run_main_and_assert(
133+
input_path=JSON_SCHEMA_DATA_PATH / "person.json",
134+
output_path=output_file,
135+
extra_args=["--target-python-version", "3.10", "--output-model-type", "pydantic_v2.BaseModel"],
136+
skip_code_validation=False,
137+
force_exec_validation=True,
138+
)
139+
140+
141+
def test_get_current_version_args_basic() -> None:
142+
"""Test that get_current_version_args returns correct args."""
143+
args = get_current_version_args()
144+
assert args == ["--target-python-version", CURRENT_PYTHON_VERSION]
145+
146+
147+
def test_get_current_version_args_with_extra() -> None:
148+
"""Test that get_current_version_args includes extra args."""
149+
args = get_current_version_args("--use-field-description", "--strict")
150+
assert args == [
151+
"--target-python-version",
152+
CURRENT_PYTHON_VERSION,
153+
"--use-field-description",
154+
"--strict",
155+
]
156+
157+
158+
def test_current_python_version_format() -> None:
159+
"""Test that CURRENT_PYTHON_VERSION matches expected format."""
160+
expected = f"{sys.version_info[0]}.{sys.version_info[1]}"
161+
assert expected == CURRENT_PYTHON_VERSION

0 commit comments

Comments
 (0)