Skip to content

Commit

Permalink
Add mapping availability in decorators
Browse files Browse the repository at this point in the history
  • Loading branch information
dkraczkowski committed Aug 28, 2023
1 parent 4782b62 commit 22637df
Show file tree
Hide file tree
Showing 11 changed files with 228 additions and 12 deletions.
47 changes: 46 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,11 +365,56 @@ The `mapped_data` output would be:
}
```

### Using mapping in decodable/encodable objects

You can also use mapping by setting `mapper` parameter in `@chili.encodable` and `@chili.decodable` decorators.

```python
from typing import List

from chili import encodable, Mapper, encode

mapper = Mapper({
"pet_name": "name",
"pet_age": "age",
"pet_tags": {
"tag_name": "tag",
"tag_type": "type",
},
})


@encodable(mapper=mapper)
class Pet:
name: str
age: int
tags: List[str]

def __init__(self, name: str, age: int, tags: List[str]):
self.name = name
self.age = age
self.tags = tags


pet = Pet("Max", 3, ["cute", "furry"])
encoded = encode(pet)

assert encoded == {
"pet_name": "Max",
"pet_age": 3,
"pet_tags": [
{"tag_name": "cute", "tag_type": "description"},
{"tag_name": "furry", "tag_type": "description"},
],
}
```

## Error handling
The library raises errors if an invalid type is passed to the Encoder or Decoder, or if an invalid dictionary is passed to the Decoder.

```python
from chili import Encoder, EncoderError, Decoder, DecoderError
from chili import Encoder, Decoder
from chili.error import EncoderError, DecoderError

# Invalid Type
encoder = Encoder[MyInvalidType]() # Raises EncoderError.invalid_type
Expand Down
11 changes: 10 additions & 1 deletion chili/decoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
Dict,
Generic,
List,
Optional,
Pattern,
Protocol,
Sequence,
Expand All @@ -32,6 +33,7 @@

from chili.typing import (
_DECODABLE,
_DECODE_MAPPER,
_PROPERTIES,
UNDEFINED,
TypeSchema,
Expand All @@ -54,6 +56,7 @@

from .error import DecoderError
from .iso_datetime import parse_iso_date, parse_iso_datetime, parse_iso_duration, parse_iso_time
from .mapper import Mapper
from .state import StateObject

C = TypeVar("C")
Expand Down Expand Up @@ -112,11 +115,13 @@ def decode_regex_from_string(value: str) -> Pattern:
return re.compile(pattern, flags=sum(_REGEX_FLAGS[flag] for flag in flags))


def decodable(_cls=None) -> Any:
def decodable(_cls=None, mapper: Optional[Mapper] = None) -> Any:
def _decorate(cls) -> Type[C]:
# Attach schema to make the class decodable
if not hasattr(cls, _PROPERTIES):
setattr(cls, _PROPERTIES, create_schema(cls))
if mapper:
setattr(cls, _DECODE_MAPPER, mapper)

setattr(cls, _DECODABLE, True)

Expand Down Expand Up @@ -520,6 +525,10 @@ def decode(self, obj: Dict[str, StateObject]) -> T:

instance = self.__generic__.__new__(self.__generic__)

if hasattr(self.__generic__, _DECODE_MAPPER):
mapper = getattr(self.__generic__, _DECODE_MAPPER)
obj = mapper.map(obj)

for key, prop in self.schema.items():
if key not in obj:
value = prop.default_value
Expand Down
11 changes: 10 additions & 1 deletion chili/encoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@

from chili.typing import (
_ENCODABLE,
_ENCODE_MAPPER,
_PROPERTIES,
UNDEFINED,
Optional,
TypeSchema,
create_schema,
get_origin_type,
Expand All @@ -38,6 +40,7 @@

from .error import EncoderError
from .iso_datetime import timedelta_to_iso_duration
from .mapper import Mapper
from .state import StateObject

C = TypeVar("C")
Expand Down Expand Up @@ -81,11 +84,13 @@ def encode_regex_to_string(value: Pattern) -> str:
return value.pattern


def encodable(_cls=None) -> Any:
def encodable(_cls=None, mapper: Optional[Mapper] = None) -> Any:
def _decorate(cls) -> Type[C]:
# Attach schema to make the class encodable
if not hasattr(cls, _PROPERTIES):
setattr(cls, _PROPERTIES, create_schema(cls))
if mapper:
setattr(cls, _ENCODE_MAPPER, mapper)

setattr(cls, _ENCODABLE, True)

Expand Down Expand Up @@ -433,6 +438,10 @@ def encode(self, obj: T) -> StateObject:
continue
result[key] = self._encoders[prop.name].encode(value)

if hasattr(self.__generic__, _ENCODE_MAPPER):
mapper = getattr(self.__generic__, _ENCODE_MAPPER)
return mapper.map(result)

return result

@property
Expand Down
8 changes: 5 additions & 3 deletions chili/mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
from chili.error import MapperError

KeyScheme = namedtuple("KeyScheme", "key scheme")
MappingScheme = Dict[str, Union[Dict, str, Callable, KeyScheme]]
MappingScheme = Dict[Union[str, Any], Union[Dict, str, Callable, KeyScheme]]


class Mapper:
def __init__(self, scheme: Union[MappingScheme]):
def __init__(self, scheme: Union[MappingScheme], preserve_keys: bool = False):
self.scheme = scheme
self.preserve_keys = preserve_keys

def map(self, data: Dict[str, Any], skip_keys: bool = False, default_value: Any = None) -> Dict[str, Any]:
return self._map(data, self.scheme, skip_keys, default_value)
Expand Down Expand Up @@ -82,5 +83,6 @@ def _map(
continue
else:
raise MapperError.invalid_value

if self.preserve_keys:
return {**{key: value for key, value in data.items() if key not in evaluated_keys}, **result}
return result
20 changes: 17 additions & 3 deletions chili/serializer.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
from __future__ import annotations

from typing import Any, Generic, Type, TypeVar
from typing import Any, Generic, Optional, Type, TypeVar

from chili.decoder import Decoder
from chili.encoder import Encoder
from chili.error import SerialisationError
from chili.typing import _DECODABLE, _ENCODABLE, _PROPERTIES, create_schema, is_class, is_dataclass
from chili.mapper import Mapper
from chili.typing import (
_DECODABLE,
_DECODE_MAPPER,
_ENCODABLE,
_ENCODE_MAPPER,
_PROPERTIES,
create_schema,
is_class,
is_dataclass,
)

C = TypeVar("C")
T = TypeVar("T")
Expand Down Expand Up @@ -38,10 +48,14 @@ def __class_getitem__(cls, item: Type[T]) -> Type[Serializer]: # noqa: E501
)


def serializable(_cls=None) -> Any:
def serializable(_cls=None, in_mapper: Optional[Mapper] = None, out_mapper: Optional[Mapper] = None) -> Any:
def _decorate(cls) -> Type[C]:
if not hasattr(cls, _PROPERTIES):
setattr(cls, _PROPERTIES, create_schema(cls))
if in_mapper is not None:
setattr(cls, _DECODE_MAPPER, in_mapper)
if out_mapper is not None:
setattr(cls, _ENCODE_MAPPER, out_mapper)

setattr(cls, _DECODABLE, True)
setattr(cls, _ENCODABLE, True)
Expand Down
2 changes: 2 additions & 0 deletions chili/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
AnnotatedTypeNames = {"AnnotatedMeta", "_AnnotatedAlias"}
_GenericAlias = getattr(typing, "_GenericAlias")
_PROPERTIES = "__typed_properties__"
_DECODE_MAPPER = "__decode_mapper__"
_ENCODE_MAPPER = "__encode_mapper__"
_ENCODABLE = "__encodable__"
_DECODABLE = "__decodable__"
UNDEFINED = object()
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ license = "MIT"
name = "chili"
readme = "README.md"
repository = "https://github.com/kodemore/chili"
version = "2.2.0"
version = "2.3.0"

[tool.poetry.dependencies]
gaffe = "0.2.0"
Expand Down
31 changes: 31 additions & 0 deletions tests/test_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,3 +346,34 @@ def test_can_map_complex_format() -> None:
"id": "103",
"title": "Book 103 Title",
}


def test_can_map_with_preserve_keys() -> None:
# given
data = {
"name": "Pimpek",
"age": 4,
"tags": [
{"name": "tag-1"},
{"name": "tag-2"},
{"name": "tag-3"},
{"name": "tag-4"},
],
}

mapper = Mapper({"_name": "name"}, preserve_keys=True)

# when
result = mapper.map(data)

# then
assert result == {
"_name": "Pimpek",
"age": 4,
"tags": [
{"name": "tag-1"},
{"name": "tag-2"},
{"name": "tag-3"},
{"name": "tag-4"},
],
}
32 changes: 31 additions & 1 deletion tests/usecases/decodable_test.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import List

from chili import Decoder, decodable
from chili import Decoder, Mapper, decodable


def test_decodable_with_default_values() -> None:
Expand All @@ -25,3 +25,33 @@ class Book:

assert result_1.isbn == "1234567890"
assert result_1.tags == ["Fantasy"]


def test_can_decode_with_mapper() -> None:
# given
mapper = Mapper({"_name": "name", "_author": "author"}, preserve_keys=True)

@decodable(mapper=mapper)
class Book:
_name: str
_author: str
isbn: str
tags: List[str] = []

book_data = {
"name": "The Hobbit",
"author": "J.R.R. Tolkien",
"isbn": "1234567890",
"tags": ["Fantasy", "Adventure"],
}
decoder = Decoder[Book]()

# when
a_book = decoder.decode(book_data)

# then
assert isinstance(a_book, Book)
assert a_book._name == "The Hobbit"
assert a_book._author == "J.R.R. Tolkien"
assert a_book.isbn == "1234567890"
assert a_book.tags == ["Fantasy", "Adventure"]
34 changes: 33 additions & 1 deletion tests/usecases/encodable_test.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import List

from chili import Encoder, encodable
from chili import Encoder, Mapper, encodable


def test_can_encode_encodable_with_undefined_field() -> None:
Expand All @@ -23,3 +23,35 @@ def __init__(self, name: str, author: str):

# then
assert result == {"name": "The Hobbit", "author": "J.R.R. Tolkien"}


def test_can_encode_with_mapper() -> None:
# given
mapper = Mapper({"name": "_name", "author": "_author"}, preserve_keys=True)

@encodable(mapper=mapper)
class Book:
_name: str
_author: str
isbn: str
tags: List[str] = []

def __init__(self, name: str, author: str, isbn: str, tags: List[str]):
self._name = name
self._author = author
self.isbn = isbn
self.tags = tags

a_book = Book("The Hobbit", "J.R.R. Tolkien", "1234567890", ["Fantasy", "Adventure"])
encoder = Encoder[Book]()

# when
result = encoder.encode(a_book)

# then
assert result == {
"name": "The Hobbit",
"author": "J.R.R. Tolkien",
"isbn": "1234567890",
"tags": ["Fantasy", "Adventure"],
}
42 changes: 42 additions & 0 deletions tests/usecases/serializable_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from typing import List

from chili import Mapper, Serializer, serializable


def test_serializable_with_mappers() -> None:
# given
encode_mapper = Mapper({"_name": "name", "_author": "author"}, preserve_keys=True)
decode_mapper = Mapper({"name": "_name", "author": "_author"}, preserve_keys=True)

@serializable(in_mapper=decode_mapper, out_mapper=encode_mapper)
class Book:
name: str
author: str
isbn: str
tags: List[str] = []

def __init__(self, name: str, author: str, isbn: str, tags: List[str]):
self.name = name
self.author = author
self.isbn = isbn
self.tags = tags

a_book = Book("The Hobbit", "J.R.R. Tolkien", "1234567890", ["Fantasy", "Adventure"])
a_book_snapshot = {
"_name": "The Hobbit",
"_author": "J.R.R. Tolkien",
"isbn": "1234567890",
"tags": ["Fantasy", "Adventure"],
}
serializer = Serializer[Book]()

# when
encoded_book = serializer.encode(a_book)
decoded_book = serializer.decode(a_book_snapshot)

# then
assert encoded_book == a_book_snapshot
assert decoded_book.name == "The Hobbit"
assert decoded_book.author == "J.R.R. Tolkien"
assert decoded_book.isbn == "1234567890"
assert decoded_book.tags == ["Fantasy", "Adventure"]

0 comments on commit 22637df

Please sign in to comment.