Skip to content

Commit b749b49

Browse files
committed
Cache parsed sources
1 parent 93e2fe3 commit b749b49

15 files changed

Lines changed: 458 additions & 37 deletions

File tree

src/datamodel_code_generator/__init__.py

Lines changed: 75 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,12 @@
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+
_ParserSourceDataSeenKey: TypeAlias = tuple[Path, str, str]
102+
_parser_source_data_cache: OrderedDict[_ParserSourceDataCacheKey, YamlValue] = OrderedDict()
103+
_parser_source_data_seen_keys: OrderedDict[_ParserSourceDataSeenKey, None] = OrderedDict()
104+
_parser_source_data_cache_lock = RLock()
97105

98106

99107
def load_yaml(stream: str | TextIO) -> YamlValue:
@@ -202,16 +210,76 @@ def load_data_from_path(path: Path, encoding: str) -> dict[str, YamlValue]:
202210
203211
For file input: tries json.load() for .json files (more efficient than
204212
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.
213+
(e.g., trailing commas), and requires the parsed content to be a dict.
214+
Uses YAML for all other extensions.
206215
"""
216+
result = _load_parser_source_data_from_path(path, encoding, cache_on_first_load=False)
217+
if not isinstance(result, dict):
218+
msg = f"Expected dict, got {type(result).__name__}"
219+
raise TypeError(msg)
220+
return result
221+
222+
223+
def _parser_source_data_yaml_backend(resolved_path: Path) -> str:
224+
if resolved_path.suffix.lower() != ".json":
225+
from datamodel_code_generator.util import get_yaml_backend # noqa: PLC0415
226+
227+
return get_yaml_backend()
228+
return ""
229+
230+
231+
def _load_parser_source_data_from_path(
232+
path: Path,
233+
encoding: str,
234+
*,
235+
cache_on_first_load: bool = True,
236+
) -> YamlValue:
237+
resolved_path = path.resolve()
238+
yaml_backend = _parser_source_data_yaml_backend(resolved_path)
239+
seen_key = (resolved_path, encoding, yaml_backend)
240+
with _parser_source_data_cache_lock:
241+
use_cache = cache_on_first_load or seen_key in _parser_source_data_seen_keys
242+
if not cache_on_first_load:
243+
_parser_source_data_seen_keys[seen_key] = None
244+
_parser_source_data_seen_keys.move_to_end(seen_key)
245+
while len(_parser_source_data_seen_keys) > _PARSER_SOURCE_DATA_CACHE_MAX_SIZE:
246+
_parser_source_data_seen_keys.popitem(last=False)
247+
248+
data = resolved_path.read_bytes()
249+
if not use_cache:
250+
return _load_parser_source_data_from_bytes(resolved_path, data, encoding)
251+
252+
digest = sha256(data).hexdigest()
253+
cache_key = (resolved_path, digest, encoding, yaml_backend)
254+
with _parser_source_data_cache_lock:
255+
if cache_key in _parser_source_data_cache:
256+
_parser_source_data_cache.move_to_end(cache_key)
257+
return _parser_source_data_cache[cache_key]
258+
259+
parsed_data = _load_parser_source_data_from_bytes(resolved_path, data, encoding)
260+
with _parser_source_data_cache_lock:
261+
_parser_source_data_cache[cache_key] = parsed_data
262+
_parser_source_data_cache.move_to_end(cache_key)
263+
while len(_parser_source_data_cache) > _PARSER_SOURCE_DATA_CACHE_MAX_SIZE:
264+
_parser_source_data_cache.popitem(last=False)
265+
return parsed_data
266+
267+
268+
def _load_parser_source_data_from_bytes(path: Path, data: bytes, encoding: str) -> YamlValue:
207269
import json # noqa: PLC0415
208270

271+
text = data.decode(encoding)
209272
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)
273+
with contextlib.suppress(json.JSONDecodeError):
274+
return json.loads(text)
275+
276+
return load_yaml(text)
277+
278+
279+
def _clear_parser_source_data_cache() -> None:
280+
with _parser_source_data_cache_lock:
281+
_parser_source_data_cache.clear()
282+
_parser_source_data_seen_keys.clear()
215283

216284

217285
@_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_parsed_source_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_parsed_source_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_parsed_source_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_parsed_source_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: 42 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_parsed_source_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_parsed_source_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,9 @@ 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_parsed_source_cache: bool = (
1320+
config.enable_parsed_source_cache and self._cache_parsed_sources_from_path
1321+
)
13031322
self.custom_template_dir = config.custom_template_dir
13041323
self.extra_template_data: defaultdict[str, Any] = config.extra_template_data or defaultdict(dict)
13051324
self.validators = config.validators
@@ -1519,12 +1538,30 @@ def _iter_source_uncached(self) -> Iterator[Source]:
15191538
if path.is_dir():
15201539
for p in sorted(path.rglob("*"), key=lambda p: p.name):
15211540
if p.is_file():
1522-
yield Source.from_path(p, self.base_path, self.encoding)
1541+
yield Source.from_path(
1542+
p,
1543+
self.base_path,
1544+
self.encoding,
1545+
enable_parsed_source_cache=self.enable_parsed_source_cache,
1546+
keep_text=self.validation,
1547+
)
15231548
else:
1524-
yield Source.from_path(path, self.base_path, self.encoding)
1549+
yield Source.from_path(
1550+
path,
1551+
self.base_path,
1552+
self.encoding,
1553+
enable_parsed_source_cache=self.enable_parsed_source_cache,
1554+
keep_text=self.validation,
1555+
)
15251556
case list() as paths: # pragma: no cover
15261557
for path in paths:
1527-
yield Source.from_path(path, self.base_path, self.encoding)
1558+
yield Source.from_path(
1559+
path,
1560+
self.base_path,
1561+
self.encoding,
1562+
enable_parsed_source_cache=self.enable_parsed_source_cache,
1563+
keep_text=self.validation,
1564+
)
15281565
case _:
15291566
yield Source(
15301567
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)