Skip to content

Commit 04be419

Browse files
committed
Cache parsed sources
1 parent 93e2fe3 commit 04be419

13 files changed

Lines changed: 344 additions & 35 deletions

File tree

src/datamodel_code_generator/__init__.py

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
import contextlib
1010
import os
1111
import sys
12-
from collections import defaultdict
12+
from collections import OrderedDict, defaultdict
1313
from collections.abc import Callable, Iterator, Mapping
1414
from datetime import datetime, timezone
1515
from functools import lru_cache as _lru_cache
16+
from hashlib import sha256
1617
from pathlib import Path
18+
from threading import RLock
1719
from typing import (
1820
IO,
1921
TYPE_CHECKING,
@@ -94,6 +96,10 @@
9496

9597
DEFAULT_BASE_CLASS: str = "pydantic.BaseModel"
9698
_IGNORED_TEXT_PREFIX_CHARS: frozenset[str] = frozenset({"\ufeff", " ", "\t", "\r", "\n"})
99+
_PARSER_SOURCE_DATA_CACHE_MAX_SIZE = 128
100+
_ParserSourceDataCacheKey: TypeAlias = tuple[Path, str, str, str]
101+
_parser_source_data_cache: OrderedDict[_ParserSourceDataCacheKey, YamlValue] = OrderedDict()
102+
_parser_source_data_cache_lock = RLock()
97103

98104

99105
def load_yaml(stream: str | TextIO) -> YamlValue:
@@ -202,16 +208,55 @@ def load_data_from_path(path: Path, encoding: str) -> dict[str, YamlValue]:
202208
203209
For file input: tries json.load() for .json files (more efficient than
204210
read_text + json.loads), falls back to YAML if JSON parsing fails
205-
(e.g., trailing commas) or if content is not a dict. Uses YAML for all other extensions.
211+
(e.g., trailing commas), and requires the parsed content to be a dict.
212+
Uses YAML for all other extensions.
206213
"""
214+
result = _load_parser_source_data_from_path(path, encoding)
215+
if not isinstance(result, dict):
216+
msg = f"Expected dict, got {type(result).__name__}"
217+
raise TypeError(msg)
218+
return result
219+
220+
221+
def _load_parser_source_data_from_path(path: Path, encoding: str) -> YamlValue:
222+
resolved_path = path.resolve()
223+
data = resolved_path.read_bytes()
224+
digest = sha256(data).hexdigest()
225+
yaml_backend = ""
226+
if resolved_path.suffix.lower() != ".json":
227+
from datamodel_code_generator.util import get_yaml_backend # noqa: PLC0415
228+
229+
yaml_backend = get_yaml_backend()
230+
231+
cache_key = (resolved_path, digest, encoding, yaml_backend)
232+
with _parser_source_data_cache_lock:
233+
if cache_key in _parser_source_data_cache:
234+
_parser_source_data_cache.move_to_end(cache_key)
235+
return _parser_source_data_cache[cache_key]
236+
237+
parsed_data = _load_parser_source_data_from_bytes(resolved_path, data, encoding)
238+
with _parser_source_data_cache_lock:
239+
_parser_source_data_cache[cache_key] = parsed_data
240+
_parser_source_data_cache.move_to_end(cache_key)
241+
while len(_parser_source_data_cache) > _PARSER_SOURCE_DATA_CACHE_MAX_SIZE:
242+
_parser_source_data_cache.popitem(last=False)
243+
return parsed_data
244+
245+
246+
def _load_parser_source_data_from_bytes(path: Path, data: bytes, encoding: str) -> YamlValue:
207247
import json # noqa: PLC0415
208248

249+
text = data.decode(encoding)
209250
if path.suffix.lower() == ".json":
210-
with contextlib.suppress(json.JSONDecodeError), path.open(encoding=encoding) as f:
211-
result = json.load(f)
212-
if isinstance(result, dict):
213-
return result
214-
return load_yaml_dict_from_path(path, encoding)
251+
with contextlib.suppress(json.JSONDecodeError):
252+
return json.loads(text)
253+
254+
return load_yaml(text)
255+
256+
257+
def _clear_parser_source_data_cache() -> None:
258+
with _parser_source_data_cache_lock:
259+
_parser_source_data_cache.clear()
215260

216261

217262
@_lru_cache(maxsize=256)

src/datamodel_code_generator/_types/generate_config_dict.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ class GenerateConfigDict(TypedDict, closed=True):
184184
schema_version: NotRequired[str | None]
185185
schema_version_mode: NotRequired[VersionMode | None]
186186
external_ref_mapping: NotRequired[dict[str, str] | None]
187+
enable_parser_cache: NotRequired[bool]
187188

188189

189190
class ValidatorDefinition(TypedDict):

src/datamodel_code_generator/_types/parser_config_dicts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ class ParserConfigDict(TypedDict):
175175
target_pydantic_version: NotRequired[TargetPydanticVersion | None]
176176
default_value_overrides: NotRequired[Mapping[str, Any] | None]
177177
external_ref_mapping: NotRequired[dict[str, str] | None]
178+
enable_parser_cache: NotRequired[bool]
178179

179180

180181
class GraphQLParserConfigDict(ParserConfigDict, closed=True):

src/datamodel_code_generator/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ class GenerateConfig(BaseModel):
212212
schema_version: str | None = None
213213
schema_version_mode: VersionMode | None = None
214214
external_ref_mapping: dict[str, str] | None = None
215+
enable_parser_cache: bool = False
215216

216217

217218
def _rebuild_generate_config() -> None:
@@ -351,6 +352,7 @@ class ParserConfig(BaseModel):
351352
target_pydantic_version: TargetPydanticVersion | None = None
352353
default_value_overrides: Mapping[str, Any] | None = None
353354
external_ref_mapping: dict[str, str] | None = None
355+
enable_parser_cache: bool = False
354356

355357

356358
class GraphQLParserConfig(ParserConfig):

src/datamodel_code_generator/parser/avro.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from __future__ import annotations
88

99
import re
10-
from typing import TYPE_CHECKING, Any, NamedTuple, cast
10+
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, cast
1111

1212
from typing_extensions import Unpack
1313

@@ -500,6 +500,7 @@ class AvroParser(JsonSchemaParser):
500500
"""
501501

502502
_config_class_name = "AvroParserConfig"
503+
_cache_parsed_sources_from_path: ClassVar[bool] = False
503504

504505
def __init__(
505506
self,

src/datamodel_code_generator/parser/base.py

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
ReuseScope,
5050
YamlValue,
5151
_internal_utils,
52+
_load_parser_source_data_from_path,
5253
)
5354
from datamodel_code_generator.enums import StrictTypes
5455
from datamodel_code_generator.format import (
@@ -949,11 +950,25 @@ class Source(BaseModel):
949950

950951
path: Path
951952
text: str = ""
952-
raw_data: dict[str, YamlValue] | None = None
953+
raw_data: YamlValue | None = None
953954

954955
@classmethod
955-
def from_path(cls, path: Path, base_path: Path, encoding: str) -> Source:
956+
def from_path(
957+
cls,
958+
path: Path,
959+
base_path: Path,
960+
encoding: str,
961+
*,
962+
enable_parser_cache: bool = False,
963+
keep_text: bool = False,
964+
) -> Source:
956965
"""Create a Source from a file path relative to base_path."""
966+
if enable_parser_cache:
967+
return cls(
968+
path=path.relative_to(base_path),
969+
text=path.read_text(encoding=encoding) if keep_text else "",
970+
raw_data=_load_parser_source_data_from_path(path, encoding),
971+
)
957972
return cls(
958973
path=path.relative_to(base_path),
959974
text=path.read_text(encoding=encoding),
@@ -1124,6 +1139,7 @@ def schema_features(self) -> SchemaFeaturesT:
11241139

11251140
_config_class_name: ClassVar[str] = "ParserConfig"
11261141
_cache_local_sources_during_parse: ClassVar[bool] = False
1142+
_cache_parsed_sources_from_path: ClassVar[bool] = False
11271143

11281144
@classmethod
11291145
def _get_config_class(cls) -> type[ParserConfig]:
@@ -1300,6 +1316,7 @@ def __init__( # noqa: PLR0912, PLR0915
13001316
self.source: str | Path | list[Path] | ParseResult | dict[str, YamlValue] = source
13011317
self._cache_local_sources = False
13021318
self._local_source_cache: tuple[Source, ...] | None = None
1319+
self.enable_parser_cache: bool = config.enable_parser_cache and self._cache_parsed_sources_from_path
13031320
self.custom_template_dir = config.custom_template_dir
13041321
self.extra_template_data: defaultdict[str, Any] = config.extra_template_data or defaultdict(dict)
13051322
self.validators = config.validators
@@ -1519,12 +1536,30 @@ def _iter_source_uncached(self) -> Iterator[Source]:
15191536
if path.is_dir():
15201537
for p in sorted(path.rglob("*"), key=lambda p: p.name):
15211538
if p.is_file():
1522-
yield Source.from_path(p, self.base_path, self.encoding)
1539+
yield Source.from_path(
1540+
p,
1541+
self.base_path,
1542+
self.encoding,
1543+
enable_parser_cache=self.enable_parser_cache,
1544+
keep_text=self.validation,
1545+
)
15231546
else:
1524-
yield Source.from_path(path, self.base_path, self.encoding)
1547+
yield Source.from_path(
1548+
path,
1549+
self.base_path,
1550+
self.encoding,
1551+
enable_parser_cache=self.enable_parser_cache,
1552+
keep_text=self.validation,
1553+
)
15251554
case list() as paths: # pragma: no cover
15261555
for path in paths:
1527-
yield Source.from_path(path, self.base_path, self.encoding)
1556+
yield Source.from_path(
1557+
path,
1558+
self.base_path,
1559+
self.encoding,
1560+
enable_parser_cache=self.enable_parser_cache,
1561+
keep_text=self.validation,
1562+
)
15281563
case _:
15291564
yield Source(
15301565
path=Path(self.source.path),

src/datamodel_code_generator/parser/jsonschema.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,7 @@ class JsonSchemaParser(Parser["JSONSchemaParserConfig", "JsonSchemaFeatures"]):
639639
SCHEMA_PATHS: ClassVar[list[str]] = list(_DEFAULT_SCHEMA_PATHS)
640640
SCHEMA_OBJECT_TYPE: ClassVar[type[JsonSchemaObject]] = JsonSchemaObject
641641
_cache_local_sources_during_parse: ClassVar[bool] = True
642+
_cache_parsed_sources_from_path: ClassVar[bool] = True
642643

643644
COMPATIBLE_PYTHON_TYPES: ClassVar[dict[str, frozenset[str]]] = {
644645
"string": frozenset({"str", "String"}),
@@ -5352,7 +5353,12 @@ def _iter_local_source_paths(self) -> Iterator[Path]:
53525353
yield from ((self.base_path / path) for path in paths)
53535354

53545355
def _load_source_dict(self, source: Source) -> dict[str, Any]: # noqa: PLR6301
5355-
return dict(source.raw_data) if source.raw_data is not None else load_data(source.text)
5356+
if source.raw_data is None:
5357+
return load_data(source.text)
5358+
if not isinstance(source.raw_data, dict):
5359+
msg = f"Expected dict, got {type(source.raw_data).__name__}"
5360+
raise TypeError(msg)
5361+
return dict(source.raw_data)
53565362

53575363
def _resolve_root_model_name(self, raw_obj: dict[str, Any]) -> tuple[str, bool]:
53585364
title = raw_obj.get("title")
@@ -5397,17 +5403,11 @@ def parse_raw(self) -> None:
53975403
"""Parse all raw input sources into data models."""
53985404
try:
53995405
for source, path_parts in self._get_context_source_path_parts():
5400-
if source.raw_data is not None:
5401-
raw_obj = source.raw_data
5402-
if not isinstance(raw_obj, dict): # pragma: no cover
5403-
warn(f"{source.path} is empty or not a dict. Skipping this file", stacklevel=2)
5404-
continue
5405-
else:
5406-
try:
5407-
raw_obj = load_data(source.text)
5408-
except TypeError:
5409-
warn(f"{source.path} is empty or not a dict. Skipping this file", stacklevel=2)
5410-
continue
5406+
try:
5407+
raw_obj = self._load_source_dict(source)
5408+
except TypeError:
5409+
warn(f"{source.path} is empty or not a dict. Skipping this file", stacklevel=2)
5410+
continue
54115411
self.raw_obj = raw_obj
54125412
obj_name, preserve_root_class_name = self._resolve_root_model_name(self.raw_obj)
54135413
self._parse_file(self.raw_obj, obj_name, path_parts, preserve_root_class_name=preserve_root_class_name)

src/datamodel_code_generator/parser/openapi.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -957,7 +957,7 @@ def parse_raw(self) -> None:
957957
if self.validation:
958958
warn_deprecated("cli.validation", stacklevel=2)
959959

960-
if source.raw_data is not None:
960+
if source.raw_data is not None and not source.text:
961961
warn(
962962
"Warning: Validation was skipped for dict input. "
963963
"The --validation option only works with file or text input.\n",

src/datamodel_code_generator/parser/protobuf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,7 @@ class ProtobufParser(JsonSchemaParser):
661661

662662
_config_class_name: ClassVar[str] = "ProtobufParserConfig"
663663
_cache_local_sources_during_parse: ClassVar[bool] = False
664+
_cache_parsed_sources_from_path: ClassVar[bool] = False
664665

665666
def __init__(
666667
self,

0 commit comments

Comments
 (0)