Repository: hazelcast/hazelcast-python-client

Issue:
implement client properties
Right now, configuration properties are set with constant strings that are defined on top of the module they are being used. This causes properties to scatter all over the code and makes it hard for user to import them one by one.

There should be a common class that holds all the available configuration properties to deal with that problem.

Comments:

Patch:
diff --git a/hazelcast/client.py b/hazelcast/client.py
index 1a3a525..e566ce2 100644
--- a/hazelcast/client.py
+++ b/hazelcast/client.py
@@ -1,6 +1,6 @@
 import logging
 from hazelcast.cluster import ClusterService, RandomLoadBalancer
-from hazelcast.config import ClientConfig
+from hazelcast.config import ClientConfig, ClientProperties
 from hazelcast.connection import ConnectionManager, Heartbeat
 from hazelcast.invocation import InvocationService, ListenerService
 from hazelcast.lifecycle import LifecycleService, LIFECYCLE_STATE_SHUTTING_DOWN, LIFECYCLE_STATE_SHUTDOWN
@@ -25,6 +25,7 @@ class HazelcastClient(object):
 
     def __init__(self, config=None):
         self.config = config or ClientConfig()
+        self.properties = ClientProperties(self.config.get_properties())
         self.lifecycle = LifecycleService(self.config)
         self.reactor = AsyncoreReactor()
         self.connection_manager = ConnectionManager(self, self.reactor.new_connection)
diff --git a/hazelcast/config.py b/hazelcast/config.py
index ea6b9bd..1b560af 100644
--- a/hazelcast/config.py
+++ b/hazelcast/config.py
@@ -2,9 +2,10 @@
 Hazelcast Client Configuration module contains configuration classes and various constants required to create a ClientConfig.
 
 """
+import os
 
 from hazelcast.serialization.api import StreamSerializer
-from hazelcast.util import validate_type, validate_serializer, enum
+from hazelcast.util import validate_type, validate_serializer, enum, TimeUnit
 
 DEFAULT_GROUP_NAME = "dev"
 """
@@ -16,18 +17,6 @@ DEFAULT_GROUP_PASSWORD = "dev-pass"
 Default password of connected Hazelcast cluster
 """
 
-PROPERTY_HEARTBEAT_INTERVAL = "hazelcast.client.heartbeat.interval"
-"""
-Configuration property for heartbeat interval in milliseconds. Client will send heartbeat to server by this value of interval
-unless other requests send to server
-"""
-
-PROPERTY_HEARTBEAT_TIMEOUT = "hazelcast.client.heartbeat.timeout"
-"""
-Configuration property for heartbeat timeout in milliseconds. If client cannot see any activity on a connection for this timeout
-value it will shutdown the connection
-"""
-
 INTEGER_TYPE = enum(VAR=0, BYTE=1, SHORT=2, INT=3, LONG=4, BIG_INT=5)
 """
 Integer type options that can be used by serialization service.
@@ -76,6 +65,7 @@ class ClientConfig(object):
 
     def __init__(self):
         self._properties = {}
+        """Config properties"""
 
         self.group_config = GroupConfig()
         """The group configuration"""
@@ -124,6 +114,16 @@ class ClientConfig(object):
             self.lifecycle_listeners.append(lifecycle_state_changed)
         return self
 
+    def add_near_cache_config(self, near_cache_config):
+        """
+        Helper method to add a new NearCacheConfig.
+
+        :param near_cache_config: (NearCacheConfig), the near_cache config to add.
+        :return: `self` for cascading configuration.
+        """
+        self.near_cache_configs[near_cache_config.name] = near_cache_config
+        return self
+
     def get_property_or_default(self, key, default):
         """
         Client property accessor with fallback to default value.
@@ -137,14 +137,21 @@ class ClientConfig(object):
         except KeyError:
             return default
 
-    def add_near_cache_config(self, near_cache_config):
+    def get_properties(self):
         """
-        Helper method to add a new NearCacheConfig.
+        Gets the configuration properties.
+        :return: (dict), Client configuration properties.
+        """
+        return self._properties
 
-        :param near_cache_config: (NearCacheConfig), the near_cache config to add.
+    def set_property(self, key, value):
+        """
+        Sets the value of a named property.
+        :param key: Property name
+        :param value: Value of the property
         :return: `self` for cascading configuration.
         """
-        self.near_cache_configs[near_cache_config.name] = near_cache_config
+        self._properties[key] = value
         return self
 
 
@@ -217,6 +224,7 @@ class SocketOption(object):
     A Socket option represent the unix socket option, that will be passed to python socket.setoption(level,`option, value)`
     See the Unix manual for level and option.
     """
+
     def __init__(self, level, option, value):
         self.level = level
         """Option level. See the Unix manual for detail."""
@@ -230,6 +238,7 @@ class SerializationConfig(object):
     """
     Hazelcast Serialization Service configuration options can be set from this class.
     """
+
     def __init__(self):
         self.portable_version = 0
         """
@@ -439,3 +448,82 @@ class NearCacheConfig(object):
         if eviction_sampling_pool_size < 1:
             raise ValueError("'eviction_sampling_pool_size' cannot be less than 1")
         self._eviction_sampling_pool_size = eviction_sampling_pool_size
+
+
+class ClientProperty(object):
+    """
+    Client property holds the name, default value and time unit of Hazelcast client properties.
+    Client properties can be set by
+    * Programmatic Configuration (config.set_property)
+    * Environment variables
+    """
+
+    def __init__(self, name, default_value=None, time_unit=None):
+        self.name = name
+        self.default_value = default_value
+        self.time_unit = time_unit
+
+
+class ClientProperties(object):
+    HEARTBEAT_INTERVAL = ClientProperty("hazelcast.client.heartbeat.interval", 5000, TimeUnit.MILLISECOND)
+    """
+    Time interval between the heartbeats sent by the client to the nodes.
+    """
+
+    HEARTBEAT_TIMEOUT = ClientProperty("hazelcast.client.heartbeat.timeout", 60000, TimeUnit.MILLISECOND)
+    """
+    Client sends heartbeat messages to the members and this is the timeout for this sending operations.
+    If there is not any message passing between the client and member within the given time via this property
+    in milliseconds, the connection will be closed.
+    """
+
+    INVOCATION_TIMEOUT_SECONDS = ClientProperty("hazelcast.client.invocation.timeout.seconds", 120, TimeUnit.SECOND)
+    """
+    When an invocation gets an exception because :
+    * Member throws an exception.
+    * Connection between the client and member is closed.
+    * Client's heartbeat requests are timed out.
+    Time passed since invocation started is compared with this property.
+    If the time is already passed, then the exception is delegated to the user. If not, the invocation is retried.
+    Note that, if invocation gets no exception and it is a long running one, then it will not get any exception,
+    no matter how small this timeout is set.
+    """
+
+    INVOCATION_RETRY_PAUSE_MILLIS = ClientProperty("hazelcast.client.invocation.retry.pause.millis", 1000,
+                                                   TimeUnit.MILLISECOND)
+    """
+    Pause time between each retry cycle of an invocation in milliseconds.
+    """
+
+    def __init__(self, properties):
+        self._properties = properties
+
+    def get(self, property):
+        """
+        Gets the value of the given property. First checks client config properties, then environment variables
+        and lastly fall backs to the default value of the property.
+        :param property: (:class:`~hazelcast.config.ClientProperty`), Property to get value from
+        :return: Returns the value of the given property
+        """
+        return self._properties.get(property.name) or os.getenv(property.name) or property.default_value
+
+    def get_seconds(self, property):
+        """
+        Gets the value of the given property in seconds. If the value of the given property is not a number,
+        throws TypeError.
+        :param property: (:class:`~hazelcast.config.ClientProperty`), Property to get seconds from
+        :return: (float), Value of the given property in seconds
+        """
+        return TimeUnit.to_seconds(self.get(property), property.time_unit)
+
+    def get_seconds_positive_or_default(self, property):
+        """
+        Gets the value of the given property in seconds. If the value of the given property is not a number,
+        throws TypeError. If the value of the given property in seconds is not positive, tries to
+        return the default value in seconds.
+        :param property: (:class:`~hazelcast.config.ClientProperty`), Property to get seconds from
+        :return: (float), Value of the given property in seconds if it is positive.
+        Else, value of the default value of given property in seconds.
+        """
+        seconds = self.get_seconds(property)
+        return seconds if seconds > 0 else TimeUnit.to_seconds(property.default_value, property.time_unit)
diff --git a/hazelcast/connection.py b/hazelcast/connection.py
index 9c580e8..e37f7bb 100644
--- a/hazelcast/connection.py
+++ b/hazelcast/connection.py
@@ -6,7 +6,6 @@ import sys
 import threading
 import time
 
-from hazelcast.config import PROPERTY_HEARTBEAT_INTERVAL, PROPERTY_HEARTBEAT_TIMEOUT
 from hazelcast.core import CLIENT_TYPE
 from hazelcast.exception import AuthenticationError
 from hazelcast.future import ImmediateFuture, ImmediateExceptionFuture
@@ -19,9 +18,6 @@ from hazelcast import six
 BUFFER_SIZE = 8192
 PROTOCOL_VERSION = 1
 
-DEFAULT_HEARTBEAT_INTERVAL = 5000
-DEFAULT_HEARTBEAT_TIMEOUT = 60000
-
 
 class ConnectionManager(object):
     """
@@ -188,10 +184,8 @@ class Heartbeat(object):
         self._client = client
         self._listeners = []
 
-        self._heartbeat_timeout = client.config.get_property_or_default(PROPERTY_HEARTBEAT_TIMEOUT,
-                                                                        DEFAULT_HEARTBEAT_TIMEOUT) // 1000
-        self._heartbeat_interval = client.config.get_property_or_default(PROPERTY_HEARTBEAT_INTERVAL,
-                                                                         DEFAULT_HEARTBEAT_INTERVAL) // 1000
+        self._heartbeat_timeout = client.properties.get_seconds_positive_or_default(client.properties.HEARTBEAT_TIMEOUT)
+        self._heartbeat_interval = client.properties.get_seconds_positive_or_default(client.properties.HEARTBEAT_INTERVAL)
 
     def start(self):
         """
diff --git a/hazelcast/util.py b/hazelcast/util.py
index 052afe5..db59e56 100644
--- a/hazelcast/util.py
+++ b/hazelcast/util.py
@@ -219,3 +219,27 @@ def get_portable_version(portable, default_version):
     except AttributeError:
         version = default_version
     return version
+
+
+class TimeUnit(object):
+    """
+    Represents the time durations at given units in seconds.
+    """
+    NANOSECOND = 1e-9
+    MICROSECOND = 1e-6
+    MILLISECOND = 1e-3
+    SECOND = 1.0
+    MINUTE = 60.0
+    HOUR = 3600.0
+
+    @staticmethod
+    def to_seconds(value, time_unit):
+        """
+        :param value: (Number), value to be translated to seconds
+        :param time_unit: Time duration in seconds
+        :return: Value of the value in seconds
+        """
+        if isinstance(value, bool):
+            # bool is a subclass of int. Don't let bool and float multiplication.
+            raise TypeError
+        return float(value) * time_unit


Test patch:
diff --git a/tests/property_tests.py b/tests/property_tests.py
new file mode 100644
index 0000000..b8b61b3
--- /dev/null
+++ b/tests/property_tests.py
@@ -0,0 +1,126 @@
+import os
+
+from hazelcast.util import TimeUnit
+from hazelcast.config import ClientProperty, ClientProperties, ClientConfig
+from unittest import TestCase
+
+
+class PropertyTest(TestCase):
+    def test_client_property_defaults(self):
+        prop = ClientProperty("name")
+        self.assertEqual("name", prop.name)
+        self.assertIsNone(prop.default_value)
+        self.assertIsNone(prop.time_unit)
+
+    def test_client_property(self):
+        prop = ClientProperty("name", 0, TimeUnit.SECOND)
+        self.assertEqual("name", prop.name)
+        self.assertEqual(0, prop.default_value)
+        self.assertEqual(TimeUnit.SECOND, prop.time_unit)
+
+    def test_client_properties_with_config(self):
+        config = ClientConfig()
+        prop = ClientProperty("key")
+        config.set_property(prop.name, "value")
+
+        props = ClientProperties(config.get_properties())
+        self.assertEqual("value", props.get(prop))
+
+    def test_client_properties_with_default_value(self):
+        config = ClientConfig()
+        prop = ClientProperty("key", "def-value")
+
+        props = ClientProperties(config.get_properties())
+        self.assertEqual("def-value", props.get(prop))
+
+    def test_client_properties_with_config_and_default_value(self):
+        config = ClientConfig()
+        prop = ClientProperty("key", "def-value")
+        config.set_property(prop.name, "value")
+
+        props = ClientProperties(config.get_properties())
+        self.assertEqual("value", props.get(prop))
+
+    def test_client_properties_with_environment_variable(self):
+        environ = os.environ
+        environ[ClientProperties.HEARTBEAT_INTERVAL.name] = "3000"
+
+        props = ClientProperties(dict())
+        self.assertEqual("3000", props.get(ClientProperties.HEARTBEAT_INTERVAL))
+        os.unsetenv(ClientProperties.HEARTBEAT_INTERVAL.name)
+
+    def test_client_properties_with_config_default_value_and_environment_variable(self):
+        environ = os.environ
+        prop = ClientProperties.HEARTBEAT_INTERVAL
+        environ[prop.name] = "1000"
+
+        config = ClientConfig()
+        config.set_property(prop.name, 2000)
+
+        props = ClientProperties(config.get_properties())
+        self.assertEqual(2, props.get_seconds(prop))
+        os.unsetenv(prop.name)
+
+    def test_client_properties_get_second(self):
+        config = ClientConfig()
+        prop = ClientProperty("test", time_unit=TimeUnit.MILLISECOND)
+        config.set_property(prop.name, 1000)
+
+        props = ClientProperties(config.get_properties())
+        self.assertEqual(1, props.get_seconds(prop))
+    
+    def test_client_properties_get_second_unsupported_type(self):
+        config = ClientConfig()
+        prop = ClientProperty("test", "value", TimeUnit.SECOND)
+        config.set_property(prop.name, None)
+        
+        props = ClientProperties(config.get_properties())
+        with self.assertRaises(ValueError):
+            props.get_seconds(prop)
+
+    def test_client_properties_get_second_positive(self):
+        config = ClientConfig()
+        prop = ClientProperty("test", 1000, TimeUnit.MILLISECOND)
+        config.set_property(prop.name, -1000)
+
+        props = ClientProperties(config.get_properties())
+        self.assertEqual(1, props.get_seconds_positive_or_default(prop))
+
+    def test_client_properties_get_second_positive_unsupported_type(self):
+        config = ClientConfig()
+        prop = ClientProperty("test", "value", TimeUnit.MILLISECOND)
+        config.set_property(prop.name, None)
+
+        props = ClientProperties(config.get_properties())
+        with self.assertRaises(ValueError):
+            props.get_seconds_positive_or_default(prop)
+
+
+class TimeUnitTest(TestCase):
+    def test_nano_to_second(self):
+        self.assertEqual(0.1, TimeUnit.to_seconds(0.1e9, TimeUnit.NANOSECOND))
+
+    def test_micro_to_second(self):
+        self.assertEqual(2, TimeUnit.to_seconds(2e6, TimeUnit.MICROSECOND))
+
+    def test_milli_to_second(self):
+        self.assertEqual(3, TimeUnit.to_seconds(3e3, TimeUnit.MILLISECOND))
+
+    def test_second_to_second(self):
+        self.assertEqual(5.5, TimeUnit.to_seconds(5.5, TimeUnit.SECOND))
+
+    def test_minute_to_second(self):
+        self.assertEqual(60, TimeUnit.to_seconds(1, TimeUnit.MINUTE))
+
+    def test_hour_to_second(self):
+        self.assertEqual(1800, TimeUnit.to_seconds(0.5, TimeUnit.HOUR))
+
+    def test_numeric_string_to_second(self):
+        self.assertEqual(1, TimeUnit.to_seconds("1000", TimeUnit.MILLISECOND))
+
+    def test_unsupported_types_to_second(self):
+        types = ["str", True, None, list(), set(), dict()]
+        for type in types:
+            with self.assertRaises((TypeError, ValueError)):
+                TimeUnit.to_seconds(type, TimeUnit.SECOND)
+