IGNITE-10358: Added collections data type specification for python thin client

This closes #5470
diff --git a/docs/source/pyignite.datatypes.base.rst b/docs/source/pyignite.datatypes.base.rst
new file mode 100644
index 0000000..849a028
--- /dev/null
+++ b/docs/source/pyignite.datatypes.base.rst
@@ -0,0 +1,7 @@
+pyignite.datatypes.base module
+==============================
+
+.. automodule:: pyignite.datatypes.base
+    :members:
+    :undoc-members:
+    :show-inheritance:
diff --git a/docs/source/pyignite.datatypes.rst b/docs/source/pyignite.datatypes.rst
index 77e7183..d72f844 100644
--- a/docs/source/pyignite.datatypes.rst
+++ b/docs/source/pyignite.datatypes.rst
@@ -11,6 +11,7 @@
 
 .. toctree::
 
+   pyignite.datatypes.base
    pyignite.datatypes.binary
    pyignite.datatypes.cache_config
    pyignite.datatypes.cache_properties
diff --git a/pyignite/datatypes/base.py b/pyignite/datatypes/base.py
new file mode 100644
index 0000000..a0522c0
--- /dev/null
+++ b/pyignite/datatypes/base.py
@@ -0,0 +1,24 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from abc import ABC
+
+
+class IgniteDataType(ABC):
+    """
+    This is a base class for all Ignite data types, a.k.a. parser/constructor
+    classes, both object and payload varieties.
+    """
+    pass
diff --git a/pyignite/datatypes/complex.py b/pyignite/datatypes/complex.py
index 9a5664c..87e5130 100644
--- a/pyignite/datatypes/complex.py
+++ b/pyignite/datatypes/complex.py
@@ -20,7 +20,8 @@
 from pyignite.constants import *
 from pyignite.exceptions import ParseError
 from pyignite.utils import entity_id, hashcode, is_hinted
-from .internal import AnyDataObject
+from .base import IgniteDataType
+from .internal import AnyDataObject, infer_from_python
 from .type_codes import *
 
 
@@ -30,7 +31,7 @@
 ]
 
 
-class ObjectArrayObject:
+class ObjectArrayObject(IgniteDataType):
     """
     Array of objects of any type. Its Python representation is
     tuple(type_id, iterable of any type).
@@ -106,11 +107,11 @@
         buffer = bytes(header)
 
         for x in value:
-            buffer += AnyDataObject.from_python(x)
+            buffer += infer_from_python(x)
         return buffer
 
 
-class WrappedDataObject:
+class WrappedDataObject(IgniteDataType):
     """
     One or more binary objects can be wrapped in an array. This allows reading,
     storing, passing and writing objects efficiently without understanding
@@ -195,7 +196,7 @@
         )
 
 
-class Map:
+class Map(IgniteDataType):
     """
     Dictionary type, payload-only.
 
@@ -273,14 +274,8 @@
         buffer = bytes(header)
 
         for k, v in value.items():
-            if is_hinted(k):
-                buffer += k[1].from_python(k[0])
-            else:
-                buffer += AnyDataObject.from_python(k)
-            if is_hinted(v):
-                buffer += v[1].from_python(v[0])
-            else:
-                buffer += AnyDataObject.from_python(v)
+            buffer += infer_from_python(k)
+            buffer += infer_from_python(v)
         return buffer
 
 
@@ -323,7 +318,7 @@
         return super().from_python(value, type_id)
 
 
-class BinaryObject:
+class BinaryObject(IgniteDataType):
     type_code = TC_COMPLEX_OBJECT
 
     USER_TYPE = 0x0001
diff --git a/pyignite/datatypes/internal.py b/pyignite/datatypes/internal.py
index a363a5f..844e0ef 100644
--- a/pyignite/datatypes/internal.py
+++ b/pyignite/datatypes/internal.py
@@ -389,6 +389,20 @@
         return cls.map_python_type(value).from_python(value)
 
 
+def infer_from_python(value: Any):
+    """
+    Convert pythonic value to ctypes buffer, type hint-aware.
+
+    :param value: pythonic value or (value, type_hint) tuple,
+    :return: bytes.
+    """
+    if is_hinted(value):
+        value, data_type = value
+    else:
+        data_type = AnyDataObject
+    return data_type.from_python(value)
+
+
 @attr.s
 class AnyDataArray(AnyDataObject):
     """
@@ -454,8 +468,5 @@
         buffer = bytes(header)
 
         for x in value:
-            if is_hinted(x):
-                buffer += x[1].from_python(x[0])
-            else:
-                buffer += super().from_python(x)
+            buffer += infer_from_python(x)
         return buffer
diff --git a/pyignite/datatypes/null_object.py b/pyignite/datatypes/null_object.py
index 9fa1e8f..a648e30 100644
--- a/pyignite/datatypes/null_object.py
+++ b/pyignite/datatypes/null_object.py
@@ -21,13 +21,14 @@
 
 import ctypes
 
+from .base import IgniteDataType
 from .type_codes import TC_NULL
 
 
 __all__ = ['Null']
 
 
-class Null:
+class Null(IgniteDataType):
     default = None
     pythonic = type(None)
     _object_c_type = None
diff --git a/pyignite/datatypes/primitive.py b/pyignite/datatypes/primitive.py
index 94c8fe3..d1e9f4e 100644
--- a/pyignite/datatypes/primitive.py
+++ b/pyignite/datatypes/primitive.py
@@ -16,6 +16,7 @@
 import ctypes
 
 from pyignite.constants import *
+from .base import IgniteDataType
 
 
 __all__ = [
@@ -24,7 +25,7 @@
 ]
 
 
-class Primitive:
+class Primitive(IgniteDataType):
     """
     Ignite primitive type. Base type for the following types:
 
diff --git a/pyignite/datatypes/primitive_arrays.py b/pyignite/datatypes/primitive_arrays.py
index 83a2b4c..6a93191 100644
--- a/pyignite/datatypes/primitive_arrays.py
+++ b/pyignite/datatypes/primitive_arrays.py
@@ -16,6 +16,7 @@
 import ctypes
 
 from pyignite.constants import *
+from .base import IgniteDataType
 from .primitive import *
 from .type_codes import *
 
@@ -28,7 +29,7 @@
 ]
 
 
-class PrimitiveArray:
+class PrimitiveArray(IgniteDataType):
     """
     Base class for array of primitives. Payload-only.
     """
diff --git a/pyignite/datatypes/primitive_objects.py b/pyignite/datatypes/primitive_objects.py
index 4e37ce1..105acee 100644
--- a/pyignite/datatypes/primitive_objects.py
+++ b/pyignite/datatypes/primitive_objects.py
@@ -16,6 +16,7 @@
 import ctypes
 
 from pyignite.constants import *
+from .base import IgniteDataType
 from .type_codes import *
 
 
@@ -25,7 +26,7 @@
 ]
 
 
-class DataObject:
+class DataObject(IgniteDataType):
     """
     Base class for primitive data objects.
 
diff --git a/pyignite/datatypes/standard.py b/pyignite/datatypes/standard.py
index 5f3af74..cc5b955 100644
--- a/pyignite/datatypes/standard.py
+++ b/pyignite/datatypes/standard.py
@@ -20,6 +20,7 @@
 import uuid
 
 from pyignite.constants import *
+from .base import IgniteDataType
 from .type_codes import *
 from .null_object import Null
 
@@ -39,7 +40,7 @@
 ]
 
 
-class StandardObject:
+class StandardObject(IgniteDataType):
     type_code = None
 
     @classmethod
@@ -58,7 +59,7 @@
         return c_type, buffer
 
 
-class String:
+class String(IgniteDataType):
     """
     Pascal-style string: `c_int` counter, followed by count*bytes.
     UTF-8-encoded, so that one character may take 1 to 4 bytes.
@@ -125,7 +126,7 @@
         return bytes(data_object)
 
 
-class DecimalObject:
+class DecimalObject(IgniteDataType):
     type_code = TC_DECIMAL
     pythonic = decimal.Decimal
     default = decimal.Decimal('0.00')
@@ -511,7 +512,7 @@
     type_code = TC_BINARY_ENUM
 
 
-class StandardArray:
+class StandardArray(IgniteDataType):
     """
     Base class for array of primitives. Payload-only.
     """
diff --git a/pyignite/utils.py b/pyignite/utils.py
index a08bc9b..1d4298e 100644
--- a/pyignite/utils.py
+++ b/pyignite/utils.py
@@ -16,6 +16,7 @@
 from functools import wraps
 from typing import Any, Type, Union
 
+from pyignite.datatypes.base import IgniteDataType
 from .constants import *
 
 
@@ -47,11 +48,14 @@
     return (
         isinstance(value, tuple)
         and len(value) == 2
-        and isinstance(value[1], object)
+        and issubclass(value[1], IgniteDataType)
     )
 
 
 def is_wrapped(value: Any) -> bool:
+    """
+    Check if a value is of WrappedDataObject type.
+    """
     return (
         type(value) is tuple
         and len(value) == 2
diff --git a/setup.py b/setup.py
index 7419c97..583eaa3 100644
--- a/setup.py
+++ b/setup.py
@@ -70,7 +70,7 @@
 
 setuptools.setup(
     name='pyignite',
-    version='0.3.1',
+    version='0.3.4',
     python_requires='>={}.{}'.format(*PYTHON_REQUIRED),
     author='Dmitry Melnichuk',
     author_email='dmitry.melnichuk@nobitlost.com',
diff --git a/tests/test_key_value.py b/tests/test_key_value.py
index c569c77..6b4fb0e 100644
--- a/tests/test_key_value.py
+++ b/tests/test_key_value.py
@@ -13,8 +13,12 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+from datetime import datetime
+
 from pyignite.api import *
-from pyignite.datatypes import IntObject
+from pyignite.datatypes import (
+    CollectionObject, IntObject, MapObject, TimestampObject,
+)
 
 
 def test_put_get(client, cache):
@@ -325,3 +329,72 @@
     result = cache_get_size(client, cache)
     assert result.status == 0
     assert result.value == 1
+
+
+def test_put_get_collection(client):
+
+    test_datetime = datetime(year=1996, month=3, day=1)
+
+    cache = client.get_or_create_cache('test_coll_cache')
+    cache.put(
+        'simple',
+        (
+            1,
+            [
+                (123, IntObject),
+                678,
+                None,
+                55.2,
+                ((test_datetime, 0), TimestampObject),
+            ]
+        ),
+        value_hint=CollectionObject
+    )
+    value = cache.get('simple')
+    assert value == (1, [123, 678, None, 55.2, (test_datetime, 0)])
+
+    cache.put(
+        'nested',
+        (
+            1,
+            [
+                123,
+                ((1, [456, 'inner_test_string', 789]), CollectionObject),
+                'outer_test_string',
+            ]
+        ),
+        value_hint=CollectionObject
+    )
+    value = cache.get('nested')
+    assert value == (
+        1,
+        [
+            123,
+            (1, [456, 'inner_test_string', 789]),
+            'outer_test_string'
+        ]
+    )
+
+
+def test_put_get_map(client):
+
+    cache = client.get_or_create_cache('test_map_cache')
+
+    cache.put(
+        'test_map',
+        (
+            MapObject.HASH_MAP,
+            {
+                (123, IntObject): 'test_data',
+                456: ((1, [456, 'inner_test_string', 789]), CollectionObject),
+                'test_key': 32.4,
+            }
+        ),
+        value_hint=MapObject
+    )
+    value = cache.get('test_map')
+    assert value == (MapObject.HASH_MAP, {
+        123: 'test_data',
+        456: (1, [456, 'inner_test_string', 789]),
+        'test_key': 32.4,
+    })