Skip to content

Commit

Permalink
Merge pull request #27 from mdrachuk/camel-case-json
Browse files Browse the repository at this point in the history
Camel Case for JSON
  • Loading branch information
mdrachuk committed Aug 8, 2019
2 parents 13eb5ec + 16c7d50 commit 881a2ad
Show file tree
Hide file tree
Showing 10 changed files with 131 additions and 13 deletions.
2 changes: 1 addition & 1 deletion config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def readme(self):

config = Config(
name='serious',
version='1.0.0.dev12',
version='1.0.0.dev13',
readme_path='README.md',
author='mdrachuk',
author_email='misha@drach.uk'
Expand Down
15 changes: 14 additions & 1 deletion serious/json/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
from serious.descriptors import describe
from serious.preconditions import _check_is_instance
from serious.serialization import FieldSerializer, SeriousModel, field_serializers
from serious.utils import class_path
from serious.serialization.model import KeyMapper
from serious.utils import class_path, snake_to_camel, camel_to_snake
from .preconditions import _check_that_loading_an_object, _check_that_loading_a_list

T = TypeVar('T')
Expand All @@ -22,6 +23,7 @@ def __init__(
allow_any: bool = False,
allow_missing: bool = False,
allow_unexpected: bool = False,
camel_case: bool = True,
indent: Optional[int] = None,
):
"""
Expand All @@ -31,6 +33,7 @@ def __init__(
(this includes generics like `List[Any]`, or simply `list`).
@param allow_missing `False` to raise during load if data is missing the optional fields.
@param allow_unexpected `False` to raise during load if data contains some unknown fields.
@param camel_case `True` to transform dataclass "snake_case" to JSON "camelCase".
@param indent number of spaces JSON output will be indented by; `None` for most compact representation.
"""
self._descriptor = describe(cls)
Expand All @@ -40,6 +43,7 @@ def __init__(
allow_any=allow_any,
allow_missing=allow_missing,
allow_unexpected=allow_unexpected,
key_mapper=JsonKeyMapper() if camel_case else None
)
self._dump_indentation = indent

Expand Down Expand Up @@ -92,3 +96,12 @@ def __repr__(self):
if path == 'serious.json.api.JsonModel':
path = 'serious.JsonModel'
return f'<{path}[{class_path(self.cls)}] at {hex(id(self))}>'


class JsonKeyMapper(KeyMapper):

def to_model(self, item: str) -> str:
return camel_to_snake(item)

def to_serialized(self, item: str) -> str:
return snake_to_camel(item)
37 changes: 31 additions & 6 deletions serious/serialization/model.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import fields, MISSING, Field
from typing import Generic, Iterable, Type, Dict, Any, Union, Mapping, Optional, Iterator, TypeVar

Expand Down Expand Up @@ -28,6 +29,7 @@ def __init__(
allow_any: bool,
allow_missing: bool,
allow_unexpected: bool,
key_mapper: Optional[KeyMapper] = None,
_registry: Dict[TypeDescriptor, SeriousModel] = None
):
"""
Expand All @@ -37,6 +39,7 @@ def __init__(
(this includes generics like `List[Any]`, or simply `list`).
@param allow_missing `False` to raise during load if data is missing the optional fields.
@param allow_unexpected `False` to raise during load if data contains some unknown fields.
@param key_mapper remap field names of between dataclass and serialized objects
@param _registry a mapping of dataclass type descriptors to corresponding serious serializer;
used internally to create child serializers.
"""
Expand All @@ -52,7 +55,8 @@ def __init__(
self._allow_unexpected = allow_unexpected
self._allow_any = allow_any
self._serializer_registry = {descriptor: self} if _registry is None else _registry
self._field_serializers = {name: self.find_serializer(desc) for name, desc in descriptor.fields.items()}
self._keys = key_mapper or NoopKeyMapper()
self._serializers_by_field = {name: self.find_serializer(desc) for name, desc in descriptor.fields.items()}

@property
def _cls(self) -> Type[T]:
Expand All @@ -65,7 +69,7 @@ def load(self, data: Mapping, _ctx: Optional[Loading] = None) -> T:
_check_is_instance(data, Mapping, f'Invalid data for {self._cls}') # type: ignore
root = _ctx is None
loading: Loading = Loading() if root else _ctx # type: ignore # checked above
mut_data = dict(data)
mut_data = {self._keys.to_model(key): value for key, value in data.items()}
if self._allow_missing:
for field in _fields_missing_from(mut_data, self._cls):
mut_data[field.name] = None
Expand All @@ -75,8 +79,8 @@ def load(self, data: Mapping, _ctx: Optional[Loading] = None) -> T:
_check_for_unexpected(self._cls, mut_data)
try:
init_kwargs = {
field: loading.run(f'.[{field}]', serializer, mut_data[field])
for field, serializer in self._field_serializers.items()
field: loading.run(f'.[{self._keys.to_serialized(field)}]', serializer, mut_data[field])
for field, serializer in self._serializers_by_field.items()
if field in mut_data
}
result = self._cls(**init_kwargs) # type: ignore # not an object
Expand All @@ -96,10 +100,11 @@ def dump(self, o: T, _ctx: Optional[Dumping] = None) -> Dict[str, Any]:
_check_is_instance(o, self._cls)
root = _ctx is None
dumping: Dumping = Dumping() if root else _ctx # type: ignore # checked above
_s = self._keys.to_serialized
try:
return {
field: dumping.run(f'.[{field}]', serializer, getattr(o, field))
for field, serializer in self._field_serializers.items()
_s(field): dumping.run(f'.[{_s(field)}]', serializer, getattr(o, field))
for field, serializer in self._serializers_by_field.items()
}
except Exception as e:
if root:
Expand Down Expand Up @@ -176,3 +181,23 @@ def _is_missing(field: Field) -> bool:
and field.default_factory is MISSING # type: ignore # default factory is an unbound function

return filter(_is_missing, fields(cls))


class KeyMapper(ABC):

@abstractmethod
def to_model(self, item: str) -> str:
raise NotImplementedError

@abstractmethod
def to_serialized(self, item: str) -> str:
raise NotImplementedError


class NoopKeyMapper(KeyMapper):

def to_model(self, item: str) -> str:
return item

def to_serialized(self, item: str) -> str:
return item
17 changes: 17 additions & 0 deletions serious/utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import re
from typing import Any, Type

DataclassType = Any
Expand All @@ -6,3 +7,19 @@
def class_path(cls: Type) -> str:
"""Returns a fully qualified type name."""
return f'{cls.__module__}.{cls.__qualname__}'


first_cap_re = re.compile(r'(.)([A-Z][a-z]+)')
all_cap_re = re.compile(r'([a-z0-9])([A-Z])')
digit_re = re.compile(r'([a-z])([0-9])')


def camel_to_snake(camel: str) -> str:
s1 = first_cap_re.sub(r'\1_\2', camel)
s2 = all_cap_re.sub(r'\1_\2', s1).lower()
return re.compile(r'([a-z])([0-9])').sub(r'\1_\2', s2)


def snake_to_camel(snake: str) -> str:
first, *others = filter(bool, snake.split('_'))
return ''.join([first.lower(), *map(str.title, others)])
32 changes: 32 additions & 0 deletions tests/test_case.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from dataclasses import dataclass

from serious import JsonModel


@dataclass
class Snack:
butterbeer: int
dragon_tartare: int
hogwarts_steak_and_kidney_pie_: int
_pumpkin__fizz: int


def test_json_transforms_case_by_default():
model = JsonModel(Snack)
actual = model.dump(Snack(1, 2, 3, 4))
expected = '{"butterbeer": 1, "dragonTartare": 2, "hogwartsSteakAndKidneyPie": 3, "pumpkinFizz": 4}'
assert actual == expected


def test_json_transforms_case():
model = JsonModel(Snack, camel_case=True)
actual = model.dump(Snack(1, 2, 3, 4))
expected = '{"butterbeer": 1, "dragonTartare": 2, "hogwartsSteakAndKidneyPie": 3, "pumpkinFizz": 4}'
assert actual == expected


def test_json_skips_transformation():
model = JsonModel(Snack, camel_case=False)
actual = model.dump(Snack(1, 2, 3, 4))
expected = '{"butterbeer": 1, "dragon_tartare": 2, "hogwarts_steak_and_kidney_pie_": 3, "_pumpkin__fizz": 4}'
assert actual == expected
2 changes: 1 addition & 1 deletion tests/test_enum.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ class EnumContainer:
class TestEnumCollection:
def setup(self):
self.model = JsonModel(EnumContainer)
self.json = '{"enum_list": ["gamma", 1], "enum_mapping": {"first": "alpha", "second": 3.14}}'
self.json = '{"enumList": ["gamma", 1], "enumMapping": {"first": "alpha", "second": 3.14}}'
self.dataclass = EnumContainer(
enum_list=[Symbol.GAMMA, Symbol.ONE],
enum_mapping={"first": Symbol.ALPHA, "second": Symbol.PI}
Expand Down
4 changes: 2 additions & 2 deletions tests/test_field_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ def test_number(self):

def test_non_primitive(self):
model = DictModel(EventComment)
serializer = model._serializer._field_serializers['event']
serializer = model._serializer._serializers_by_field['event']
assert type(serializer) is EnumSerializer
ctx = Loading()
with pytest.raises(ValidationError):
Expand Down Expand Up @@ -178,7 +178,7 @@ class MockDataclass:

def test_dataclass_load_validation():
model = DictModel(MockDataclass)
serializer = model._serializer._field_serializers['child']
serializer = model._serializer._serializers_by_field['child']
assert type(serializer) is OptionalSerializer
assert type(serializer._serializer) is DataclassSerializer
ctx = Loading()
Expand Down
2 changes: 1 addition & 1 deletion tests/test_nested.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def setup_class(self):

def test_nested_dataclass(self):
actual = self.dcwdc.dump(DataclassWithDataclass(DataclassWithList([1])))
expected = json.dumps({"dc_with_list": {"xs": [1]}})
expected = json.dumps({"dcWithList": {"xs": [1]}})
assert actual == expected

def test_nested_list_of_dataclasses(self):
Expand Down
2 changes: 1 addition & 1 deletion tests/test_time.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def setup_class(self):
self.model = JsonModel(Post)
dt = datetime(2018, 11, 17, 16, 55, 28, 456753, tzinfo=timezone.utc)
iso = dt.isoformat()
self.json = f'{{"created_at": "{iso}"}}'
self.json = f'{{"createdAt": "{iso}"}}'
self.dataclass = Post(datetime.fromisoformat(iso))

def test_load(self):
Expand Down
31 changes: 31 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from collections import namedtuple
from typing import Callable

from serious.utils import snake_to_camel, camel_to_snake

Ex = namedtuple('Example', ['snake', 'camel'])


class TestCases:

def setup_class(self):
self.examples = [
Ex('_http_response', 'httpResponse'),
Ex('name__space', 'httpResponse'),
Ex('some_str_3_one_two_three_234', 'someStr3AbcOneTwoThree234'),
Ex('some32_cows', 'some32Cows'),
]

def each_example(self, test: Callable[[Ex], None]):
for example in self.examples:
test(example)

def test_snake_to_camel(self):
self.each_example(lambda ex: snake_to_camel(ex.snake) == ex.camel)

def test_camel_to_snake(self):
self.each_example(lambda ex: camel_to_snake(ex.camel) == ex.snake)

def check_symmetry(self):
self.each_example(lambda ex: snake_to_camel(camel_to_snake(ex.camel)) == ex.camel)
self.each_example(lambda ex: camel_to_snake(snake_to_camel(ex.snake)) == ex.snake)

0 comments on commit 881a2ad

Please sign in to comment.