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.
- spaghettimap-0.3.0/PKG-INFO +117 -0
- spaghettimap-0.3.0/README.md +106 -0
- spaghettimap-0.3.0/pyproject.toml +23 -0
- spaghettimap-0.3.0/src/spaghettimap/__init__.py +19 -0
- spaghettimap-0.3.0/src/spaghettimap/config.py +193 -0
- spaghettimap-0.3.0/src/spaghettimap/exceptions.py +29 -0
- spaghettimap-0.3.0/src/spaghettimap/mapper.py +256 -0
- spaghettimap-0.3.0/src/spaghettimap/py.typed +0 -0
|
@@ -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
|