Repository: ciena/afkak

Issue:
KafkaUnavailableError due to bad hosts argument
I passed a value like `[('host1', 1234), ('host2', 1234)]` as the first argument to `KafkaClient`, which isn't supported. Instead of a `ValueError` or `TypeError`, I got a `KafkaUnavailableError` at a later time which wrapped the appropriate `TypeError`:

```
Traceback (most recent call last):
  File "/home/tmost/dev/porch/afkak/.tox/py27-int-snappy-murmur/local/lib/python2.7/site-packages/twisted/internet
/defer.py", line 653, in _runCallbacks
    current.result = callback(current.result, *args, **kw)
  File "/home/tmost/dev/porch/afkak/afkak/client.py", line 346, in _handleMetadataErr
    "hosts: {!r}".format(err))
KafkaUnavailableError: Unable to load metadata from configured hosts: <twisted.python.failure.Failure exceptions.TypeError: not all arguments converted during string formatting>
```

In this case `TypeError` should be raised synchronously by the `KafkaClient` constructor.

Comments:

Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 767780c..b7dc291 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,12 +1,14 @@
 Version Next
 ==============
 
-* **Feature:**  The repr of the `afkak.Consumer` class has been cleaned up to make log messages that include it less noisy.
+- **Feature:** `afkak.KafkaClient` now accepts a sequence ``(host, port)`` tuples as its ``hosts`` argument and when passed to the ``update_cluster_hosts()`` method.
+  This permits passing IPv6 addresses and fixes [#41](https://github.com/ciena/afkak/issues/41).
+
+* **Feature:**  The string representation of `afkak.Consumer` instances has been cleaned up to make log messages that include it less noisy.
    It now looks like ``<Consumer topicname/0 running>`` where ``0`` is the partition number.
 
 * **Feature:** Additional contextual information has been added to several of `afkak.Consumer` debug log messages.
 
-
 Version 19.8.0
 ==============
 
diff --git a/README.md b/README.md
index 31213a0..8d9f8b9 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,7 @@
 # <img src="docs/_static/afkak.png" width="23" height="36" alt=""> Afkak: Twisted Python Kafka Client
 
 <a href="https://pypi.org/projects/afkak"><img src="https://img.shields.io/pypi/v/afkak.svg" alt="PyPI"></a>
+<a href="https://calver.org/"><img src="https://img.shields.io/badge/calver-YY.MM.MICRO-22bfda.svg" alt="calver: YY.MM.MICRO"></a>
 <a href="./LICENSE"><img src="https://img.shields.io/pypi/l/afkak.svg" alt="Apache 2.0"></a>
 <a href="https://afkak.readthedocs.io/en/latest/"><img src="https://readthedocs.org/projects/pip/badge/" alt="Documentation"></a>
 <!--
diff --git a/afkak/client.py b/afkak/client.py
index f90eaf8..7c48102 100644
--- a/afkak/client.py
+++ b/afkak/client.py
@@ -1258,8 +1258,14 @@ def _normalize_hosts(hosts):
     [('127.0.0.2', 2909), ('host', 9092)]
 
     :param hosts:
-        A list or comma-separated string of hostnames which may also include
-        port numbers. All of the following are valid::
+        One of:
+
+        - A comma-separated string of hostnames
+        - A sequence of strings of the form ``host`` or ``host:port``
+        - A sequence of two-tuples of the form ``('host', port)``
+
+        Types are aggressively coerced for the sake of backwards compatibility,
+        so all of the following are valid::
 
             b'host'
             u'host'
@@ -1268,6 +1274,7 @@ def _normalize_hosts(hosts):
             b'host:1234 , host:2345 '
             [u'host1', b'host2']
             [b'host:1234', b'host:2345']
+            [(b'host', 1234), (u'host', '234')]
 
         Hostnames must be ASCII (IDN is not supported). The default Kafka port
         of 9092 is implied when no port is given.
@@ -1282,9 +1289,13 @@ def _normalize_hosts(hosts):
 
     result = set()
     for host_port in hosts:
-        # FIXME This won't handle IPv6 addresses
-        res = nativeString(host_port).split(':')
-        host = res[0].strip()
-        port = int(res[1].strip()) if len(res) > 1 else DefaultKafkaPort
+        if isinstance(host_port, (bytes, _unicode)):
+            res = nativeString(host_port).split(':')
+            host = res[0].strip()
+            port = int(res[1].strip()) if len(res) > 1 else DefaultKafkaPort
+        else:
+            host, port = host_port
+            host = nativeString(host)
+            port = int(port)
         result.add((host, port))
     return sorted(result)


Test patch:
diff --git a/afkak/test/test_client.py b/afkak/test/test_client.py
index 6f220fb..62e38cb 100644
--- a/afkak/test/test_client.py
+++ b/afkak/test/test_client.py
@@ -198,11 +198,27 @@ class TestKafkaClient(unittest.TestCase):
         self.assertRaises(Exception, KafkaClient, 'kafka.example.com', clientId='MyClient', timeout="100ms")
         self.assertRaises(TypeError, KafkaClient, 'kafka.example.com', clientId='MyClient', timeout=None)
 
+    def test_client_bad_hosts(self):
+        """
+        `KafkaClient.__init__` raises an exception when passed an invalid
+        *hosts* argument.
+        """
+        self.assertRaises(Exception, KafkaClient, hosts='foo:notaport')
+
     def test_update_cluster_hosts(self):
         c = KafkaClient(hosts='www.example.com')
         c.update_cluster_hosts('meep.org')
         self.assertEqual(c._bootstrap_hosts, [('meep.org', 9092)])
 
+    def test_update_cluster_hosts_empty(self):
+        """
+        Attempting to set empty bootstrap hosts raises an exception. The
+        configured hosts don't change.
+        """
+        c = KafkaClient(hosts=[('kafka.example.com', 1234)])
+        self.assertRaises(Exception, c.update_cluster_hosts, ['foo:notaport'])
+        self.assertEqual(c._bootstrap_hosts, [('kafka.example.com', 1234)])
+
     def test_send_broker_unaware_request_bootstrap_fail(self):
         """
         Broker unaware requests fail with `KafkaUnavailableError` when boostrap
@@ -1671,3 +1687,30 @@ class NormalizeHostsTests(unittest.TestCase):
         self.assertEqual([('kafka', 1234)], _normalize_hosts(u'kafka:1234 '))
         self.assertEqual([('kafka', 1234), ('kafka', 2345)], _normalize_hosts(u'kafka:1234 ,kafka:2345'))
         self.assertEqual([('1.2.3.4', 5555)], _normalize_hosts(b' 1.2.3.4:5555 '))
+
+    def test_sequence(self):
+        """
+        The input may be a sequence of hostnames or host ports. The default
+        port is implied when none is given.
+        """
+        self.assertEqual(
+            [('kafka', 9092)],
+            _normalize_hosts([u'kafka', b'kafka', ('kafka', 9092)]),
+        )
+        self.assertEqual(
+            [('kafka1', 9092), ('kafka2', 9092)],
+            _normalize_hosts([('kafka2', 9092), ('kafka1', 9092)]),
+        )
+        self.assertEqual(
+            [('kafka1', 1234), ('kafka2', 2345)],
+            _normalize_hosts([('kafka2', u'2345'), ('kafka1', b'1234')]),
+        )
+
+    def test_ipv6(self):
+        """
+        IPv6 addresses may be passed as part of a sequence.
+        """
+        self.assertEqual(
+            [('2001:db8::1', 2345), ('::1', 1234)],
+            _normalize_hosts([('2001:db8::1', '2345'), (b'::1', 1234)]),
+        )