yaml-compose 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,78 @@
1
+ """Safe YAML loading with file includes and mapping overlays."""
2
+
3
+ from ._search import IncludeSearch
4
+ from ._unset import UNSET, UnsetType
5
+ from .limits import (
6
+ DEFAULT_INCLUDE_LIMITS,
7
+ IncludeLimits,
8
+ get_default_include_limits,
9
+ set_default_include_limits,
10
+ )
11
+ from .loader import (
12
+ IncludeLoader,
13
+ YamlConfigLoader,
14
+ YamlIncludeError,
15
+ load_yaml_compose,
16
+ load_yaml_dispatch,
17
+ load_yaml_file,
18
+ )
19
+ from .resolver import (
20
+ FileRoot,
21
+ FileSource,
22
+ FileSystemIncludeResolver,
23
+ IncludeRequest,
24
+ IncludeResolver,
25
+ IncludeRoot,
26
+ IncludeSource,
27
+ MemoryIncludeResolver,
28
+ MemoryRoot,
29
+ MemorySource,
30
+ ResolvedInclude,
31
+ )
32
+ from .tags import (
33
+ COMPOSE_LOADER_TAG,
34
+ DEEP_MERGE_KEY,
35
+ DELETE_TAG,
36
+ INCLUDE_TAG,
37
+ LOADER_DIRECTIVE,
38
+ MERGE_KEY,
39
+ MERGE_TAG,
40
+ REPLACE_TAG,
41
+ SPREAD_TAG,
42
+ )
43
+
44
+ __all__ = (
45
+ "COMPOSE_LOADER_TAG",
46
+ "DEEP_MERGE_KEY",
47
+ "DEFAULT_INCLUDE_LIMITS",
48
+ "DELETE_TAG",
49
+ "INCLUDE_TAG",
50
+ "LOADER_DIRECTIVE",
51
+ "MERGE_KEY",
52
+ "MERGE_TAG",
53
+ "REPLACE_TAG",
54
+ "SPREAD_TAG",
55
+ "UNSET",
56
+ "FileRoot",
57
+ "FileSource",
58
+ "FileSystemIncludeResolver",
59
+ "IncludeLimits",
60
+ "IncludeLoader",
61
+ "IncludeRequest",
62
+ "IncludeResolver",
63
+ "IncludeRoot",
64
+ "IncludeSearch",
65
+ "IncludeSource",
66
+ "MemoryIncludeResolver",
67
+ "MemoryRoot",
68
+ "MemorySource",
69
+ "ResolvedInclude",
70
+ "UnsetType",
71
+ "YamlConfigLoader",
72
+ "YamlIncludeError",
73
+ "get_default_include_limits",
74
+ "load_yaml_compose",
75
+ "load_yaml_dispatch",
76
+ "load_yaml_file",
77
+ "set_default_include_limits",
78
+ )
@@ -0,0 +1,114 @@
1
+ """Mapping composition and validation shared by YAML merge keys and overlays.
2
+
3
+ Merge keys combine completed mappings by source priority and apply explicit
4
+ local values over the inherited mapping. Mapping operations copy their inputs
5
+ instead of modifying the original sources.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Hashable, Mapping, MutableSet
11
+ from dataclasses import dataclass
12
+ from typing import cast
13
+
14
+ import yaml
15
+ from yaml.constructor import ConstructorError
16
+ from yaml.error import Mark
17
+
18
+ from .tags import DELETE_TAG, REPLACE_TAG, SPREAD_TAG
19
+
20
+ YamlDict = dict[Hashable, object]
21
+ YamlType = YamlDict | list[object] | str | int | float | bool | None
22
+ DELETE = object()
23
+
24
+
25
+ class YamlIncludeError(yaml.YAMLError, ValueError):
26
+ """YAML error raised for invalid loader selection, includes, or overlays."""
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class SpreadMarker:
31
+ """Sequence items waiting for insertion into their containing list."""
32
+
33
+ source: list[object]
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class ReplaceMarker:
38
+ """A complete mapping that replaces one value during a deep overlay."""
39
+
40
+ source: YamlDict
41
+ start_mark: Mark
42
+
43
+
44
+ def assert_no_markers(value: object, seen: MutableSet[int] | None = None) -> None:
45
+ """Reject unapplied operations in a completed value, including cyclic data."""
46
+ if value is DELETE:
47
+ raise YamlIncludeError(f"{DELETE_TAG} is only valid inside a mapping overlay")
48
+ if isinstance(value, SpreadMarker):
49
+ raise YamlIncludeError(f"{SPREAD_TAG} is only valid as a list item")
50
+ if isinstance(value, ReplaceMarker):
51
+ raise ConstructorError(
52
+ f"while applying {REPLACE_TAG}",
53
+ None,
54
+ f"{REPLACE_TAG} is only valid in a deep mapping overlay",
55
+ value.start_mark,
56
+ )
57
+ if seen is None:
58
+ seen = set()
59
+ if isinstance(value, dict):
60
+ mapping = cast(YamlDict, value)
61
+ if id(mapping) in seen:
62
+ return
63
+ seen.add(id(mapping))
64
+ for key, item in mapping.items():
65
+ assert_no_markers(key, seen)
66
+ assert_no_markers(item, seen)
67
+ elif isinstance(value, (list, tuple)):
68
+ sequence = cast(list[object] | tuple[object, ...], value)
69
+ if id(sequence) in seen:
70
+ return
71
+ seen.add(id(sequence))
72
+ for item in sequence:
73
+ assert_no_markers(item, seen)
74
+
75
+
76
+ # opaque-boundary: PyYAML values may contain arbitrary nested mappings.
77
+ def merge_mappings(
78
+ base: Mapping[Hashable, object],
79
+ overlay: Mapping[Hashable, object],
80
+ *,
81
+ deep: bool,
82
+ ) -> YamlDict:
83
+ """Apply local overrides, optionally recursing into inherited mappings."""
84
+ active: set[tuple[int, int]] = set()
85
+
86
+ # opaque-boundary: PyYAML values may contain arbitrary nested mappings.
87
+ def merge(left: Mapping[Hashable, object], right: Mapping[Hashable, object]) -> YamlDict:
88
+ pair = (id(left), id(right))
89
+ if pair in active:
90
+ raise YamlIncludeError("cyclic YAML aliases are not supported during mapping overlays")
91
+ active.add(pair)
92
+ try:
93
+ result = dict(left)
94
+ for key, value in right.items():
95
+ if value is DELETE:
96
+ result.pop(key, None)
97
+ elif isinstance(value, ReplaceMarker):
98
+ if not deep:
99
+ raise ConstructorError(
100
+ f"while applying {REPLACE_TAG}",
101
+ None,
102
+ f"{REPLACE_TAG} is only valid in a deep mapping overlay",
103
+ value.start_mark,
104
+ )
105
+ result[key] = value.source
106
+ elif deep and isinstance(result.get(key), dict) and isinstance(value, dict):
107
+ result[key] = merge(cast(YamlDict, result[key]), cast(YamlDict, value))
108
+ else:
109
+ result[key] = value
110
+ return result
111
+ finally:
112
+ active.remove(pair)
113
+
114
+ return merge(base, overlay)
yaml_compose/_paths.py ADDED
@@ -0,0 +1,58 @@
1
+ """Canonical filesystem lookup for includes within explicitly allowed roots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from pathlib import Path
7
+
8
+ from ._search import IncludeSearch, is_explicit_relative, search_locations
9
+
10
+
11
+ def resolve_include_file(
12
+ name: str,
13
+ current_file: Path | None,
14
+ search_paths: Iterable[Path],
15
+ allowed_roots: Iterable[Path],
16
+ *,
17
+ include_search: IncludeSearch = "local-first",
18
+ ) -> Path:
19
+ """Resolve a local include against its parent, search paths, or absolute path.
20
+
21
+ Roots arrive canonicalized from the loader. Each candidate is canonicalized
22
+ before the root check, so a symlink outside an allowed root is rejected.
23
+ Bare names follow ``include_search``. Explicit ``./`` and ``../`` names
24
+ require a containing file and never fall back to a search directory.
25
+ Absolute paths name one exact file independently of the search policy.
26
+ """
27
+ requested = Path(name)
28
+
29
+ roots = tuple(allowed_roots)
30
+ if not roots:
31
+ raise ValueError("include loading requires at least one allowed root")
32
+
33
+ candidates: list[Path] = []
34
+ if requested.is_absolute():
35
+ candidates.append(requested)
36
+ elif is_explicit_relative(name):
37
+ if current_file is None:
38
+ raise ValueError("explicit relative includes require an including source")
39
+ candidates.append(current_file.parent / requested)
40
+ else:
41
+ local = current_file.parent if current_file is not None else None
42
+ candidates.extend(root / requested for root in search_locations(local, search_paths, include_search))
43
+
44
+ for candidate in candidates:
45
+ try:
46
+ resolved = candidate.resolve(strict=True)
47
+ except FileNotFoundError:
48
+ continue
49
+ except (OSError, RuntimeError) as exc:
50
+ raise ValueError(f"cannot safely resolve include path {name!r}: {exc}") from exc
51
+
52
+ if not resolved.is_file():
53
+ continue
54
+ if not any(resolved.is_relative_to(root) for root in roots):
55
+ raise ValueError(f"include path {name!r} resolves outside the allowed roots")
56
+ return resolved
57
+
58
+ raise FileNotFoundError(f"include file {name!r} was not found")
@@ -0,0 +1,45 @@
1
+ """Shared include lookup policies for filesystem and virtual sources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from pathlib import PurePath
7
+ from typing import Literal, TypeVar, cast, get_args
8
+
9
+ IncludeSearch = Literal["local-first", "search-first", "local-only", "search-only"]
10
+ """Lookup policy for include names without an explicit relative prefix.
11
+
12
+ ``local`` means the directory of the document containing that include.
13
+ ``search`` means the configured search locations in their supplied order.
14
+ Absolute paths and explicit ``./`` or ``../`` paths bypass this policy.
15
+ """
16
+
17
+ _INCLUDE_SEARCH_CHOICES: tuple[str, ...] = get_args(IncludeSearch)
18
+
19
+ _TPath = TypeVar("_TPath", bound=PurePath)
20
+
21
+
22
+ def validate_include_search(value: object) -> IncludeSearch:
23
+ """Reject unknown lookup policies before any document is loaded."""
24
+ if not isinstance(value, str) or value not in _INCLUDE_SEARCH_CHOICES:
25
+ raise ValueError("include_search must be " + ", ".join(repr(x) for x in _INCLUDE_SEARCH_CHOICES))
26
+ return cast(IncludeSearch, value)
27
+
28
+
29
+ def is_explicit_relative(name: str) -> bool:
30
+ """Recognize a containing-file reference before path normalization removes dots."""
31
+ return name in (".", "..") or name.startswith(("./", "../", ".\\", "..\\"))
32
+
33
+
34
+ def search_locations(local: _TPath | None, search_paths: Iterable[_TPath], policy: IncludeSearch) -> tuple[_TPath, ...]:
35
+ """Order candidate directories without changing which roots are authorized."""
36
+ validate_include_search(policy)
37
+ nearby = () if local is None else (local,)
38
+ configured = tuple(search_paths)
39
+ if policy == "local-first":
40
+ return *nearby, *configured
41
+ if policy == "search-first":
42
+ return *configured, *nearby
43
+ if policy == "local-only":
44
+ return nearby
45
+ return configured
@@ -0,0 +1,37 @@
1
+ """Read resolver-owned binary streams with optional byte bounds."""
2
+
3
+ from typing import BinaryIO, cast
4
+
5
+ _DEFAULT_MAX_CHUNK_SIZE = 65_536
6
+
7
+
8
+ def _read_binary_chunk(stream: BinaryIO, size: int) -> bytes | None:
9
+ """Keep a resolver's possibly non-blocking read result at the boundary."""
10
+ value: object = stream.read(size)
11
+ return cast(bytes | None, value)
12
+
13
+
14
+ def read_bounded(stream: BinaryIO, limit: int | None, max_chunk_size: int | None = None) -> bytes:
15
+ """Read a binary stream in chunks, stopping at ``limit`` or EOF.
16
+
17
+ ``None`` reads to EOF. Bounded callers include one probe byte in ``limit``
18
+ so they can distinguish a complete file from one that exceeds the budget.
19
+ """
20
+ content = bytearray()
21
+ remaining = limit
22
+ chunk_size = _DEFAULT_MAX_CHUNK_SIZE if (max_chunk_size is None or max_chunk_size < 1) else max_chunk_size
23
+ while remaining is None or remaining > 0:
24
+ requested = min(remaining, chunk_size) if remaining is not None else chunk_size
25
+ chunk = _read_binary_chunk(stream, requested)
26
+ if not isinstance(chunk, bytes):
27
+ if chunk is None:
28
+ raise OSError("include resolver stream returned no blocking read result")
29
+ raise TypeError("include resolver streams must return bytes")
30
+ if len(chunk) > requested:
31
+ raise ValueError("include resolver stream returned more bytes than requested")
32
+ if not chunk:
33
+ break
34
+ content.extend(chunk)
35
+ if remaining is not None:
36
+ remaining -= len(chunk)
37
+ return bytes(content)
yaml_compose/_unset.py ADDED
@@ -0,0 +1,23 @@
1
+ """Typed singleton distinguishing omitted arguments from explicit values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import cast
6
+
7
+
8
+ class UnsetType:
9
+ """Sentinel type distinguishing an omitted argument from explicit ``None``."""
10
+
11
+ _instance: UnsetType | None = None
12
+
13
+ def __new__(cls) -> UnsetType:
14
+ """Return the one module-level instance for stable identity checks."""
15
+ if cls._instance is None:
16
+ cls._instance = super().__new__(cls)
17
+ return cast(UnsetType, cls._instance)
18
+
19
+ def __repr__(self) -> str:
20
+ return "UNSET"
21
+
22
+
23
+ UNSET = UnsetType()
@@ -0,0 +1,123 @@
1
+ """Keep caller-selected PyYAML loaders and duplicate-key policy together."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Hashable
6
+ from typing import Protocol, TypeVar, cast
7
+
8
+ from yaml import FullLoader, Loader, MappingNode, Node, SafeLoader, ScalarNode, UnsafeLoader
9
+ from yaml.constructor import ConstructorError
10
+
11
+ from .tags import (
12
+ DELETE_TAG,
13
+ INCLUDE_TAG,
14
+ MERGE_KEY,
15
+ MERGE_TAG,
16
+ REPLACE_TAG,
17
+ SPREAD_TAG,
18
+ )
19
+
20
+
21
+ class _ObjectConstructor(Protocol):
22
+ def construct_object(self, node: Node, deep: bool = False) -> object: ...
23
+
24
+
25
+ class _LoaderRegistries(Protocol):
26
+ yaml_constructors: dict[Hashable, object]
27
+ yaml_multi_constructors: dict[Hashable, object]
28
+ yaml_implicit_resolvers: dict[Hashable, object]
29
+ yaml_path_resolvers: dict[Hashable, object]
30
+
31
+
32
+ class UniqueKeySafeLoader(SafeLoader):
33
+ """Reject duplicate explicit keys while retaining SafeLoader constructors."""
34
+
35
+ @staticmethod
36
+ def _is_merge_key(node: Node) -> bool:
37
+ return isinstance(node, ScalarNode) and node.tag == MERGE_TAG and node.value == MERGE_KEY
38
+
39
+ # opaque-boundary: PyYAML mappings can contain arbitrary hashable keys and values.
40
+ def construct_mapping(self, node: MappingNode, deep: bool = False) -> dict[Hashable, object]:
41
+ """Construct a mapping after checking its explicit keys."""
42
+ seen: set[Hashable] = set()
43
+ for key_node, _ in node.value:
44
+ if self._is_merge_key(key_node):
45
+ continue
46
+ construct = cast(_ObjectConstructor, self).construct_object
47
+ key = construct(key_node, False)
48
+ try:
49
+ hash(key)
50
+ except TypeError as exc:
51
+ raise ConstructorError(
52
+ "while constructing a mapping",
53
+ node.start_mark,
54
+ f"found unhashable key {key!r}",
55
+ key_node.start_mark,
56
+ ) from exc
57
+ if key in seen:
58
+ raise ConstructorError(
59
+ "while constructing a mapping",
60
+ node.start_mark,
61
+ f"found duplicate key {key!r}",
62
+ key_node.start_mark,
63
+ )
64
+ seen.add(key)
65
+ return super().construct_mapping(node, deep=deep)
66
+
67
+
68
+ def validate_yaml_loader(yaml_loader: object) -> None:
69
+ """Require a supported PyYAML loader class or one of its subclasses."""
70
+ supported_loaders = (SafeLoader, FullLoader, Loader, UnsafeLoader)
71
+ if not isinstance(yaml_loader, type) or not issubclass(yaml_loader, supported_loaders):
72
+ raise TypeError(
73
+ "yaml_loader must be a class derived from a supported PyYAML loader "
74
+ "(yaml.SafeLoader, yaml.FullLoader, yaml.Loader, or yaml.UnsafeLoader)"
75
+ )
76
+
77
+
78
+ def ordinary_loader(yaml_loader: type[object]) -> type[object]:
79
+ """Layer duplicate-key checking over a caller's PyYAML loader class."""
80
+ validate_yaml_loader(yaml_loader)
81
+ if yaml_loader is SafeLoader:
82
+ return UniqueKeySafeLoader
83
+ loader_class = cast(_LoaderRegistries, cast(object, yaml_loader))
84
+ return cast(
85
+ type[object],
86
+ type(
87
+ "UniqueKeyConfiguredLoader",
88
+ (UniqueKeySafeLoader, yaml_loader),
89
+ {
90
+ "yaml_constructors": dict(loader_class.yaml_constructors),
91
+ "yaml_multi_constructors": dict(loader_class.yaml_multi_constructors),
92
+ "yaml_implicit_resolvers": dict(loader_class.yaml_implicit_resolvers),
93
+ "yaml_path_resolvers": dict(loader_class.yaml_path_resolvers),
94
+ },
95
+ ),
96
+ )
97
+
98
+
99
+ _TLoader = TypeVar("_TLoader", bound=SafeLoader)
100
+
101
+
102
+ def configured_include_loader(yaml_loader: type[object], adapter: type[_TLoader]) -> type[_TLoader]:
103
+ """Give the include adapter a caller's constructors without changing global loaders."""
104
+ validate_yaml_loader(yaml_loader)
105
+ if yaml_loader is SafeLoader:
106
+ return adapter
107
+ loader_class = cast(_LoaderRegistries, cast(object, yaml_loader))
108
+ constructors = dict(loader_class.yaml_constructors)
109
+ for tag in (INCLUDE_TAG, DELETE_TAG, REPLACE_TAG, SPREAD_TAG):
110
+ constructors[tag] = adapter.yaml_constructors[tag]
111
+ return cast(
112
+ type[_TLoader],
113
+ type(
114
+ "ConfiguredIncludeLoader",
115
+ (adapter, yaml_loader),
116
+ {
117
+ "yaml_constructors": constructors,
118
+ "yaml_multi_constructors": dict(loader_class.yaml_multi_constructors),
119
+ "yaml_implicit_resolvers": dict(loader_class.yaml_implicit_resolvers),
120
+ "yaml_path_resolvers": dict(loader_class.yaml_path_resolvers),
121
+ },
122
+ ),
123
+ )
yaml_compose/limits.py ADDED
@@ -0,0 +1,129 @@
1
+ """Bounds for included files and the defaults used by new loaders.
2
+
3
+ ``DEFAULT_INCLUDE_LIMITS`` is the documented baseline. The active process-wide
4
+ value starts as a separate copy, which applications can inspect with
5
+ ``get_default_include_limits`` and replace with ``set_default_include_limits``.
6
+ A top-level load captures one limits snapshot; changing the default does not
7
+ change an existing loader.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import asdict, dataclass
13
+
14
+ from ._unset import UNSET, UnsetType
15
+
16
+
17
+ def _resolve_limit(value: int | UnsetType | None, default: int | None) -> int | None:
18
+ """Resolve an omitted override; the resulting profile validates its fields."""
19
+ return default if isinstance(value, UnsetType) else value
20
+
21
+
22
+ _DEFAULT_MAX_INCLUDE_DEPTH = 32
23
+ _DEFAULT_MAX_INCLUDE_FILES = 1024
24
+ _DEFAULT_MAX_FILE_BYTES = 8_388_608
25
+ _DEFAULT_MAX_TOTAL_INCLUDE_BYTES = 67_108_864
26
+
27
+
28
+ @dataclass(slots=True)
29
+ class IncludeLimits:
30
+ """Optional non-negative limits applied to files reached through ``!include``.
31
+
32
+ The top-level YAML input is not byte-limited. Each new top-level load starts
33
+ a fresh count. A non-negative integer enforces a bound; zero forbids any
34
+ use of that resource; ``None`` leaves that resource unbounded. Booleans,
35
+ negative numbers, and other values raise ``ValueError``.
36
+ ``UNSET`` is reserved for omitted function arguments, not profile fields.
37
+
38
+ These are package policy choices for configuration composition, not YAML
39
+ specification limits. File and byte counts include repeated reads of the
40
+ same source, including reads for spread and replacement values.
41
+ They bound input content, not the size of the constructed Python object graph.
42
+
43
+ Attributes:
44
+ max_include_depth: Maximum nested ``!include`` levels, or ``None``.
45
+ max_include_files: Maximum ``!include`` operations per top-level load,
46
+ counting repeated loads of the same source, or ``None``.
47
+ max_file_bytes: Maximum encoded bytes read from one included file, or ``None``.
48
+ max_total_include_bytes: Maximum encoded bytes read across all includes,
49
+ or ``None``.
50
+
51
+ """
52
+
53
+ max_include_depth: int | None = _DEFAULT_MAX_INCLUDE_DEPTH
54
+ max_include_files: int | None = _DEFAULT_MAX_INCLUDE_FILES
55
+ max_file_bytes: int | None = _DEFAULT_MAX_FILE_BYTES
56
+ max_total_include_bytes: int | None = _DEFAULT_MAX_TOTAL_INCLUDE_BYTES
57
+
58
+ def __post_init__(self) -> None:
59
+ """Reject invalid bounds before a limits profile can be used."""
60
+ values = (
61
+ self.max_include_depth,
62
+ self.max_include_files,
63
+ self.max_file_bytes,
64
+ self.max_total_include_bytes,
65
+ )
66
+ if any(value is not None and (type(value) is not int or value < 0) for value in values):
67
+ raise ValueError("include limits must be non-negative integers or None")
68
+
69
+
70
+ def _copy_limits(limits: IncludeLimits) -> IncludeLimits:
71
+ """Copy a profile into a validated snapshot."""
72
+ return IncludeLimits(**asdict(limits))
73
+
74
+
75
+ DEFAULT_INCLUDE_LIMITS = IncludeLimits()
76
+ _configured_default_limits = _copy_limits(DEFAULT_INCLUDE_LIMITS)
77
+
78
+
79
+ def get_default_include_limits() -> IncludeLimits:
80
+ """Return the active defaults for new top-level loads and loader instances.
81
+
82
+ The returned ``IncludeLimits`` is an independent copy. Use
83
+ ``dataclasses.replace`` and ``set_default_include_limits`` to change active
84
+ defaults.
85
+ """
86
+ return _copy_limits(_configured_default_limits)
87
+
88
+
89
+ def set_default_include_limits(limits: IncludeLimits) -> None:
90
+ """Replace the process-wide defaults for future loads and loader instances.
91
+
92
+ Pass an ``IncludeLimits`` instance, usually at application startup. Existing
93
+ ``YamlConfigLoader`` instances and in-progress loads keep their captured
94
+ limits. Per-call ``max_*`` arguments still override individual fields;
95
+ passing ``None`` explicitly disables one bound.
96
+ Pass ``DEFAULT_INCLUDE_LIMITS`` to restore the package baseline. A value
97
+ of another type raises ``TypeError``.
98
+ """
99
+ if type(limits) is not IncludeLimits:
100
+ raise TypeError("limits must be an IncludeLimits instance")
101
+ global _configured_default_limits
102
+ _configured_default_limits = _copy_limits(limits)
103
+
104
+
105
+ def resolve_include_limits(
106
+ max_include_depth: int | UnsetType | None,
107
+ max_include_files: int | UnsetType | None,
108
+ max_file_bytes: int | UnsetType | None,
109
+ max_total_include_bytes: int | UnsetType | None,
110
+ *,
111
+ include_limits: IncludeLimits | UnsetType = UNSET,
112
+ ) -> IncludeLimits:
113
+ """Combine a reusable profile and individual overrides into one snapshot.
114
+
115
+ An omitted profile uses the current process-wide default. Omitted field
116
+ overrides keep the profile value; explicit ``None`` removes that bound.
117
+ The returned limits profile is one snapshot, so later default changes
118
+ cannot alter an in-progress load. Invalid bounds raise ``ValueError`` and
119
+ an invalid profile raises ``TypeError``.
120
+ """
121
+ defaults = get_default_include_limits() if include_limits is UNSET else include_limits
122
+ if not isinstance(defaults, IncludeLimits):
123
+ raise TypeError("include_limits must be an IncludeLimits instance")
124
+ return IncludeLimits(
125
+ max_include_depth=_resolve_limit(max_include_depth, defaults.max_include_depth),
126
+ max_include_files=_resolve_limit(max_include_files, defaults.max_include_files),
127
+ max_file_bytes=_resolve_limit(max_file_bytes, defaults.max_file_bytes),
128
+ max_total_include_bytes=_resolve_limit(max_total_include_bytes, defaults.max_total_include_bytes),
129
+ )