Repository: s-knibbs/dataclasses-jsonschema (2019-07-12 ~ 2019-08-05)

problem_statement: Improve handling of enum fields in `from_object`
We should automatically convert enum fields from the source field by calling the enum constructor.

The enum constructor accepts both enums and enum values.
date: 2019-07-12
patch: diff --git a/dataclasses_jsonschema/__init__.py b/dataclasses_jsonschema/__init__.py
index 92bbf2e..3c01cc8 100644
--- a/dataclasses_jsonschema/__init__.py
+++ b/dataclasses_jsonschema/__init__.py
@@ -409,7 +409,7 @@ class JsonSchemaMixin:
         return instance
 
     @classmethod
-    def from_object(cls: Type[T], obj: Any, exclude: FieldExcludeList = tuple()):
+    def from_object(cls: Type[T], obj: Any, exclude: FieldExcludeList = tuple()) -> T:
         """Returns a dataclass instance from another object (typically an ORM model).
         The `exclude` parameter is a tuple of field names or (field.name, nested_exclude)
         to exclude from the conversion. For example `exclude=('artist_name', ('albums', ('tracks',))` will exclude
@@ -434,6 +434,8 @@ class JsonSchemaMixin:
             field_type_name = cls._get_field_type_name(ft)
             if cls._is_json_schema_subclass(ft):
                 values[field.name] = ft.from_object(getattr(obj, field.name), exclude=sub_exclude)
+            elif is_enum(ft):
+                values[field.name] = ft(getattr(obj, field.name))
             elif field_type_name == "List" and cls._is_json_schema_subclass(ft.__args__[0]):
                 values[field.name] = [
                     ft.__args__[0].from_object(v, exclude=sub_exclude) for v in getattr(obj, field.name)

test_patch: diff --git a/tests/test_core.py b/tests/test_core.py
index 977660a..55cd03b 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -1,5 +1,7 @@
 import datetime
 from _decimal import Decimal
+from enum import Enum
+
 from dataclasses import dataclass, field
 from ipaddress import IPv4Address, IPv6Address
 from typing import List, NewType, Optional, Union
@@ -453,6 +455,11 @@ def test_literal_types():
 
 def test_from_object():
 
+    class Genre(Enum):
+        HORROR = "horror"
+        BIOGRAPHY = "biography"
+        THRILLER = "thriller"
+
     class AuthorModel:
 
         def __init__(self, name, age, books):
@@ -462,15 +469,17 @@ def test_from_object():
 
     class BookModel:
 
-        def __init__(self, name, first_print, publisher):
+        def __init__(self, name, first_print, publisher, genre):
             self.name = name
             self.first_print = first_print
             self.publisher = publisher
+            self.genre = genre
 
     @dataclass
     class Book(JsonSchemaMixin):
         name: str
         publisher: str
+        genre: Genre
         first_print: Optional[datetime.datetime] = None
 
     @dataclass
@@ -479,8 +488,10 @@ def test_from_object():
         age: Optional[int] = None
         books: Optional[List[Book]] = None
 
-    sample_author = AuthorModel("Joe Bloggs", 32, [BookModel("Hello World!", datetime.datetime.utcnow(), "ACME Corp")])
-    expected_author = Author("Joe Bloggs", books=[Book("Hello World!", "ACME Corp")])
+    sample_author = AuthorModel(
+        "Joe Bloggs", 32, [BookModel("Hello World!", datetime.datetime.utcnow(), "ACME Corp", "biography")]
+    )
+    expected_author = Author("Joe Bloggs", books=[Book("Hello World!", "ACME Corp", Genre.BIOGRAPHY)])
     assert Author.from_object(sample_author, exclude=('age', ('books', ('first_print',)))) == expected_author
 
     with pytest.raises(ValueError):


----

problem_statement: Change private fields from _ to __
Hi!
I'm trying to declare a class member with name
_id
as the mongodb index field of the class but the library exclude fields name with _

Would be nice to have an option to change that to another prefix like __
Thanks

Edit:
the private prefix is hardcoded here:
https://github.com/s-knibbs/dataclasses-jsonschema/blob/22aaa09d09e7d6a37e75cccb2100b824dd200bb0/dataclasses_jsonschema/__init__.py#L282
Should be as easy as define a member (_priv_prefix?) for JsonSchemaMixin?
date: 2019-08-05
patch: diff --git a/dataclasses_jsonschema/__init__.py b/dataclasses_jsonschema/__init__.py
index 3c01cc8..46ea060 100644
--- a/dataclasses_jsonschema/__init__.py
+++ b/dataclasses_jsonschema/__init__.py
@@ -172,22 +172,22 @@ class JsonSchemaMixin:
     }
 
     # Cache of the generated schema
-    _schema: Dict[SchemaType, JsonDict]
-    _compiled_schema: Dict[SchemaType, Callable]
-    _definitions: Dict[SchemaType, JsonDict]
+    __schema: Dict[SchemaType, JsonDict]
+    __compiled_schema: Dict[SchemaType, Callable]
+    __definitions: Dict[SchemaType, JsonDict]
     # Cache of field encode / decode functions
-    _encode_cache: Dict[Any, _ValueEncoder]
-    _decode_cache: Dict[Any, _ValueDecoder]
-    _mapped_fields: List[Tuple[Field, str]]
+    __encode_cache: Dict[Any, _ValueEncoder]
+    __decode_cache: Dict[Any, _ValueDecoder]
+    __mapped_fields: List[Tuple[Field, str]]
 
     def __init_subclass__(cls):
         # Initialise caches
-        cls._schema = {}
-        cls._compiled_schema = {}
-        cls._definitions = {}
-        cls._encode_cache = {}
-        cls._decode_cache = {}
-        cls._mapped_fields = []
+        cls.__schema = {}
+        cls.__compiled_schema = {}
+        cls.__definitions = {}
+        cls.__encode_cache = {}
+        cls.__decode_cache = {}
+        cls.__mapped_fields = []
 
     @classmethod
     def field_mapping(cls) -> Dict[str, str]:
@@ -213,7 +213,7 @@ class JsonSchemaMixin:
         if value is None or value is NULL:
             return value
         try:
-            encoder = cls._encode_cache[field_type]  # type: ignore
+            encoder = cls.__encode_cache[field_type]  # type: ignore
         except (KeyError, TypeError):
             # TODO: Use field_type.__origin__ instead of the type name.
             # This has different behaviour between 3.6 & 3.7 however
@@ -261,7 +261,7 @@ class JsonSchemaMixin:
                     return cls._encode_field(ft.__supertype__, v, o)
             else:
                 def encoder(_, v, __): return v
-            cls._encode_cache[field_type] = encoder  # type: ignore
+            cls.__encode_cache[field_type] = encoder  # type: ignore
         return encoder(field_type, value, omit_none)
 
     @classmethod
@@ -279,7 +279,7 @@ class JsonSchemaMixin:
             type_hints = get_type_hints(cls)
             for f in fields(cls):
                 # Skip internal fields
-                if f.name.startswith("_") or (not base_fields and f.name in base_field_names):
+                if f.name.startswith("__") or (not base_fields and f.name in base_field_names):
                     continue
                 # Note fields() doesn't resolve forward refs
                 f.type = type_hints[f.name]
@@ -289,9 +289,9 @@ class JsonSchemaMixin:
         if not base_fields:
             return _get_fields_uncached()
 
-        if not cls._mapped_fields:
-            cls._mapped_fields = _get_fields_uncached()
-        return cls._mapped_fields  # type: ignore
+        if not cls.__mapped_fields:
+            cls.__mapped_fields = _get_fields_uncached()
+        return cls.__mapped_fields  # type: ignore
 
     def to_dict(self, omit_none: bool = True, validate: bool = False) -> JsonDict:
         """Converts the dataclass instance to a JSON encodable dict, with optional JSON schema validation.
@@ -316,7 +316,7 @@ class JsonSchemaMixin:
             return NULL if is_nullable(field_type) else None
         decoder = None
         try:
-            decoder = cls._decode_cache[field_type]  # type: ignore
+            decoder = cls.__decode_cache[field_type]  # type: ignore
         except (KeyError, TypeError):
             # Note: Only literal types composed of primitive values are currently supported
             if type(value) in JSON_ENCODABLE_TYPES and (field_type in JSON_ENCODABLE_TYPES or is_literal(field_type)):
@@ -368,7 +368,7 @@ class JsonSchemaMixin:
             if decoder is None:
                 warnings.warn(f"Unable to decode value for '{field}: {field_type_name}'")
                 return value
-            cls._decode_cache[field_type] = decoder
+            cls.__decode_cache[field_type] = decoder
         return decoder(field, field_type, value)
 
     @classmethod
@@ -376,10 +376,10 @@ class JsonSchemaMixin:
         try:
             if fast_validation:
                 # TODO: Support validating with other schema types
-                schema_validator = cls._compiled_schema.get(DEFAULT_SCHEMA_TYPE)
+                schema_validator = cls.__compiled_schema.get(DEFAULT_SCHEMA_TYPE)
                 if schema_validator is None:
                     schema_validator = fastjsonschema.compile(cls.json_schema())
-                    cls._compiled_schema[DEFAULT_SCHEMA_TYPE] = schema_validator
+                    cls.__compiled_schema[DEFAULT_SCHEMA_TYPE] = schema_validator
                 schema_validator(data)
             else:
                 validate_func(data, cls.json_schema())
@@ -621,17 +621,17 @@ class JsonSchemaMixin:
             return cls.all_json_schemas(schema_type)
 
         definitions: JsonDict = {}
-        if schema_type not in cls._definitions:
-            cls._definitions[schema_type] = definitions
+        if schema_type not in cls.__definitions:
+            cls.__definitions[schema_type] = definitions
         else:
-            definitions = cls._definitions[schema_type]
+            definitions = cls.__definitions[schema_type]
 
         if schema_type in (SchemaType.SWAGGER_V3, SchemaType.SWAGGER_V2) and not embeddable:
             schema_type = SchemaType.DRAFT_06
             warnings.warn("'Swagger schema types unsupported when 'embeddable=False', using 'SchemaType.DRAFT_06'")
 
-        if cls._schema is not None and schema_type in cls._schema:
-            schema = cls._schema[schema_type]
+        if cls.__schema is not None and schema_type in cls.__schema:
+            schema = cls.__schema[schema_type]
         else:
             properties = {}
             required = []
@@ -663,7 +663,7 @@ class JsonSchemaMixin:
             if cls.__doc__:
                 schema['description'] = cls.__doc__
 
-            cls._schema[schema_type] = schema
+            cls.__schema[schema_type] = schema
 
         if embeddable:
             return {**definitions, cls.__name__: schema}

test_patch: diff --git a/tests/test_core.py b/tests/test_core.py
index 55cd03b..bad6b27 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -611,3 +611,26 @@ def test_nullable_field():
     expected_dict = {"name": "Joe Bloggs", "manager": None}
     assert Employee.from_dict(expected_dict) == expected_obj
     assert expected_dict == expected_obj.to_dict()
+
+
+def test_underscore_fields():
+    @dataclass
+    class Album(JsonSchemaMixin):
+        """An album"""
+        _id: int
+        name: str
+
+    expected_json_schema = compose_schema({
+        "type": "object",
+        "description": "An album",
+        "properties": {
+            "name": {"type": "string"},
+            "_id": {"type": "integer"}
+        },
+        "required": ["_id", "name"]
+    })
+    assert Album.json_schema() == expected_json_schema
+    expected_data = {"name": "Foo", "_id": 5}
+    expected_obj = Album(_id=5, name="Foo")
+    assert expected_data == expected_obj.to_dict()
+    assert expected_obj == Album.from_dict(expected_data)


----

problem_statement: Improve handling of None in from_object
Currently a `ValueError` is raised in `from_object` if the source object has a `None` value for an enum or nested object field.
date: 2019-08-05
patch: diff --git a/dataclasses_jsonschema/__init__.py b/dataclasses_jsonschema/__init__.py
index 46ea060..391302f 100644
--- a/dataclasses_jsonschema/__init__.py
+++ b/dataclasses_jsonschema/__init__.py
@@ -432,16 +432,20 @@ class JsonSchemaMixin:
             if is_optional(ft):
                 ft = unwrap_optional(ft)
             field_type_name = cls._get_field_type_name(ft)
-            if cls._is_json_schema_subclass(ft):
-                values[field.name] = ft.from_object(getattr(obj, field.name), exclude=sub_exclude)
+
+            from_value = getattr(obj, field.name)
+            if from_value is None:
+                values[field.name] = from_value
+            elif cls._is_json_schema_subclass(ft):
+                values[field.name] = ft.from_object(from_value, exclude=sub_exclude)
             elif is_enum(ft):
-                values[field.name] = ft(getattr(obj, field.name))
+                values[field.name] = ft(from_value)
             elif field_type_name == "List" and cls._is_json_schema_subclass(ft.__args__[0]):
                 values[field.name] = [
-                    ft.__args__[0].from_object(v, exclude=sub_exclude) for v in getattr(obj, field.name)
+                    ft.__args__[0].from_object(v, exclude=sub_exclude) for v in from_value
                 ]
             else:
-                values[field.name] = getattr(obj, field.name)
+                values[field.name] = from_value
 
         instance = cls(**init_values)  # type: ignore
         for field_name, value in non_init_values.items():

test_patch: diff --git a/tests/test_core.py b/tests/test_core.py
index bad6b27..a6aeb3d 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -479,7 +479,7 @@ def test_from_object():
     class Book(JsonSchemaMixin):
         name: str
         publisher: str
-        genre: Genre
+        genre: Optional[Genre]
         first_print: Optional[datetime.datetime] = None
 
     @dataclass
@@ -494,6 +494,12 @@ def test_from_object():
     expected_author = Author("Joe Bloggs", books=[Book("Hello World!", "ACME Corp", Genre.BIOGRAPHY)])
     assert Author.from_object(sample_author, exclude=('age', ('books', ('first_print',)))) == expected_author
 
+    sample_author_2 = AuthorModel(
+        "Joe Bloggs", 32, [BookModel("Hello World!", datetime.datetime.utcnow(), "ACME Corp", None)]
+    )
+    expected_author_2 = Author("Joe Bloggs", books=[Book("Hello World!", "ACME Corp", None)])
+    assert Author.from_object(sample_author_2, exclude=('age', ('books', ('first_print',)))) == expected_author_2
+
     with pytest.raises(ValueError):
         Author.from_object(sample_author, exclude=('age', ('books', ('publisher',))))