spaghettimap 0.3.0__tar.gz

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,117 @@
1
+ Metadata-Version: 2.3
2
+ Name: spaghettimap
3
+ Version: 0.3.0
4
+ Summary: Add your description here
5
+ Author: Fowler
6
+ Author-email: Fowler <matty01fowler@gmail.com>
7
+ Requires-Dist: jmespath>=1.1.0
8
+ Requires-Dist: pydantic>=2.0
9
+ Requires-Python: >=3.12
10
+ Description-Content-Type: text/markdown
11
+
12
+ # spaghettimap
13
+
14
+ A Python library for **pydantic model-to-model conversion** powered by [JMESPath](https://jmespath.org/).
15
+
16
+ ## Features
17
+
18
+ - Map any pydantic `BaseModel` to another using a declarative schema
19
+ - Schema values can be **JMESPath expressions**, **Python callables**, or a **dict** combining both with an optional `transform`
20
+ - Full support for all **JMESPath built-in functions** (`length`, `sort`, `max_by`, `contains`, `join`, `keys`, `to_string`, …)
21
+ - **Custom JMESPath functions** via `jmespath.functions.Functions` subclass
22
+ - **Filter expressions**, multi-select hash/list, pipe expressions, wildcards, and or-expressions
23
+ - Solid **error handling** with `ConfigurationError`, `MappingError`, and `FieldMappingError` – all with clear, field-specific messages
24
+ - **Fail-fast config checks** for invalid JMESPath expressions and schema fields missing from the target model
25
+ - Pydantic validators (`@field_validator`, `@model_validator`) and type coercion run on the target model automatically
26
+ - `map_many()` for batch conversion of model lists
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pip install spaghettimap
32
+ # or with uv
33
+ uv add spaghettimap
34
+ ```
35
+
36
+ ## Quick Start
37
+
38
+ ```python
39
+ from pydantic import BaseModel
40
+ from spaghettimap import Mapper, MappingConfig
41
+
42
+ class Source(BaseModel):
43
+ first_name: str
44
+ last_name: str
45
+ contact: dict # {"email": "...", "phone": "..."}
46
+ tags: list[dict] # [{"name": "...", "weight": 1.0}]
47
+
48
+ class Target(BaseModel):
49
+ full_name: str
50
+ email: str
51
+ tag_count: int
52
+ upper_name: str
53
+
54
+ mapper = Mapper()
55
+ mapper.add_config(
56
+ MappingConfig(
57
+ from_type=Source,
58
+ to_type=Target,
59
+ schema={
60
+ # Python callable
61
+ "full_name": lambda d: f"{d['first_name']} {d['last_name']}",
62
+ # Nested JMESPath expression
63
+ "email": "contact.email",
64
+ # JMESPath built-in function
65
+ "tag_count": "length(tags)",
66
+ # JMESPath expression + Python transform
67
+ "upper_name": {"expression": "first_name", "transform": str.upper},
68
+ },
69
+ )
70
+ )
71
+
72
+ result: Target = mapper.map(source_instance, Target)
73
+ ```
74
+
75
+ ## Schema Value Types
76
+
77
+ | Type | Description | Example |
78
+ |------|-------------|---------|
79
+ | `str` | JMESPath expression | `"contact.email"`, `"tags[*].name"`, `"length(tags)"` |
80
+ | `Callable[[dict], Any]` | Python function receiving the full source dict | `lambda d: d["x"] + d["y"]` |
81
+ | `dict` | `{"expression": str\|Callable, "transform": Callable}` | `{"expression": "price", "transform": lambda p: f"£{p:.2f}"}` |
82
+
83
+ ## Custom JMESPath Functions
84
+
85
+ ```python
86
+ import jmespath.functions
87
+ from spaghettimap import Mapper, MappingConfig
88
+
89
+ class MyFunctions(jmespath.functions.Functions):
90
+ @jmespath.functions.signature({"types": ["string"]})
91
+ def _func_upper(self, value: str) -> str:
92
+ return value.upper()
93
+
94
+ mapper.add_config(
95
+ MappingConfig(
96
+ from_type=Source,
97
+ to_type=Target,
98
+ schema={"name": "upper(first_name)"},
99
+ custom_functions=MyFunctions(),
100
+ )
101
+ )
102
+ ```
103
+
104
+ ## Batch Mapping
105
+
106
+ ```python
107
+ results: list[Target] = mapper.map_many(source_list, Target)
108
+ ```
109
+
110
+ ## Error Hierarchy
111
+
112
+ ```
113
+ SpaghettimapMapperError
114
+ ├── ConfigurationError – invalid config (bad types, missing keys, unregistered pair)
115
+ └── MappingError – runtime mapping failure
116
+ └── FieldMappingError – failure for a specific field (has .field attribute)
117
+ ```
@@ -0,0 +1,106 @@
1
+ # spaghettimap
2
+
3
+ A Python library for **pydantic model-to-model conversion** powered by [JMESPath](https://jmespath.org/).
4
+
5
+ ## Features
6
+
7
+ - Map any pydantic `BaseModel` to another using a declarative schema
8
+ - Schema values can be **JMESPath expressions**, **Python callables**, or a **dict** combining both with an optional `transform`
9
+ - Full support for all **JMESPath built-in functions** (`length`, `sort`, `max_by`, `contains`, `join`, `keys`, `to_string`, …)
10
+ - **Custom JMESPath functions** via `jmespath.functions.Functions` subclass
11
+ - **Filter expressions**, multi-select hash/list, pipe expressions, wildcards, and or-expressions
12
+ - Solid **error handling** with `ConfigurationError`, `MappingError`, and `FieldMappingError` – all with clear, field-specific messages
13
+ - **Fail-fast config checks** for invalid JMESPath expressions and schema fields missing from the target model
14
+ - Pydantic validators (`@field_validator`, `@model_validator`) and type coercion run on the target model automatically
15
+ - `map_many()` for batch conversion of model lists
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ pip install spaghettimap
21
+ # or with uv
22
+ uv add spaghettimap
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ ```python
28
+ from pydantic import BaseModel
29
+ from spaghettimap import Mapper, MappingConfig
30
+
31
+ class Source(BaseModel):
32
+ first_name: str
33
+ last_name: str
34
+ contact: dict # {"email": "...", "phone": "..."}
35
+ tags: list[dict] # [{"name": "...", "weight": 1.0}]
36
+
37
+ class Target(BaseModel):
38
+ full_name: str
39
+ email: str
40
+ tag_count: int
41
+ upper_name: str
42
+
43
+ mapper = Mapper()
44
+ mapper.add_config(
45
+ MappingConfig(
46
+ from_type=Source,
47
+ to_type=Target,
48
+ schema={
49
+ # Python callable
50
+ "full_name": lambda d: f"{d['first_name']} {d['last_name']}",
51
+ # Nested JMESPath expression
52
+ "email": "contact.email",
53
+ # JMESPath built-in function
54
+ "tag_count": "length(tags)",
55
+ # JMESPath expression + Python transform
56
+ "upper_name": {"expression": "first_name", "transform": str.upper},
57
+ },
58
+ )
59
+ )
60
+
61
+ result: Target = mapper.map(source_instance, Target)
62
+ ```
63
+
64
+ ## Schema Value Types
65
+
66
+ | Type | Description | Example |
67
+ |------|-------------|---------|
68
+ | `str` | JMESPath expression | `"contact.email"`, `"tags[*].name"`, `"length(tags)"` |
69
+ | `Callable[[dict], Any]` | Python function receiving the full source dict | `lambda d: d["x"] + d["y"]` |
70
+ | `dict` | `{"expression": str\|Callable, "transform": Callable}` | `{"expression": "price", "transform": lambda p: f"£{p:.2f}"}` |
71
+
72
+ ## Custom JMESPath Functions
73
+
74
+ ```python
75
+ import jmespath.functions
76
+ from spaghettimap import Mapper, MappingConfig
77
+
78
+ class MyFunctions(jmespath.functions.Functions):
79
+ @jmespath.functions.signature({"types": ["string"]})
80
+ def _func_upper(self, value: str) -> str:
81
+ return value.upper()
82
+
83
+ mapper.add_config(
84
+ MappingConfig(
85
+ from_type=Source,
86
+ to_type=Target,
87
+ schema={"name": "upper(first_name)"},
88
+ custom_functions=MyFunctions(),
89
+ )
90
+ )
91
+ ```
92
+
93
+ ## Batch Mapping
94
+
95
+ ```python
96
+ results: list[Target] = mapper.map_many(source_list, Target)
97
+ ```
98
+
99
+ ## Error Hierarchy
100
+
101
+ ```
102
+ SpaghettimapMapperError
103
+ ├── ConfigurationError – invalid config (bad types, missing keys, unregistered pair)
104
+ └── MappingError – runtime mapping failure
105
+ └── FieldMappingError – failure for a specific field (has .field attribute)
106
+ ```
@@ -0,0 +1,23 @@
1
+ [project]
2
+ name = "spaghettimap"
3
+ version = "0.3.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [{ name = "Fowler", email = "matty01fowler@gmail.com" }]
7
+ requires-python = ">=3.12"
8
+ dependencies = ["jmespath>=1.1.0", "pydantic>=2.0"]
9
+
10
+ [build-system]
11
+ requires = ["uv_build>=0.9.7,<0.10.0"]
12
+ build-backend = "uv_build"
13
+
14
+ [dependency-groups]
15
+ dev = ["pytest>=9.0.2", "pytest-cov>=6.0"]
16
+
17
+ [tool.semantic_release]
18
+ branch = "main"
19
+ commit_parser = "conventional"
20
+ version_toml = ["pyproject.toml:project.version"]
21
+ build_command = "uv build"
22
+ major_on_zero = false
23
+ allow_zero_version = true
@@ -0,0 +1,19 @@
1
+ """jmespath-mapper – pydantic model-to-model conversion via JMESPath."""
2
+
3
+ from .config import MappingConfig
4
+ from .exceptions import (
5
+ ConfigurationError,
6
+ FieldMappingError,
7
+ SpaghettimapMapperError,
8
+ MappingError,
9
+ )
10
+ from .mapper import Mapper
11
+
12
+ __all__ = [
13
+ "Mapper",
14
+ "MappingConfig",
15
+ "SpaghettimapMapperError",
16
+ "ConfigurationError",
17
+ "MappingError",
18
+ "FieldMappingError",
19
+ ]
@@ -0,0 +1,193 @@
1
+ """MappingConfig definition and schema validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable
6
+
7
+ import jmespath
8
+ import jmespath.exceptions
9
+ import jmespath.functions
10
+ from pydantic import BaseModel
11
+
12
+ from .exceptions import ConfigurationError
13
+
14
+ # A schema value can be:
15
+ # - str → JMESPath expression
16
+ # - Callable[[dict], Any] → Python function receiving the source model dict
17
+ # - dict → {"expression": str | Callable, "transform": Callable (optional)}
18
+ FieldMapping = str | Callable[..., Any] | dict[str, Any]
19
+
20
+
21
+ def _is_basemodel_subclass(typ: Any) -> bool:
22
+ try:
23
+ return isinstance(typ, type) and issubclass(typ, BaseModel)
24
+ except TypeError:
25
+ return False
26
+
27
+
28
+ class MappingConfig:
29
+ """
30
+ Configuration describing how to map from one pydantic model type to another.
31
+
32
+ Parameters
33
+ ----------
34
+ from_type:
35
+ The source pydantic ``BaseModel`` subclass.
36
+ to_type:
37
+ The target pydantic ``BaseModel`` subclass.
38
+ schema:
39
+ A mapping of *target field name* → field mapping. Optional – if
40
+ omitted (or ``None``), *passthrough* is automatically enabled so that
41
+ all target fields are auto-mapped from source fields of the same name.
42
+
43
+ Each value may be:
44
+
45
+ * **str** – a JMESPath expression evaluated against the source model
46
+ serialised to a plain ``dict``.
47
+ * **Callable[[dict], Any]** – a Python callable that receives the full
48
+ source dict and returns the field value.
49
+ * **dict** – must contain an ``"expression"`` key (``str`` or
50
+ ``Callable``) and an optional ``"transform"`` key (``Callable``)
51
+ applied to the extracted value after the expression is evaluated.
52
+
53
+ passthrough:
54
+ When ``True``, any target field *not* covered by *schema* is
55
+ automatically filled from the source field with the **same name**
56
+ (if one exists). Fields already produced by *schema* are never
57
+ overwritten. Defaults to ``False`` unless *schema* is omitted/``None``,
58
+ in which case it is set to ``True`` automatically.
59
+
60
+ custom_functions:
61
+ An optional instance of a :class:`jmespath.functions.Functions`
62
+ subclass that provides additional JMESPath functions available to
63
+ *all* expressions in this config.
64
+
65
+ Raises
66
+ ------
67
+ ConfigurationError
68
+ If ``from_type`` or ``to_type`` are not pydantic ``BaseModel``
69
+ subclasses, or if any schema value has an unsupported type.
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ from_type: type[BaseModel],
75
+ to_type: type[BaseModel],
76
+ schema: dict[str, FieldMapping] | None = None,
77
+ custom_functions: jmespath.functions.Functions | None = None,
78
+ passthrough: bool = False,
79
+ ) -> None:
80
+ if not _is_basemodel_subclass(from_type):
81
+ raise ConfigurationError(
82
+ f"'from_type' must be a pydantic BaseModel subclass, got {from_type!r}"
83
+ )
84
+ if not _is_basemodel_subclass(to_type):
85
+ raise ConfigurationError(
86
+ f"'to_type' must be a pydantic BaseModel subclass, got {to_type!r}"
87
+ )
88
+ if schema is None:
89
+ # No schema provided → enable passthrough automatically.
90
+ schema = {}
91
+ passthrough = True
92
+ elif not isinstance(schema, dict):
93
+ raise ConfigurationError(
94
+ f"'schema' must be a dict, got {type(schema).__name__!r}"
95
+ )
96
+
97
+ for key, value in schema.items():
98
+ if not isinstance(key, str):
99
+ raise ConfigurationError(
100
+ f"All schema keys must be strings; got key {key!r} of type {type(key).__name__!r}"
101
+ )
102
+ _validate_field_mapping(key, value)
103
+
104
+ unknown_fields = [
105
+ field for field in schema if field not in to_type.model_fields
106
+ ]
107
+ if unknown_fields:
108
+ raise ConfigurationError(
109
+ f"Schema contains field(s) not present on target model {to_type.__name__!r}: "
110
+ f"{unknown_fields!r}"
111
+ )
112
+
113
+ if custom_functions is not None and not isinstance(
114
+ custom_functions, jmespath.functions.Functions
115
+ ):
116
+ raise ConfigurationError(
117
+ "'custom_functions' must be an instance of jmespath.functions.Functions "
118
+ f"(or a subclass), got {type(custom_functions).__name__!r}"
119
+ )
120
+
121
+ self.from_type = from_type
122
+ self.to_type = to_type
123
+ self.schema = schema
124
+ self.passthrough = passthrough
125
+ self.custom_functions = custom_functions
126
+ self._target_field_names = tuple(to_type.model_fields.keys())
127
+ self._jmespath_options = (
128
+ jmespath.Options(custom_functions=custom_functions)
129
+ if custom_functions is not None
130
+ else None
131
+ )
132
+ self._compiled_expressions = _compile_schema_expressions(schema)
133
+
134
+ def __repr__(self) -> str:
135
+ return (
136
+ f"MappingConfig("
137
+ f"from_type={self.from_type.__name__!r}, "
138
+ f"to_type={self.to_type.__name__!r}, "
139
+ f"fields={list(self.schema.keys())!r}, "
140
+ f"passthrough={self.passthrough!r})"
141
+ )
142
+
143
+
144
+ def _validate_field_mapping(key: str, value: FieldMapping) -> None:
145
+ """Raise ConfigurationError if *value* is not a valid field mapping."""
146
+ if isinstance(value, (str, Callable)): # type: ignore[arg-type]
147
+ return
148
+ if isinstance(value, dict):
149
+ if "expression" not in value:
150
+ raise ConfigurationError(
151
+ f"Schema value for field '{key}' is a dict but is missing the required "
152
+ f"'expression' key. Got keys: {list(value.keys())!r}"
153
+ )
154
+ expr = value["expression"]
155
+ if not isinstance(expr, str) and not callable(expr):
156
+ raise ConfigurationError(
157
+ f"'expression' for field '{key}' must be a str or callable, "
158
+ f"got {type(expr).__name__!r}"
159
+ )
160
+ transform = value.get("transform")
161
+ if transform is not None and not callable(transform):
162
+ raise ConfigurationError(
163
+ f"'transform' for field '{key}' must be callable, "
164
+ f"got {type(transform).__name__!r}"
165
+ )
166
+ return
167
+ raise ConfigurationError(
168
+ f"Schema value for field '{key}' must be a str, callable, or dict; "
169
+ f"got {type(value).__name__!r}"
170
+ )
171
+
172
+
173
+ def _compile_schema_expressions(schema: dict[str, FieldMapping]) -> dict[str, Any]:
174
+ """Pre-compile all string JMESPath expressions in *schema*."""
175
+ compiled: dict[str, Any] = {}
176
+ for key, value in schema.items():
177
+ if isinstance(value, str):
178
+ compiled[key] = _compile_expression(key, value)
179
+ continue
180
+ if isinstance(value, dict):
181
+ expr = value.get("expression")
182
+ if isinstance(expr, str):
183
+ compiled[key] = _compile_expression(key, expr)
184
+ return compiled
185
+
186
+
187
+ def _compile_expression(field_name: str, expression: str) -> Any:
188
+ try:
189
+ return jmespath.compile(expression)
190
+ except jmespath.exceptions.JMESPathError as exc:
191
+ raise ConfigurationError(
192
+ f"Invalid JMESPath expression for field '{field_name}': {expression!r}: {exc}"
193
+ ) from exc
@@ -0,0 +1,29 @@
1
+ """Custom exceptions for jmespath-mapper."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class SpaghettimapMapperError(Exception):
7
+ """Base exception for all jmespath-mapper errors."""
8
+
9
+
10
+ class ConfigurationError(SpaghettimapMapperError):
11
+ """Raised when a MappingConfig is invalid."""
12
+
13
+
14
+ class MappingError(SpaghettimapMapperError):
15
+ """Raised when a mapping operation fails at runtime."""
16
+
17
+
18
+ class FieldMappingError(MappingError):
19
+ """Raised when mapping a specific field fails."""
20
+
21
+ def __init__(
22
+ self, field: str, reason: str, source_exception: BaseException | None = None
23
+ ) -> None:
24
+ self.field = field
25
+ self.reason = reason
26
+ message = f"Failed to map field '{field}': {reason}"
27
+ super().__init__(message)
28
+ if source_exception is not None:
29
+ self.__cause__ = source_exception
@@ -0,0 +1,256 @@
1
+ """Mapper class and internal evaluation helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, TypeVar, cast
6
+
7
+ import jmespath
8
+ import jmespath.exceptions
9
+ import jmespath.functions
10
+ from pydantic import BaseModel, ValidationError
11
+
12
+ from .config import FieldMapping, MappingConfig, _is_basemodel_subclass
13
+ from .exceptions import ConfigurationError, FieldMappingError, MappingError
14
+
15
+ T = TypeVar("T", bound=BaseModel)
16
+
17
+
18
+ class Mapper:
19
+ """
20
+ Holds one or more :class:`MappingConfig` objects and performs pydantic
21
+ model-to-model conversions using JMESPath.
22
+
23
+ Usage
24
+ -----
25
+ ::
26
+
27
+ mapper = Mapper()
28
+ mapper.add_config(
29
+ MappingConfig(
30
+ from_type=SourceModel,
31
+ to_type=TargetModel,
32
+ schema={
33
+ "name": "firstName",
34
+ "email": "contact.email",
35
+ "tag_count": "length(tags)",
36
+ "upper_name": {"expression": "firstName", "transform": str.upper},
37
+ "computed": lambda d: d["x"] + d["y"],
38
+ },
39
+ )
40
+ )
41
+ result: TargetModel = mapper.map(source_instance, TargetModel)
42
+ """
43
+
44
+ def __init__(self) -> None:
45
+ # Keyed by (from_type, to_type) pairs for O(1) lookup.
46
+ self._configs: dict[tuple[type[BaseModel], type[BaseModel]], MappingConfig] = {}
47
+
48
+ # ------------------------------------------------------------------
49
+ # Config management
50
+ # ------------------------------------------------------------------
51
+
52
+ def add_config(self, config: MappingConfig) -> None:
53
+ """
54
+ Register a :class:`MappingConfig`.
55
+
56
+ If a config already exists for the same ``(from_type, to_type)`` pair
57
+ it is silently replaced.
58
+
59
+ Raises
60
+ ------
61
+ ConfigurationError
62
+ If *config* is not a :class:`MappingConfig` instance.
63
+ """
64
+ if not isinstance(config, MappingConfig):
65
+ raise ConfigurationError(
66
+ f"Expected a MappingConfig instance, got {type(config).__name__!r}"
67
+ )
68
+ self._configs[(config.from_type, config.to_type)] = config
69
+
70
+ def get_config(
71
+ self, from_type: type[BaseModel], to_type: type[BaseModel]
72
+ ) -> MappingConfig | None:
73
+ """Return the registered config for *from_type* → *to_type*, or ``None``."""
74
+ return self._configs.get((from_type, to_type))
75
+
76
+ def _resolve_config(
77
+ self, from_type: type[BaseModel], to_type: type[BaseModel]
78
+ ) -> MappingConfig | None:
79
+ """Return config for *from_type*→*to_type*, allowing BaseModel ancestry fallback."""
80
+ config = self.get_config(from_type, to_type)
81
+ if config is not None:
82
+ return config
83
+
84
+ for base in from_type.__mro__[1:]:
85
+ if not _is_basemodel_subclass(base):
86
+ continue
87
+ inherited = self.get_config(cast(type[BaseModel], base), to_type)
88
+ if inherited is not None:
89
+ return inherited
90
+ return None
91
+
92
+ # ------------------------------------------------------------------
93
+ # Mapping
94
+ # ------------------------------------------------------------------
95
+
96
+ def map(self, source: BaseModel, to_type: type[T]) -> T:
97
+ """
98
+ Map *source* to a validated instance of *to_type*.
99
+
100
+ Raises
101
+ ------
102
+ ConfigurationError
103
+ If *source* is not a BaseModel instance, *to_type* is not a
104
+ BaseModel subclass, or no config is registered for the pair.
105
+ FieldMappingError
106
+ If evaluating a field expression or transform raises an exception.
107
+ MappingError
108
+ If pydantic validation of the constructed target model fails.
109
+ """
110
+ if not isinstance(source, BaseModel):
111
+ raise ConfigurationError(
112
+ f"'source' must be a pydantic BaseModel instance, got {type(source).__name__!r}"
113
+ )
114
+ if not _is_basemodel_subclass(to_type):
115
+ raise ConfigurationError(
116
+ f"'to_type' must be a pydantic BaseModel subclass, got {to_type!r}"
117
+ )
118
+
119
+ config = self._resolve_config(type(source), to_type)
120
+ if config is None:
121
+ raise ConfigurationError(
122
+ f"No mapping config registered for {type(source).__name__!r} → {to_type.__name__!r}. "
123
+ f"Call add_config() with a MappingConfig for this pair first."
124
+ )
125
+
126
+ # Serialise to a plain dict; nested BaseModel instances become dicts.
127
+ source_dict: dict[str, Any] = source.model_dump(mode="python")
128
+
129
+ compiled_expressions = config._compiled_expressions
130
+ result: dict[str, Any] = {
131
+ field: _evaluate_field_mapping(
132
+ field,
133
+ mapping,
134
+ source_dict,
135
+ compiled_expressions.get(field),
136
+ config._jmespath_options,
137
+ )
138
+ for field, mapping in config.schema.items()
139
+ }
140
+
141
+ if config.passthrough:
142
+ for field_name in config._target_field_names:
143
+ if field_name not in result and field_name in source_dict:
144
+ result[field_name] = source_dict[field_name]
145
+
146
+ try:
147
+ return to_type.model_validate(result)
148
+ except ValidationError as exc:
149
+ raise MappingError(
150
+ f"Pydantic validation failed while constructing {to_type.__name__!r} "
151
+ f"from mapped data: {exc}"
152
+ ) from exc
153
+
154
+ def map_many(self, sources: list[BaseModel], to_type: type[T]) -> list[T]:
155
+ """
156
+ Map a list of source instances to a list of *to_type* instances.
157
+
158
+ Raises
159
+ ------
160
+ ConfigurationError
161
+ If *sources* is not a list.
162
+ """
163
+ if not isinstance(sources, list):
164
+ raise ConfigurationError(
165
+ f"'sources' must be a list, got {type(sources).__name__!r}"
166
+ )
167
+ return [self.map(source, to_type) for source in sources]
168
+
169
+ def __repr__(self) -> str:
170
+ pairs = [f"{f.__name__!r}→{t.__name__!r}" for f, t in self._configs]
171
+ return f"Mapper(configs=[{', '.join(pairs)}])"
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Internal helpers
176
+ # ---------------------------------------------------------------------------
177
+
178
+
179
+ def _evaluate_field_mapping(
180
+ field_name: str,
181
+ mapping: FieldMapping,
182
+ source_dict: dict[str, Any],
183
+ compiled_expression: Any | None,
184
+ jmespath_options: jmespath.Options | None,
185
+ ) -> Any:
186
+ """Evaluate a single field mapping against *source_dict*."""
187
+ try:
188
+ if isinstance(mapping, str):
189
+ return _eval_jmespath(
190
+ field_name,
191
+ mapping,
192
+ source_dict,
193
+ jmespath_options,
194
+ compiled_expression,
195
+ )
196
+
197
+ if callable(mapping):
198
+ return mapping(source_dict)
199
+
200
+ if isinstance(mapping, dict):
201
+ expr = mapping["expression"]
202
+ value = (
203
+ _eval_jmespath(
204
+ field_name,
205
+ expr,
206
+ source_dict,
207
+ jmespath_options,
208
+ compiled_expression,
209
+ )
210
+ if isinstance(expr, str)
211
+ else expr(source_dict)
212
+ )
213
+ transform = mapping.get("transform")
214
+ return transform(value) if transform is not None else value
215
+
216
+ raise FieldMappingError(
217
+ field_name, f"Unsupported mapping type {type(mapping).__name__!r}"
218
+ )
219
+
220
+ except FieldMappingError:
221
+ raise
222
+ except Exception as exc:
223
+ raise FieldMappingError(field_name, str(exc), source_exception=exc) from exc
224
+
225
+
226
+ def _eval_jmespath(
227
+ field_name: str,
228
+ expression: str,
229
+ source_dict: dict[str, Any],
230
+ options: jmespath.Options | None,
231
+ compiled_expression: Any | None = None,
232
+ ) -> Any:
233
+ """Compile and search a JMESPath expression with clear error messaging."""
234
+ try:
235
+ compiled = (
236
+ compiled_expression
237
+ if compiled_expression is not None
238
+ else jmespath.compile(expression)
239
+ )
240
+ except jmespath.exceptions.JMESPathError as exc:
241
+ raise FieldMappingError(
242
+ field_name,
243
+ f"Invalid JMESPath expression {expression!r}: {exc}",
244
+ source_exception=exc,
245
+ ) from exc
246
+
247
+ try:
248
+ if options is None:
249
+ return compiled.search(source_dict)
250
+ return compiled.search(source_dict, options=options)
251
+ except jmespath.exceptions.JMESPathError as exc:
252
+ raise FieldMappingError(
253
+ field_name,
254
+ f"JMESPath evaluation error for expression {expression!r}: {exc}",
255
+ source_exception=exc,
256
+ ) from exc
File without changes