Repository: hazelcast/hazelcast-python-client

Issue:
Allow using literals for enum-like configs
For example, to configure the reconnect mode, one has to do the following.

```python
import hazelcast

from hazelcast.config import ReconnectMode


client = hazelcast.HazelcastClient(
    reconnect_mode=ReconnectMode.OFF,
)
```

We can get rid of the import and extra typing required by also allowing configurations like below
```python
import hazelcast

client = hazelcast.HazelcastClient(
    reconnect_mode="OFF",
)
```

Discussion:
mdumandag: Thanks a lot for the review Andrey

Patch:
diff --git a/docs/client_connection_strategy.rst b/docs/client_connection_strategy.rst
index 2b3815c..58df752 100644
--- a/docs/client_connection_strategy.rst
+++ b/docs/client_connection_strategy.rst
@@ -38,6 +38,8 @@ starting and reconnecting modes.
 
     client = hazelcast.HazelcastClient(
         async_start=False,
+        # You can also set this to "ON"
+        # without importing anything.
         reconnect_mode=ReconnectMode.ON
     )
 
diff --git a/docs/securing_client_connection.rst b/docs/securing_client_connection.rst
index 9751067..ca684df 100644
--- a/docs/securing_client_connection.rst
+++ b/docs/securing_client_connection.rst
@@ -51,6 +51,8 @@ configuration and then go over the configuration options one by one:
         ssl_certfile="/home/hazelcast/certfile.pem",
         ssl_keyfile="/home/hazelcast/keyfile.pem",
         ssl_password="keyfile-password",
+        # You can also set this to "TLSv1_3"
+        # without importing anything.
         ssl_protocol=SSLProtocol.TLSv1_3,
         ssl_ciphers="DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA"
     )
diff --git a/docs/using_python_client_with_hazelcast_imdg.rst b/docs/using_python_client_with_hazelcast_imdg.rst
index f312dbb..b9e8483 100644
--- a/docs/using_python_client_with_hazelcast_imdg.rst
+++ b/docs/using_python_client_with_hazelcast_imdg.rst
@@ -1745,6 +1745,8 @@ configuration, its default value is used.
                 "invalidate_on_change": True,
                 "time_to_live": 60,
                 "max_idle": 30,
+                # You can also set these to "OBJECT"
+                # and "LRU" without importing anything.
                 "in_memory_format": InMemoryFormat.OBJECT,
                 "eviction_policy": EvictionPolicy.LRU,
                 "eviction_max_size": 100,
diff --git a/hazelcast/client.py b/hazelcast/client.py
index 0a8aac6..dc8cc0c 100644
--- a/hazelcast/client.py
+++ b/hazelcast/client.py
@@ -78,8 +78,9 @@ class HazelcastClient(object):
             it will be encoded as UTF-8 before using it to decrypt the key.
             Alternatively a string, bytes, or bytearray value may be supplied
             directly as the password.
-        ssl_protocol (int): Protocol version used in SSL communication.
-            By default, set to ``SSLProtocol.TLSv1_2``.
+        ssl_protocol (int|str): Protocol version used in SSL communication.
+            By default, set to ``TLSv1_2``. See the
+            :class:`hazelcast.config.SSLProtocol` for possible values.
         ssl_ciphers (str): String in the OpenSSL cipher list format to set the
             available ciphers for sockets. More than one cipher can be set by
             separating them with a colon.
@@ -91,8 +92,9 @@ class HazelcastClient(object):
             cluster and becomes ready. If set to ``False``, :class:`HazelcastClient`
             will block until a cluster connection established and it is ready to use
             the client instance. By default, set to ``False``.
-        reconnect_mode (int): Defines how a client reconnects to cluster after a
-            disconnect. By default, set to ``ReconnectMode.ON``.
+        reconnect_mode (int|str): Defines how a client reconnects to cluster after a
+            disconnect. By default, set to ``ON``. See the
+            :class:`hazelcast.config.ReconnectMode` for possible values.
         retry_initial_backoff (float): Wait period in seconds after the first failure
             before retrying. Must be non-negative. By default, set to ``1.0``.
         retry_max_backoff (float): Upper bound for the backoff interval in seconds.
@@ -164,9 +166,10 @@ class HazelcastClient(object):
             set to ``True``.
         is_big_endian (bool): Defines if big-endian is used as the byte order
             for the serialization. By default, set to ``True``.
-        default_int_type (int): Defines how the ``int``/``long`` type is
+        default_int_type (int|str): Defines how the ``int``/``long`` type is
             represented on the cluster side. By default, it is serialized
-            as ``IntType.INT`` (``32`` bits).
+            as ``INT`` (``32`` bits). See the
+            :class:`hazelcast.config.IntType` for possible values.
         global_serializer (hazelcast.serialization.api.StreamSerializer):
             Defines the global serializer. This serializer
             is registered as a fallback serializer to handle all other objects
@@ -198,16 +201,18 @@ class HazelcastClient(object):
             - **invalidate_on_change** (bool): Enables cluster-assisted invalidate
               on change behavior. When set to ``True``, entries are invalidated
               when they are changed in cluster. By default, set to ``True``.
-            - **in_memory_format** (int): Specifies in which format data will be
-              stored in the Near Cache. By default, set to ``InMemoryFormat.BINARY``.
+            - **in_memory_format** (int|str): Specifies in which format data will be
+              stored in the Near Cache. By default, set to ``BINARY``. See the
+              :class:`hazelcast.config.InMemoryFormat` for possible values.
             - **time_to_live** (float): Maximum number of seconds that an entry can
               stay in cache. When not set, entries won't be evicted due
               to expiration.
             - **max_idle** (float): Maximum number of seconds that an entry can stay
               in the Near Cache until it is accessed. When not set, entries won't be
               evicted due to inactivity.
-            - **eviction_policy** (int): Defines eviction policy configuration.
-              By default, set to ``EvictionPolicy.LRU``.
+            - **eviction_policy** (int|str): Defines eviction policy configuration.
+              By default, set to ``LRU``. See the
+              :class:`hazelcast.config.EvictionPolicy` for possible values.
             - **eviction_max_size** (int): Defines maximum number of entries kept in
               the memory before eviction kicks in. By default, set to ``10000``.
             - **eviction_sampling_count** (int): Number of random entries that are
diff --git a/hazelcast/config.py b/hazelcast/config.py
index 6dac76f..51dd88c 100644
--- a/hazelcast/config.py
+++ b/hazelcast/config.py
@@ -4,7 +4,7 @@ from hazelcast import six
 from hazelcast.errors import InvalidConfigurationError
 from hazelcast.serialization.api import StreamSerializer, IdentifiedDataSerializable, Portable
 from hazelcast.serialization.portable.classdef import ClassDefinition
-from hazelcast.util import check_not_none, number_types, LoadBalancer, none_type, get_attr_name
+from hazelcast.util import check_not_none, number_types, LoadBalancer, none_type, try_to_get_enum_value
 
 
 class IntType(object):
@@ -216,21 +216,15 @@ class BitmapIndexOptions(object):
 
     @unique_key.setter
     def unique_key(self, value):
-        if get_attr_name(QueryConstants, value):
-            self._unique_key = value
-        else:
-            raise TypeError("unique_key must be of type QueryConstants")
-    
+        self._unique_key = try_to_get_enum_value(value, QueryConstants)
+
     @property
     def unique_key_transformation(self):
         return self._unique_key_transformation
     
     @unique_key_transformation.setter
     def unique_key_transformation(self, value):
-        if get_attr_name(UniqueKeyTransformation, value):
-            self._unique_key_transformation = value
-        else:
-            raise TypeError("unique_key_transformation must be of type UniqueKeyTransformation")
+        self._unique_key_transformation = try_to_get_enum_value(value, UniqueKeyTransformation)
 
     @classmethod
     def from_dict(cls, d):
@@ -288,10 +282,7 @@ class IndexConfig(object):
 
     @type.setter
     def type(self, value):
-        if get_attr_name(IndexType, value):
-            self._type = value
-        else:
-            raise TypeError("type must be of type IndexType")
+        self._type = try_to_get_enum_value(value, IndexType)
 
     @property
     def attributes(self):
@@ -300,6 +291,8 @@ class IndexConfig(object):
     @attributes.setter
     def attributes(self, value):
         if isinstance(value, list):
+            for attribute in value:
+                IndexUtil.validate_attribute(attribute)
             self._attributes = value
         else:
             raise TypeError("attributes must be a list")
@@ -655,10 +648,7 @@ class _Config(object):
 
     @ssl_protocol.setter
     def ssl_protocol(self, value):
-        if get_attr_name(SSLProtocol, value):
-            self._ssl_protocol = value
-        else:
-            raise TypeError("ssl_protocol must be of type SSLProtocol")
+        self._ssl_protocol = try_to_get_enum_value(value, SSLProtocol)
 
     @property
     def ssl_ciphers(self):
@@ -699,10 +689,7 @@ class _Config(object):
 
     @reconnect_mode.setter
     def reconnect_mode(self, value):
-        if get_attr_name(ReconnectMode, value):
-            self._reconnect_mode = value
-        else:
-            raise TypeError("reconnect_mode must be a type of ReconnectMode")
+        self._reconnect_mode = try_to_get_enum_value(value, ReconnectMode)
 
     @property
     def retry_initial_backoff(self):
@@ -877,10 +864,7 @@ class _Config(object):
 
     @default_int_type.setter
     def default_int_type(self, value):
-        if get_attr_name(IntType, value):
-            self._default_int_type = value
-        else:
-            raise TypeError("default_int_type must be of type IntType")
+        self._default_int_type = try_to_get_enum_value(value, IntType)
 
     @property
     def global_serializer(self):
@@ -1184,10 +1168,7 @@ class _NearCacheConfig(object):
 
     @in_memory_format.setter
     def in_memory_format(self, value):
-        if get_attr_name(InMemoryFormat, value):
-            self._in_memory_format = value
-        else:
-            raise TypeError("in_memory_format must be of the type InMemoryFormat")
+        self._in_memory_format = try_to_get_enum_value(value, InMemoryFormat)
 
     @property
     def time_to_live(self):
@@ -1221,10 +1202,7 @@ class _NearCacheConfig(object):
 
     @eviction_policy.setter
     def eviction_policy(self, value):
-        if get_attr_name(EvictionPolicy, value):
-            self._eviction_policy = value
-        else:
-            raise TypeError("eviction_policy must be of type EvictionPolicy")
+        self._eviction_policy = try_to_get_enum_value(value, EvictionPolicy)
 
     @property
     def eviction_max_size(self):
diff --git a/hazelcast/proxy/map.py b/hazelcast/proxy/map.py
index 8bef366..43dea9a 100644
--- a/hazelcast/proxy/map.py
+++ b/hazelcast/proxy/map.py
@@ -167,10 +167,22 @@ class Map(Proxy):
 
         Args:
             attributes (list[str]): List of indexed attributes.
-            index_type (int): Type of the index.
+            index_type (int|str): Type of the index. By default, set to ``SORTED``.
+                See the :class:`hazelcast.config.IndexType` for possible values.
             name (str): Name of the index.
             bitmap_index_options (dict): Bitmap index options.
 
+                - **unique_key:** (str): The unique key attribute is used as a
+                  source of values which uniquely identify each entry being
+                  inserted into an index. Defaults to ``KEY_ATTRIBUTE_NAME``.
+                  See the :class:`hazelcast.config.QueryConstants` for possible
+                  values.
+                - **unique_key_transformation** (int|str): The transformation is
+                  applied to every value extracted from the unique key attribue.
+                  Defaults to ``OBJECT``. See the
+                  :class:`hazelcast.config.UniqueKeyTransformation` for possible
+                  values.
+
         Returns:
             hazelcast.future.Future[None]:
         """
diff --git a/hazelcast/util.py b/hazelcast/util.py
index 5abf30c..72c977b 100644
--- a/hazelcast/util.py
+++ b/hazelcast/util.py
@@ -209,6 +209,31 @@ def get_attr_name(cls, value):
     return None
 
 
+def _get_enum_value(cls, key):
+    if isinstance(key, six.string_types):
+        return getattr(cls, key, None)
+    return None
+
+
+def try_to_get_enum_value(value, enum_class):
+    if get_attr_name(enum_class, value):
+        # If the value given by the user corresponds
+        # to value of the one of the enum members,
+        # it is okay to set it directly
+        return value
+    else:
+        # We couldn't find a enum member name for the
+        # given value. Try to match the given config
+        # option with enum member names and get value
+        # associated with it.
+        enum_value = _get_enum_value(enum_class, value)
+        if enum_value is not None:
+            return enum_value
+        else:
+            raise TypeError("%s must be equal to one of the values or "
+                            "names of the members of the %s" % (value, enum_class.__name__))
+
+
 number_types = (six.integer_types, float)
 none_type = type(None)
 


Test patch:
diff --git a/tests/config_test.py b/tests/config_test.py
index 541a60e..3467f2a 100644
--- a/tests/config_test.py
+++ b/tests/config_test.py
@@ -1,7 +1,7 @@
 import unittest
 
-from hazelcast.config import _Config, SSLProtocol, ReconnectMode, IntType, InMemoryFormat, EvictionPolicy,\
-    IndexConfig, IndexType, UniqueKeyTransformation, QueryConstants
+from hazelcast.config import _Config, SSLProtocol, ReconnectMode, IntType, InMemoryFormat, EvictionPolicy, \
+    IndexConfig, IndexType, UniqueKeyTransformation, QueryConstants, BitmapIndexOptions
 from hazelcast.errors import InvalidConfigurationError
 from hazelcast.serialization.api import IdentifiedDataSerializable, Portable, StreamSerializer
 from hazelcast.serialization.portable.classdef import ClassDefinition
@@ -189,6 +189,9 @@ class ConfigTest(unittest.TestCase):
         config.ssl_protocol = 0
         self.assertEqual(0, config.ssl_protocol)
 
+        config.ssl_protocol = "TLSv1_2"
+        self.assertEqual(SSLProtocol.TLSv1_2, config.ssl_protocol)
+
     def test_ssl_ciphers(self):
         config = self.config
         self.assertIsNone(config.ssl_ciphers)
@@ -232,6 +235,9 @@ class ConfigTest(unittest.TestCase):
         config.reconnect_mode = 0
         self.assertEqual(0, config.reconnect_mode)
 
+        config.reconnect_mode = "OFF"
+        self.assertEqual(ReconnectMode.OFF, config.reconnect_mode)
+
     def test_retry_initial_backoff(self):
         config = self.config
         self.assertEqual(1, config.retry_initial_backoff)
@@ -408,6 +414,9 @@ class ConfigTest(unittest.TestCase):
         config.default_int_type = 0
         self.assertEqual(0, config.default_int_type)
 
+        config.default_int_type = "INT"
+        self.assertEqual(IntType.INT, config.default_int_type)
+
     def test_global_serializer(self):
         config = self.config
         self.assertIsNone(config.global_serializer)
@@ -500,10 +509,10 @@ class ConfigTest(unittest.TestCase):
         config = self.config
         config.near_caches = {"a": {
             "invalidate_on_change": False,
-            "in_memory_format": InMemoryFormat.OBJECT,
+            "in_memory_format": "OBJECT",
             "time_to_live": 100,
             "max_idle": 200,
-            "eviction_policy": EvictionPolicy.RANDOM,
+            "eviction_policy": "RANDOM",
             "eviction_max_size": 1000,
             "eviction_sampling_count": 20,
             "eviction_sampling_pool_size": 15,
@@ -757,7 +766,31 @@ class IndexConfigTest(unittest.TestCase):
         self.assertIsNone(config.name)
         self.assertEqual(IndexType.SORTED, config.type)
         self.assertEqual([], config.attributes)
-        self.assertEqual("__key", config.bitmap_index_options.unique_key)
+        self.assertEqual(QueryConstants.KEY_ATTRIBUTE_NAME, config.bitmap_index_options.unique_key)
+        self.assertEqual(UniqueKeyTransformation.OBJECT, config.bitmap_index_options.unique_key_transformation)
+
+    def test_from_dict(self):
+        with self.assertRaises(InvalidConfigurationError):
+            IndexConfig.from_dict({
+                "unknown_key": 1
+            })
+
+    def test_from_dict_defaults(self):
+        config = IndexConfig.from_dict({})
+        self.assertIsNone(config.name)
+        self.assertEqual(IndexType.SORTED, config.type)
+        self.assertEqual([], config.attributes)
+        self.assertEqual(QueryConstants.KEY_ATTRIBUTE_NAME, config.bitmap_index_options.unique_key)
+        self.assertEqual(UniqueKeyTransformation.OBJECT, config.bitmap_index_options.unique_key_transformation)
+
+    def test_from_dict_with_changes(self):
+        config = IndexConfig.from_dict({
+            "name": "test",
+        })
+        self.assertEqual("test", config.name)
+        self.assertEqual(IndexType.SORTED, config.type)
+        self.assertEqual([], config.attributes)
+        self.assertEqual(QueryConstants.KEY_ATTRIBUTE_NAME, config.bitmap_index_options.unique_key)
         self.assertEqual(UniqueKeyTransformation.OBJECT, config.bitmap_index_options.unique_key_transformation)
 
     def test_add_attributes(self):
@@ -795,6 +828,14 @@ class IndexConfigTest(unittest.TestCase):
         self.assertEqual(bio["unique_key_transformation"], config.bitmap_index_options.unique_key_transformation)
 
     def test_bitmap_index_options(self):
+        config = IndexConfig()
+
+        config.bitmap_index_options = {
+            "unique_key": QueryConstants.THIS_ATTRIBUTE_NAME
+        }
+        self.assertEqual(QueryConstants.THIS_ATTRIBUTE_NAME, config.bitmap_index_options.unique_key)
+        self.assertEqual(UniqueKeyTransformation.OBJECT, config.bitmap_index_options.unique_key_transformation)
+
         invalid_options = [
             ({"unique_key": None}, TypeError),
             ({"unique_key_transformation": None}, TypeError),
@@ -804,4 +845,86 @@ class IndexConfigTest(unittest.TestCase):
 
         for o, e in invalid_options:
             with self.assertRaises(e):
-                IndexConfig(bitmap_index_options=o)
+                config.bitmap_index_options = o
+
+    def test_name(self):
+        config = IndexConfig()
+
+        config.name = "test"
+        self.assertEqual("test", config.name)
+
+        with self.assertRaises(TypeError):
+            config.name = 123
+
+    def test_type(self):
+        config = IndexConfig()
+
+        config.type = IndexType.BITMAP
+        self.assertEqual(IndexType.BITMAP, config.type)
+
+        config.type = "HASH"
+        self.assertEqual(IndexType.HASH, config.type)
+
+        with self.assertRaises(TypeError):
+            config.type = "HASHH"
+
+    def test_attributes(self):
+        config = IndexConfig()
+
+        config.attributes = ["a"]
+        self.assertEqual(["a"], config.attributes)
+
+        with self.assertRaises(TypeError):
+            config.attributes = None
+
+        with self.assertRaises(ValueError):
+            config.attributes = ["a."]
+
+
+class BitmapIndexOptionsTest(unittest.TestCase):
+    def test_defaults(self):
+        options = BitmapIndexOptions()
+        self.assertEqual(QueryConstants.KEY_ATTRIBUTE_NAME, options.unique_key)
+        self.assertEqual(UniqueKeyTransformation.OBJECT, options.unique_key_transformation)
+
+    def test_from_dict(self):
+        with self.assertRaises(InvalidConfigurationError):
+            BitmapIndexOptions.from_dict({
+                "unknown_key": 1
+            })
+
+    def test_from_dict_defaults(self):
+        options = BitmapIndexOptions.from_dict({})
+        self.assertEqual(QueryConstants.KEY_ATTRIBUTE_NAME, options.unique_key)
+        self.assertEqual(UniqueKeyTransformation.OBJECT, options.unique_key_transformation)
+
+    def test_from_dict_with_changes(self):
+        options = BitmapIndexOptions.from_dict({
+            "unique_key": QueryConstants.THIS_ATTRIBUTE_NAME,
+        })
+        self.assertEqual(QueryConstants.THIS_ATTRIBUTE_NAME, options.unique_key)
+        self.assertEqual(UniqueKeyTransformation.OBJECT, options.unique_key_transformation)
+
+    def test_unique_key(self):
+        options = BitmapIndexOptions()
+
+        options.unique_key = QueryConstants.THIS_ATTRIBUTE_NAME
+        self.assertEqual(options.unique_key, QueryConstants.THIS_ATTRIBUTE_NAME)
+
+        options.unique_key = "KEY_ATTRIBUTE_NAME"
+        self.assertEqual(options.unique_key, QueryConstants.KEY_ATTRIBUTE_NAME)
+
+        with self.assertRaises(TypeError):
+            options.unique_key = None
+
+    def test_unique_key_transformation(self):
+        options = BitmapIndexOptions()
+
+        options.unique_key_transformation = UniqueKeyTransformation.RAW
+        self.assertEqual(options.unique_key_transformation, UniqueKeyTransformation.RAW)
+
+        options.unique_key_transformation = "LONG"
+        self.assertEqual(options.unique_key_transformation, UniqueKeyTransformation.LONG)
+
+        with self.assertRaises(TypeError):
+            options.unique_key_transformation = 132