Skip to content

Commit

Permalink
Merge pull request #2 from kirillsulim/develop
Browse files Browse the repository at this point in the history
Develop
  • Loading branch information
kirillsulim committed Jul 24, 2020
2 parents 75742b7 + d7c1742 commit 0261edc
Show file tree
Hide file tree
Showing 7 changed files with 290 additions and 25 deletions.
200 changes: 198 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,204 @@
# panamap

[![Build Status](https://travis-ci.com/kirillsulim/panamap.svg?branch=master)](https://travis-ci.com/kirillsulim/panamap)
[![PyPI version](https://badge.fury.io/py/panamap.svg)](https://badge.fury.io/py/panamap)
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/panamap)](https://pypi.org/project/panamap/)
[![Build Status](https://travis-ci.com/kirillsulim/panamap.svg?branch=master)](https://travis-ci.com/kirillsulim/panamap)
[![Coveralls github](https://img.shields.io/coveralls/github/kirillsulim/panamap)](https://coveralls.io/github/kirillsulim/panamap)


Panamap is python object mapper.
Panamap is a Python object mapper. It is useful to avoid boilerplate code when copying data between objects with
similar data, for example protobuf generated files and domain models.

## Installation


Use the package manager [pip](https://pip.pypa.io/en/stable/) to install foobar.

```bash
pip install panamap
```

## Usage

### Mapping primitive values

The most simple usage of panamap is to map primitive values:

```python
from panamap import Mapper

mapper = Mapper()
print(mapper.map("123", int) + 1)
# 124
```

### Mapping simple classes

To set up mapping call `mapping` function of mapper object.
Each field pair can be bind with `bidirectional` function or separately with `l_to_r` and `r_to_l` if we only need one
directional mapping or there is custom conversion on mapping.

Here are some examples:

```python
from panamap import Mapper

class A:
def __init__(self, a_value: int):
self.a_value = a_value


class B:
def __init__(self, b_value: int):
self.b_value = b_value

mapper = Mapper()
mapper.mapping(A, B) \
.l_to_r("a_value", "b_value") \
.register()

b = mapper.map(A(123), B)
print(b.b_value)
# 123
# a = mapper.map(B(123), A) will raise MissingMappingException cause we didn't set any r_to_l map rules

bidirectional_mapper = Mapper()
bidirectional_mapper.mapping(A, B) \
.bidirectional("a_value", "b_value") \
.register()

b = bidirectional_mapper.map(A(123), B)
print(b.b_value)
# 123
a = bidirectional_mapper.map(B(123), A)
print(a.a_value)
# 123

shifting_mapper = Mapper()
shifting_mapper.mapping(A, B) \
.l_to_r("a_value", "b_value", lambda a: a + 1) \
.r_to_l("a_value", "b_value", lambda b: b - 1) \
.register()

b = shifting_mapper.map(A(123), B)
print(b.b_value)
# 124
a = shifting_mapper.map(B(123), A)
print(a.a_value)
# 122
```

### Mapping empty classes

Sometimes there is need to convert one empty class to another. For such case there is `_empty` versions of map config
functions:

```python
from panamap import Mapper

class A:
pass

class B:
pass

mapper = Mapper()
mapper.mapping(A, B) \
.bidirectional_empty() \
.register()

b = mapper.map(A(), B)
print(isinstance(b, B))
# True
```

### Mapping nested fields

Panamap supports mapping of nested fields. To perform this mapping for nested fields classes must be set up.

```python
from dataclasses import dataclass
from panamap import Mapper

@dataclass
class NestedA:
value: str


@dataclass
class A:
value: NestedA


@dataclass
class NestedB:
value: str


@dataclass
class B:
value: NestedB

mapper = Mapper()
mapper.mapping(A, B) \
.map_matching() \
.register()
mapper.mapping(NestedA, NestedB) \
.map_matching() \
.register()

b = mapper.map(A(NestedA("abc")), B)
print(isinstance(b.value, NestedB))
# True
print(b.value.value)
# abc
```

### Mapping from and to dict

Panamap allow to set up mapping frm and to dict object. Here is an example:

```python
from typing import List
from dataclasses import dataclass
from panamap import Mapper

@dataclass
class Nested:
value: str


@dataclass
class A:
nested: Nested
list_of_nested: List[Nested]

mapper = Mapper()
mapper.mapping(A, dict) \
.map_matching() \
.register()
mapper.mapping(Nested, dict) \
.map_matching() \
.register()

a = mapper.map({
"nested": {
"value": "abc",
},
"list_of_nested": [
{"value": "def",},
{"value": "xyz",},
]},
A,
)
print(a)
# A(nested=Nested(value='abc'), list_of_nested=[Nested(value='def'), Nested(value='xyz')])
```

### Mapping protobuf generated classes

To map protobuf generated classes use separate module [panamap-proto](https://github.com/kirillsulim/panamap-proto).

## Contributing

Contributing described in [separate document](docs/contributing.md).
13 changes: 13 additions & 0 deletions docs/contributing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Contributing to panamap

All pull requests are welcome.

## Unit testing

Unit tests are run with [nox](https://github.com/theacodes/nox).

## Code style

Panamap uses [black](https://github.com/psf/black) codestyle with some tweaks.

Style check is performed as one of session in [noxfile.py](../noxfile.py).
64 changes: 44 additions & 20 deletions panamap/panamap.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from inspect import signature
from copy import deepcopy

from typing_inspect import get_origin, get_args, is_union_type
from typing_inspect import get_origin, get_args, is_union_type, is_forward_ref


@dataclass
Expand Down Expand Up @@ -113,7 +113,6 @@ class FieldMapRule(Generic[T1, F1, T2, F2]):
from_field: FieldDescriptor[T1, F1]
to_field: FieldDescriptor[T2, F2]
converter: Optional[Callable[[F1], F2]]
check_types: bool


class MappingDescriptor(ABC, Generic[T]):
Expand Down Expand Up @@ -337,7 +336,7 @@ def __init__(self, mapper: "Mapper", left_descriptor: MappingDescriptor, right_d
self.r_to_l_map_list: List[FieldMapRule] = []

def l_to_r(
self, left_field_name: str, right_field_name: str, converter: Callable[[Any], Any] = None, check_types=True
self, left_field_name: str, right_field_name: str, converter: Callable[[Any], Any] = None
) -> "MappingConfigFlow":
left_field = self.left_descriptor.get_field_descriptor(left_field_name)
if left_field is None:
Expand All @@ -347,14 +346,12 @@ def l_to_r(
if right_field is None:
raise UnsupportedFieldException(self.right_descriptor.type, right_field_name)

self.l_to_r_map_list.append(
FieldMapRule(from_field=left_field, to_field=right_field, converter=converter, check_types=check_types)
)
self.l_to_r_map_list.append(FieldMapRule(from_field=left_field, to_field=right_field, converter=converter))
self.l_to_r_touched = True
return self

def r_to_l(
self, left_field_name: str, right_field_name: str, converter: Callable[[Any], Any] = None, check_types=True
self, left_field_name: str, right_field_name: str, converter: Callable[[Any], Any] = None
) -> "MappingConfigFlow":
left_field = self.left_descriptor.get_field_descriptor(left_field_name)
if left_field is None:
Expand All @@ -364,15 +361,13 @@ def r_to_l(
if right_field is None:
raise UnsupportedFieldException(self.right_descriptor.type, right_field_name)

self.r_to_l_map_list.append(
FieldMapRule(from_field=right_field, to_field=left_field, converter=converter, check_types=check_types)
)
self.r_to_l_map_list.append(FieldMapRule(from_field=right_field, to_field=left_field, converter=converter))
self.r_to_l_touched = True
return self

def bidirectional(self, l_field_name: str, r_field_name: str, check_types=True) -> "MappingConfigFlow":
self.l_to_r(l_field_name, r_field_name, check_types=check_types)
self.r_to_l(l_field_name, r_field_name, check_types=check_types)
def bidirectional(self, l_field_name: str, r_field_name: str) -> "MappingConfigFlow":
self.l_to_r(l_field_name, r_field_name)
self.r_to_l(l_field_name, r_field_name)
return self

def l_to_r_empty(self):
Expand All @@ -396,7 +391,7 @@ def bidirectional_empty(self):
self.l_to_r_empty()
return self

def map_matching(self, ignore_case: bool = False, check_types=True) -> "MappingConfigFlow":
def map_matching(self, ignore_case: bool = False) -> "MappingConfigFlow":
if self.left_descriptor.is_container_type() and self.right_descriptor.is_container_type():
raise ImproperlyConfiguredException(
MappingExceptionInfo(self.left, self.right), "map matching for two container types doesn't make sense"
Expand Down Expand Up @@ -425,7 +420,7 @@ def map_matching(self, ignore_case: bool = False, check_types=True) -> "MappingC
for f in common_fields:
lf_name = l_fields[f]
rf_name = r_fields[f]
self.bidirectional(lf_name, rf_name, check_types=check_types)
self.bidirectional(lf_name, rf_name)
return self

def register(self) -> None:
Expand Down Expand Up @@ -453,6 +448,7 @@ class Mapper:
def __init__(self, custom_descriptors: Optional[List[Type[MappingDescriptor]]] = None):
self.map_rules: Dict[Type, Dict[Type, List[FieldMapRule]]] = {}
self.custom_descriptors = custom_descriptors if custom_descriptors else []
self.forward_ref_dict: Dict[str, Type[Any]] = {}

def mapping(self, a: Union[Type, MappingDescriptor], b: Union[Type, MappingDescriptor]) -> MappingConfigFlow:
if isinstance(a, Type):
Expand All @@ -474,6 +470,31 @@ def _add_map_rules(self, a: Type, b: Type, rules: List[FieldMapRule]):
raise DuplicateMappingException(a, b)

a_type_mappings[b] = rules
self._add_class_to_forward_ref_dict(a)
self._add_class_to_forward_ref_dict(b)

def _add_class_to_forward_ref_dict(self, t: Type):
if hasattr(t, "__name__"):
name = t.__name__
if name in self.forward_ref_dict and t != self.forward_ref_dict[name]:
raise Exception(
f"Conflicting forward references '{name}'. Rearrange your class definitions or use type aliases."
)
else:
self.forward_ref_dict[name] = t

def _resolve_forward_ref(self, t: Type[Any]):
if isinstance(t, str) or is_forward_ref(t):
if isinstance(t, str):
name = t
else:
name = get_args(t)[0]
if name in self.forward_ref_dict:
return self.forward_ref_dict[name]
else:
raise Exception(f"Unknown forward reference '{name}'")
else:
return t

def map(self, a_obj: Any, b: Type[T], exc_info: Optional[MappingExceptionInfo] = None) -> T:
a = a_obj.__class__
Expand Down Expand Up @@ -501,9 +522,11 @@ def _map_with_map_rules(self, a_obj: Any, b: Type[Any], exc_info: MappingExcepti
fields = []

for rule in self.map_rules[a][b]:
from_field_type = self._resolve_forward_ref(rule.from_field.type)
to_field_type = self._resolve_forward_ref(rule.to_field.type)
fields_exc_info = MappingExceptionInfo(
rule.from_field.type,
rule.to_field.type,
from_field_type,
to_field_type,
exc_info.a_fields_chain + [rule.from_field.name],
exc_info.b_fields_chain + [rule.to_field.name],
)
Expand All @@ -517,7 +540,7 @@ def _map_with_map_rules(self, a_obj: Any, b: Type[Any], exc_info: MappingExcepti
except Exception as e:
raise FieldMappingException(fields_exc_info, "Error on value conversion") from e
else:
value = self.map(field_value, rule.to_field.type, fields_exc_info)
value = self.map(field_value, to_field_type, fields_exc_info)

if rule.to_field.is_constructor_arg:
constructor_args[rule.to_field.name] = value
Expand Down Expand Up @@ -546,6 +569,7 @@ def _is_iterable_mapping_possible(self, a: Type[Any], b: Type[Any]) -> bool:
return self._is_iterable(a) and self._is_iterable(b)

def _map_iterables(self, a_obj: Any, b: Type[Any], exc_info: MappingExceptionInfo):
b = self._resolve_forward_ref(b)
args = get_args(b)
if len(args) == 0:
# Iterable without type
Expand All @@ -559,7 +583,7 @@ def _map_iterables(self, a_obj: Any, b: Type[Any], exc_info: MappingExceptionInf
elif len(args) == 1:
# Iterable with type
to_type = get_origin(b)
to_type_item = args[0]
to_type_item = self._resolve_forward_ref(args[0])

mapped_list = []
for index, item in enumerate(a_obj):
Expand All @@ -575,7 +599,7 @@ def _map_iterables(self, a_obj: Any, b: Type[Any], exc_info: MappingExceptionInf

mapped_list = []
for index, item in enumerate(a_obj):
to_type_item = args[index]
to_type_item = self._resolve_forward_ref(args[index])

try:
mapped_list.append(self.map(item, to_type_item))
Expand Down
2 changes: 1 addition & 1 deletion panamap/panamap.version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.1.0
1.1.1

0 comments on commit 0261edc

Please sign in to comment.