Skip to content

Commit 730b494

Browse files
authored
Fix generation reliability edge cases (#3561)
* Fix generation reliability edge cases * Fix command header redaction test compatibility * Address reliability review feedback * Avoid path monkeypatch leakage * Fix RootModel config rendering * Sync RootModel config before rendering * Stabilize RootModel config rendering * Harden RootModel config sync * Preserve RootModel config object * Tighten Ruff stdout fallback
1 parent b5d167a commit 730b494

13 files changed

Lines changed: 529 additions & 85 deletions

src/datamodel_code_generator/__init__.py

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@
106106
Returned by generate() when output=None and multiple modules are generated.
107107
"""
108108

109+
_SCHEMA_TEXT_START_CHARS = ("{", "[", "<")
110+
109111

110112
def _apply_generate_config_preset(config: GenerateConfig) -> GenerateConfig:
111113
"""Return a generate config with preset defaults applied."""
@@ -759,6 +761,23 @@ def _generate_config_values(generate_config: GenerateConfig) -> dict[str, Any]:
759761
return values
760762

761763

764+
def _coerce_existing_path_input(
765+
input_: Path | str | ParseResult | Mapping[str, Any] | list[Any],
766+
) -> Path | str | ParseResult | Mapping[str, Any] | list[Any]:
767+
if not isinstance(input_, str):
768+
return input_
769+
if not input_ or "\n" in input_ or "\r" in input_:
770+
return input_
771+
if (stripped := input_.lstrip()).startswith(_SCHEMA_TEXT_START_CHARS) or "://" in stripped:
772+
return input_
773+
try:
774+
path = Path(input_).expanduser()
775+
path_exists = path.exists()
776+
except (OSError, RuntimeError, ValueError): # pragma: no cover
777+
return input_
778+
return path if path_exists else input_
779+
780+
762781
def _create_parser_config(
763782
generate_config: GenerateConfig,
764783
additional_options: ParserConfigDict
@@ -1148,7 +1167,7 @@ def _build_parser( # noqa: PLR0911, PLR0913
11481167
raise Error(msg)
11491168

11501169

1151-
def _emit_results( # noqa: PLR0912, PLR0913, PLR0914, PLR0915
1170+
def _emit_results( # noqa: PLR0912, PLR0913, PLR0915
11521171
results: str | dict[tuple[str, ...], Any],
11531172
input_: Path | str | ParseResult | Mapping[str, Any] | list[Any],
11541173
input_filename: str | None,
@@ -1225,25 +1244,24 @@ def _emit_results( # noqa: PLR0912, PLR0913, PLR0914, PLR0915
12251244
for name, result in sorted(results.items())
12261245
}
12271246

1228-
file: IO[Any] | None
12291247
for path, (body, future_imports, filename) in modules.items():
12301248
if not path.parent.exists():
12311249
path.parent.mkdir(parents=True)
12321250

12331251
safe_filename = filename.replace("\n", " ").replace("\r", " ") if filename else ""
12341252
effective_header = custom_file_header or header.format(safe_filename)
1235-
file = path.open("wt", encoding=config.encoding)
1236-
if custom_file_header and body:
1237-
file.write(
1238-
_build_module_content(body, effective_header, custom_file_header, future_imports=future_imports) + "\n"
1239-
)
1240-
else:
1241-
file.write(effective_header)
1242-
if body:
1243-
file.write("\n\n")
1244-
file.write(body.rstrip())
1245-
file.write("\n")
1246-
file.close()
1253+
with path.open("wt", encoding=config.encoding) as file:
1254+
if custom_file_header and body:
1255+
file.write(
1256+
_build_module_content(body, effective_header, custom_file_header, future_imports=future_imports)
1257+
+ "\n"
1258+
)
1259+
else:
1260+
file.write(effective_header)
1261+
if body:
1262+
file.write("\n\n")
1263+
file.write(body.rstrip())
1264+
file.write("\n")
12471265

12481266
if defer_formatting and config.formatters:
12491267
from datamodel_code_generator._format_types import Formatter # noqa: PLC0415
@@ -1343,6 +1361,8 @@ def generate( # noqa: PLR0912, PLR0914, PLR0915
13431361
skip_root_model = config.skip_root_model
13441362
source_override: Mapping[str, Any] | None = None
13451363

1364+
input_ = _coerce_existing_path_input(input_)
1365+
13461366
if (
13471367
isinstance(input_, list)
13481368
and input_file_type != InputFileType.MCPTools

src/datamodel_code_generator/__main__.py

Lines changed: 48 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,8 @@
188188
})
189189

190190
ORIGINAL_FIELD_NAME_DELIMITER_ERROR = "`--original-field-name-delimiter` can not be used without `--snake-case-field`."
191+
SENSITIVE_COMMAND_OPTIONS: frozenset[str] = frozenset({"--http-headers", "--http-query-parameters"})
192+
REDACTED_COMMAND_ARGUMENT = "<redacted>"
191193

192194

193195
class Exit(IntEnum):
@@ -204,6 +206,31 @@ def sig_int_handler(_: int, __: Any) -> None: # pragma: no cover
204206
sys.exit(Exit.OK)
205207

206208

209+
def _redact_command_args(args: Sequence[str]) -> list[str]:
210+
redacted: list[str] = []
211+
redact_values = False
212+
for arg in args:
213+
match arg:
214+
case option if option in SENSITIVE_COMMAND_OPTIONS:
215+
redacted.append(option)
216+
redact_values = True
217+
case option_value if (option := option_value.partition("="))[1] and option[0] in SENSITIVE_COMMAND_OPTIONS:
218+
redacted.append(f"{option[0]}={REDACTED_COMMAND_ARGUMENT}")
219+
redact_values = False
220+
case option if redact_values and option.startswith("-"):
221+
redacted.append(option)
222+
redact_values = False
223+
case _ if redact_values:
224+
redacted.append(REDACTED_COMMAND_ARGUMENT)
225+
case _:
226+
redacted.append(arg)
227+
return redacted
228+
229+
230+
def _command_header(args: Sequence[str]) -> str:
231+
return shlex.join(["datamodel-codegen", *_redact_command_args(args)])
232+
233+
207234
signal.signal(signal.SIGINT, sig_int_handler)
208235

209236

@@ -1393,6 +1420,11 @@ def main(args: Sequence[str] | None = None) -> Exit: # noqa: PLR0911, PLR0912,
13931420
generate_output = config.output
13941421
is_directory_output = False
13951422

1423+
def cleanup_and_return(exit_code: Exit) -> Exit:
1424+
if temp_context is not None:
1425+
temp_context.cleanup()
1426+
return exit_code
1427+
13961428
try:
13971429
input_: Path | str | ParseResult
13981430
if config.input_model:
@@ -1421,29 +1453,23 @@ def main(args: Sequence[str] | None = None) -> Exit: # noqa: PLR0911, PLR0912,
14211453
extra_template_data=extra_template_data,
14221454
aliases=aliases,
14231455
serialization_aliases=serialization_aliases,
1424-
command_line=shlex.join(["datamodel-codegen", *args]) if config.enable_command_header else None,
1456+
command_line=_command_header(args) if config.enable_command_header else None,
14251457
custom_formatters_kwargs=custom_formatters_kwargs,
14261458
settings_path=config.output,
14271459
validators=validators_config,
14281460
default_value_overrides=default_value_overrides,
14291461
)
14301462
except InvalidClassNameError as e:
14311463
print(f"{e} You have to set `--class-name` option", file=sys.stderr) # noqa: T201
1432-
if temp_context is not None:
1433-
temp_context.cleanup()
1434-
return Exit.ERROR
1464+
return cleanup_and_return(Exit.ERROR)
14351465
except Error as e:
14361466
print(str(e), file=sys.stderr) # noqa: T201
1437-
if temp_context is not None:
1438-
temp_context.cleanup()
1439-
return Exit.ERROR
1467+
return cleanup_and_return(Exit.ERROR)
14401468
except Exception: # noqa: BLE001
14411469
import traceback # noqa: PLC0415
14421470

14431471
print(traceback.format_exc(), file=sys.stderr) # noqa: T201
1444-
if temp_context is not None:
1445-
temp_context.cleanup()
1446-
return Exit.ERROR
1472+
return cleanup_and_return(Exit.ERROR)
14471473

14481474
if writes_json_output_file and generate_output is not None and config.output is not None:
14491475
_copy_generated_output(generate_output, config.output, is_directory_output=is_directory_output)
@@ -1459,10 +1485,6 @@ def main(args: Sequence[str] | None = None) -> Exit: # noqa: PLR0911, PLR0912,
14591485
)
14601486
+ "\n"
14611487
)
1462-
if temp_context is not None: # pragma: no branch
1463-
temp_context.cleanup()
1464-
temp_context = None
1465-
14661488
if config.check and config.output is not None and generate_output is not None:
14671489
from datamodel_code_generator._structured_output import CheckDifferencePayload # noqa: PLC0415
14681490

@@ -1536,9 +1558,6 @@ def main(args: Sequence[str] | None = None) -> Exit: # noqa: PLR0911, PLR0912,
15361558
print("".join(single_file_diff_lines), end="") # noqa: T201
15371559
has_differences = True
15381560

1539-
if temp_context is not None: # pragma: no branch
1540-
temp_context.cleanup()
1541-
15421561
if namespace.output_format == "json":
15431562
content = "".join(single_file_diff_lines)
15441563
content += "".join("".join(changed_file.diff_lines) for changed_file in changed_files)
@@ -1553,25 +1572,27 @@ def main(args: Sequence[str] | None = None) -> Exit: # noqa: PLR0911, PLR0912,
15531572
+ "\n"
15541573
)
15551574

1556-
return Exit.DIFF if has_differences else Exit.OK
1575+
return cleanup_and_return(Exit.DIFF if has_differences else Exit.OK)
15571576

15581577
if config.watch:
15591578
try:
15601579
from datamodel_code_generator.watch import watch_and_regenerate # noqa: PLC0415
15611580

1562-
return watch_and_regenerate(
1563-
config,
1564-
extra_template_data,
1565-
aliases,
1566-
serialization_aliases,
1567-
custom_formatters_kwargs,
1568-
default_value_overrides,
1581+
return cleanup_and_return(
1582+
watch_and_regenerate(
1583+
config,
1584+
extra_template_data,
1585+
aliases,
1586+
serialization_aliases,
1587+
custom_formatters_kwargs,
1588+
default_value_overrides,
1589+
)
15691590
)
15701591
except Exception as e: # noqa: BLE001
15711592
print(str(e), file=sys.stderr) # noqa: T201
1572-
return Exit.ERROR
1593+
return cleanup_and_return(Exit.ERROR)
15731594

1574-
return Exit.OK
1595+
return cleanup_and_return(Exit.OK)
15751596

15761597

15771598
if __name__ == "__main__":

src/datamodel_code_generator/format.py

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -504,45 +504,63 @@ def apply_builtin_formatter( # noqa: PLR0913
504504

505505
def apply_ruff_lint(self, code: str) -> str:
506506
"""Run ruff check with auto-fix on code."""
507-
result = subprocess.run( # noqa: S603
507+
result = self._run_ruff_command(
508508
self._ruff_check_command("-"),
509-
input=code.encode(self.encoding),
510-
capture_output=True,
511-
check=False,
512-
cwd=self.settings_path,
509+
stdin=code.encode(self.encoding),
510+
allow_stdout_on_error=True,
513511
)
514512
return result.stdout.decode(self.encoding)
515513

516514
def apply_ruff_formatter(self, code: str) -> str:
517515
"""Format code using ruff format."""
518516
ruff_path = self._find_ruff_path()
519-
result = subprocess.run( # noqa: S603
517+
result = self._run_ruff_command(
520518
(ruff_path, "format", "-"),
521-
input=code.encode(self.encoding),
522-
capture_output=True,
523-
check=False,
524-
cwd=self.settings_path,
519+
stdin=code.encode(self.encoding),
525520
)
526521
return result.stdout.decode(self.encoding)
527522

528523
def apply_ruff_check_and_format(self, code: str) -> str:
529524
"""Run ruff check and format sequentially for reliable processing."""
530525
ruff_path = self._find_ruff_path()
531-
check_result = subprocess.run( # noqa: S603
526+
check_result = self._run_ruff_command(
532527
self._ruff_check_command("-", ruff_path=ruff_path),
533-
input=code.encode(self.encoding),
534-
capture_output=True,
535-
check=False,
536-
cwd=self.settings_path,
528+
stdin=code.encode(self.encoding),
529+
allow_stdout_on_error=True,
537530
)
538-
format_result = subprocess.run( # noqa: S603
531+
format_result = self._run_ruff_command(
539532
(ruff_path, "format", "-"),
540-
input=check_result.stdout,
533+
stdin=check_result.stdout,
534+
)
535+
return format_result.stdout.decode(self.encoding)
536+
537+
def _run_ruff_command(
538+
self,
539+
command: tuple[str, ...],
540+
*,
541+
stdin: bytes | None = None,
542+
allow_stdout_on_error: bool = False,
543+
) -> subprocess.CompletedProcess[bytes]:
544+
kwargs: dict[str, Any] = {}
545+
if stdin is not None:
546+
kwargs["input"] = stdin
547+
result = subprocess.run( # noqa: S603
548+
command,
541549
capture_output=True,
542550
check=False,
543551
cwd=self.settings_path,
552+
**kwargs,
544553
)
545-
return format_result.stdout.decode(self.encoding)
554+
if result.returncode == 0 or (allow_stdout_on_error and result.returncode == 1 and result.stdout):
555+
return result
556+
if (message := result.stderr.decode(self.encoding, errors="replace").strip()) or (
557+
message := result.stdout.decode(self.encoding, errors="replace").strip()
558+
):
559+
detail = message
560+
else:
561+
detail = "no output"
562+
msg = f"Ruff command failed with exit code {result.returncode}: {' '.join(command)}\n{detail}"
563+
raise RuntimeError(msg)
546564

547565
def _ruff_check_command(self, *paths: str, ruff_path: str | None = None) -> tuple[str, ...]:
548566
"""Build the Ruff check command for the current formatter settings."""
@@ -561,7 +579,10 @@ def _find_ruff_path() -> str:
561579
ruff_in_venv = bin_dir / ruff_name
562580
if ruff_in_venv.exists():
563581
return str(ruff_in_venv)
564-
return shutil.which("ruff") or "ruff" # pragma: no cover
582+
if ruff_path := shutil.which("ruff"):
583+
return ruff_path
584+
msg = "Ruff executable was not found. Install it with `pip install 'datamodel-code-generator[ruff]'`."
585+
raise RuntimeError(msg)
565586

566587
def apply_isort(self, code: str) -> str:
567588
"""Sort imports using isort."""
@@ -578,18 +599,12 @@ def format_directory(self, directory: Path) -> None:
578599
"""Apply ruff formatting to all Python files in a directory."""
579600
ruff_path = self._find_ruff_path()
580601
if Formatter.RUFF_CHECK in self.formatters:
581-
subprocess.run( # noqa: S603
602+
self._run_ruff_command(
582603
self._ruff_check_command(str(directory), ruff_path=ruff_path),
583-
capture_output=True,
584-
check=False,
585-
cwd=self.settings_path,
586604
)
587605
if Formatter.RUFF_FORMAT in self.formatters:
588-
subprocess.run( # noqa: S603
606+
self._run_ruff_command(
589607
(ruff_path, "format", str(directory)),
590-
capture_output=True,
591-
check=False,
592-
cwd=self.settings_path,
593608
)
594609

595610

src/datamodel_code_generator/model/pydantic_v2/base_model.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -137,13 +137,11 @@ def _config_dict_items(config: Any) -> list[tuple[str, Any]]:
137137
if isinstance(config, dict):
138138
return list(config.items())
139139

140-
if model_dump := getattr(config, "model_dump", None):
141-
return list(model_dump(exclude_unset=True).items())
142-
143-
if dict_ := getattr(config, "dict", None):
144-
return list(dict_(exclude_unset=True).items())
145-
146-
return []
140+
dump = getattr(config, "model_dump", None) or getattr(config, "dict", None)
141+
if not dump:
142+
return []
143+
values = dump(exclude_unset=True)
144+
return list(values.items()) if isinstance(values, dict) else []
147145

148146

149147
_PYDANTIC_V2_BASE_FIELD_KEYS: frozenset[str] = frozenset({
@@ -643,8 +641,11 @@ def __init__( # noqa: PLR0913
643641
config_parameters[_ALIAS_GENERATOR_TEMPLATE_DATA_KEY] = alias_generator
644642
self._additional_imports.append(_ALIAS_GENERATOR_IMPORTS[alias_generator])
645643

646-
if isinstance(config := self.extra_template_data.get("config"), dict):
644+
config = self.extra_template_data.get("config")
645+
if isinstance(config, dict):
647646
config_parameters.update(dict(config.items()))
647+
elif config_items := _config_dict_items(config):
648+
config_parameters.update(dict(config_items))
648649

649650
# Handle json_schema_extra from schema extensions (x-* fields)
650651
model_extras = self.extra_template_data.get("model_extras")
@@ -661,6 +662,7 @@ def __init__( # noqa: PLR0913
661662
)
662663
self._additional_imports.append(IMPORT_CONFIG_DICT)
663664
else:
665+
self.extra_template_data.pop("config", None)
664666
self.extra_template_data.pop(_CONFIG_ITEMS_TEMPLATE_DATA_KEY, None)
665667

666668
self._process_schema_runtime_validation()

0 commit comments

Comments
 (0)