summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore4
-rw-r--r--setup.py4
-rw-r--r--src/etcd/__init__.py20
-rw-r--r--src/etcd/client.py69
-rw-r--r--src/etcd/tests/integration/test_simple.py1
-rw-r--r--src/etcd/tests/unit/test_result.py144
6 files changed, 205 insertions, 37 deletions
diff --git a/.gitignore b/.gitignore
index 00bb6bf..765321b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,9 +4,13 @@
bin
develop-eggs
eggs
+.eggs
+.idea
*.egg-info
tmp
build
dist
+docs
+etcd
.coverage
diff --git a/setup.py b/setup.py
index 0766c60..f2401e8 100644
--- a/setup.py
+++ b/setup.py
@@ -18,7 +18,8 @@ test_requires = [
'pyOpenSSL>=0.14'
]
-setup(name='python-etcd',
+setup(
+ name='python-etcd',
version=version,
description="A python client for etcd",
long_description=README + '\n\n' + NEWS,
@@ -42,5 +43,4 @@ setup(name='python-etcd',
install_requires=install_requires,
tests_require=test_requires,
test_suite='nose.collector',
-
)
diff --git a/src/etcd/__init__.py b/src/etcd/__init__.py
index 2a5992b..b532be6 100644
--- a/src/etcd/__init__.py
+++ b/src/etcd/__init__.py
@@ -76,13 +76,14 @@ class EtcdResult(object):
#if the current result is a leaf, return itself
yield self
return
- for n in self._children:
- node = EtcdResult(None, n)
+ else:
+ # node is not a leaf
if not leaves_only:
- #Return also dirs, not just value nodes
- yield node
- for child in node.get_subtree(leaves_only=leaves_only):
- yield child
+ yield self
+ for n in self._children:
+ node = EtcdResult(None, n)
+ for child in node.get_subtree(leaves_only=leaves_only):
+ yield child
return
@property
@@ -120,7 +121,7 @@ class EtcdException(Exception):
Generic Etcd Exception.
"""
def __init__(self, message=None, payload=None):
- super(Exception, self).__init__(message)
+ super(EtcdException, self).__init__(message)
self.payload = payload
@@ -193,7 +194,10 @@ class EtcdConnectionFailed(EtcdException):
"""
Connection to etcd failed.
"""
- pass
+ def __init__(self, message=None, payload=None, cause=None):
+ super(EtcdConnectionFailed, self).__init__(message=message,
+ payload=payload)
+ self.cause = cause
class EtcdWatcherCleared(EtcdException):
diff --git a/src/etcd/client.py b/src/etcd/client.py
index fe766a5..03b0451 100644
--- a/src/etcd/client.py
+++ b/src/etcd/client.py
@@ -688,8 +688,13 @@ class Client(object):
def _result_from_response(self, response):
""" Creates an EtcdResult from json dictionary """
+ raw_response = response.data
+ try:
+ res = json.loads(raw_response.decode('utf-8'))
+ except (TypeError, ValueError, UnicodeError) as e:
+ raise etcd.EtcdException(
+ 'Server response was not valid JSON: %r' % e)
try:
- res = json.loads(response.data.decode('utf-8'))
r = etcd.EtcdResult(**res)
if response.status == 201:
r.newKey = True
@@ -697,9 +702,9 @@ class Client(object):
return r
except Exception as e:
raise etcd.EtcdException(
- 'Unable to decode server response: %s' % e)
+ 'Unable to decode server response: %r' % e)
- def _next_server(self):
+ def _next_server(self, cause=None):
""" Selects the next server in the list, refreshes the server list. """
_log.debug("Selection next machine in cache. Available machines: %s",
self._machines_cache)
@@ -707,7 +712,8 @@ class Client(object):
mach = self._machines_cache.pop()
except IndexError:
_log.error("Machines cache is empty, no machines to try.")
- raise etcd.EtcdConnectionFailed('No more machines in the cluster')
+ raise etcd.EtcdConnectionFailed('No more machines in the cluster',
+ cause=cause)
else:
_log.info("Selected new etcd server %s", mach)
return mach
@@ -752,7 +758,15 @@ class Client(object):
else:
raise etcd.EtcdException(
'HTTP method {} not supported'.format(method))
-
+
+ # Check the cluster ID hasn't changed under us. We use
+ # preload_content=False above so we can read the headers
+ # before we wait for the content of a watch.
+ self._check_cluster_id(response)
+ # Now force the data to be preloaded in order to trigger any
+ # IO-related errors in this method rather than when we try to
+ # access it later.
+ _ = response.data
# urllib3 doesn't wrap all httplib exceptions and earlier versions
# don't wrap socket errors either.
except (urllib3.exceptions.HTTPError,
@@ -765,35 +779,18 @@ class Client(object):
"server.")
# _next_server() raises EtcdException if there are no
# machines left to try, breaking out of the loop.
- self._base_uri = self._next_server()
+ self._base_uri = self._next_server(cause=e)
some_request_failed = True
else:
_log.debug("Reconnection disabled, giving up.")
raise etcd.EtcdConnectionFailed(
- "Connection to etcd failed due to %r" % e)
+ "Connection to etcd failed due to %r" % e,
+ cause=e
+ )
except:
_log.exception("Unexpected request failure, re-raising.")
raise
-
- else:
- # Check the cluster ID hasn't changed under us. We use
- # preload_content=False above so we can read the headers
- # before we wait for the content of a long poll.
- cluster_id = response.getheader("x-etcd-cluster-id")
- id_changed = (self.expected_cluster_id
- and cluster_id is not None and
- cluster_id != self.expected_cluster_id)
- # Update the ID so we only raise the exception once.
- old_expected_cluster_id = self.expected_cluster_id
- self.expected_cluster_id = cluster_id
- if id_changed:
- # Defensive: clear the pool so that we connect afresh next
- # time.
- self.http.clear()
- raise etcd.EtcdClusterIdChanged(
- 'The UUID of the cluster changed from {} to '
- '{}.'.format(old_expected_cluster_id, cluster_id))
-
+
if some_request_failed:
if not self._use_proxies:
# The cluster may have changed since last invocation
@@ -801,6 +798,24 @@ class Client(object):
self._machines_cache.remove(self._base_uri)
return self._handle_server_response(response)
+ def _check_cluster_id(self, response):
+ cluster_id = response.getheader("x-etcd-cluster-id")
+ if not cluster_id:
+ _log.warning("etcd response did not contain a cluster ID")
+ return
+ id_changed = (self.expected_cluster_id and
+ cluster_id != self.expected_cluster_id)
+ # Update the ID so we only raise the exception once.
+ old_expected_cluster_id = self.expected_cluster_id
+ self.expected_cluster_id = cluster_id
+ if id_changed:
+ # Defensive: clear the pool so that we connect afresh next
+ # time.
+ self.http.clear()
+ raise etcd.EtcdClusterIdChanged(
+ 'The UUID of the cluster changed from {} to '
+ '{}.'.format(old_expected_cluster_id, cluster_id))
+
def _handle_server_response(self, response):
""" Handles the server response """
if response.status in [200, 201]:
diff --git a/src/etcd/tests/integration/test_simple.py b/src/etcd/tests/integration/test_simple.py
index c275d6e..da0954d 100644
--- a/src/etcd/tests/integration/test_simple.py
+++ b/src/etcd/tests/integration/test_simple.py
@@ -64,6 +64,7 @@ class TestSimple(EtcdIntegrationTest):
def test_machines(self):
""" INTEGRATION: retrieve machines """
self.assertEquals(self.client.machines[0], 'http://127.0.0.1:6001')
+
def test_leader(self):
""" INTEGRATION: retrieve leader """
self.assertEquals(self.client.leader['clientURLs'], ['http://127.0.0.1:6001'])
diff --git a/src/etcd/tests/unit/test_result.py b/src/etcd/tests/unit/test_result.py
new file mode 100644
index 0000000..cb1414b
--- /dev/null
+++ b/src/etcd/tests/unit/test_result.py
@@ -0,0 +1,144 @@
+import etcd
+import unittest
+import json
+import urllib3
+
+try:
+ import mock
+except ImportError:
+ from unittest import mock
+
+class TestEtcdResult(unittest.TestCase):
+
+ def test_get_subtree_1_level(self):
+ """
+ Test get_subtree() for a read with tree 1 level deep.
+ """
+ response = {"node": {
+ 'key': "/test",
+ 'value': "hello",
+ 'expiration': None,
+ 'ttl': None,
+ 'modifiedIndex': 5,
+ 'createdIndex': 1,
+ 'newKey': False,
+ 'dir': False,
+ }}
+ result = etcd.EtcdResult(**response)
+ self.assertEqual(result.key, response["node"]["key"])
+ self.assertEqual(result.value, response["node"]["value"])
+
+ # Get subtree returns itself, whether or not leaves_only
+ subtree = list(result.get_subtree(leaves_only=True))
+ self.assertListEqual([result], subtree)
+ subtree = list(result.get_subtree(leaves_only=False))
+ self.assertListEqual([result], subtree)
+
+ def test_get_subtree_2_level(self):
+ """
+ Test get_subtree() for a read with tree 2 levels deep.
+ """
+ leaf0 = {
+ 'key': "/test/leaf0",
+ 'value': "hello1",
+ 'expiration': None,
+ 'ttl': None,
+ 'modifiedIndex': 5,
+ 'createdIndex': 1,
+ 'newKey': False,
+ 'dir': False,
+ }
+ leaf1 = {
+ 'key': "/test/leaf1",
+ 'value': "hello2",
+ 'expiration': None,
+ 'ttl': None,
+ 'modifiedIndex': 6,
+ 'createdIndex': 2,
+ 'newKey': False,
+ 'dir': False,
+ }
+ testnode = {"node": {
+ 'key': "/test/",
+ 'expiration': None,
+ 'ttl': None,
+ 'modifiedIndex': 6,
+ 'createdIndex': 2,
+ 'newKey': False,
+ 'dir': True,
+ 'nodes': [leaf0, leaf1]
+ }}
+ result = etcd.EtcdResult(**testnode)
+ self.assertEqual(result.key, "/test/")
+ self.assertTrue(result.dir)
+
+ # Get subtree returns just two leaves for leaves only.
+ subtree = list(result.get_subtree(leaves_only=True))
+ self.assertEqual(subtree[0].key, "/test/leaf0")
+ self.assertEqual(subtree[1].key, "/test/leaf1")
+ self.assertEqual(len(subtree), 2)
+
+ # Get subtree returns leaves and directory.
+ subtree = list(result.get_subtree(leaves_only=False))
+ self.assertEqual(subtree[0].key, "/test/")
+ self.assertEqual(subtree[1].key, "/test/leaf0")
+ self.assertEqual(subtree[2].key, "/test/leaf1")
+ self.assertEqual(len(subtree), 3)
+
+ def test_get_subtree_3_level(self):
+ """
+ Test get_subtree() for a read with tree 3 levels deep.
+ """
+ leaf0 = {
+ 'key': "/test/mid0/leaf0",
+ 'value': "hello1",
+ }
+ leaf1 = {
+ 'key': "/test/mid0/leaf1",
+ 'value': "hello2",
+ }
+ leaf2 = {
+ 'key': "/test/mid1/leaf2",
+ 'value': "hello1",
+ }
+ leaf3 = {
+ 'key': "/test/mid1/leaf3",
+ 'value': "hello2",
+ }
+ mid0 = {
+ 'key': "/test/mid0/",
+ 'dir': True,
+ 'nodes': [leaf0, leaf1]
+ }
+ mid1 = {
+ 'key': "/test/mid1/",
+ 'dir': True,
+ 'nodes': [leaf2, leaf3]
+ }
+ testnode = {"node": {
+ 'key': "/test/",
+ 'dir': True,
+ 'nodes': [mid0, mid1]
+ }}
+ result = etcd.EtcdResult(**testnode)
+ self.assertEqual(result.key, "/test/")
+ self.assertTrue(result.dir)
+
+ # Get subtree returns just two leaves for leaves only.
+ subtree = list(result.get_subtree(leaves_only=True))
+ self.assertEqual(subtree[0].key, "/test/mid0/leaf0")
+ self.assertEqual(subtree[1].key, "/test/mid0/leaf1")
+ self.assertEqual(subtree[2].key, "/test/mid1/leaf2")
+ self.assertEqual(subtree[3].key, "/test/mid1/leaf3")
+ self.assertEqual(len(subtree), 4)
+
+ # Get subtree returns leaves and directory.
+ subtree = list(result.get_subtree(leaves_only=False))
+ self.assertEqual(subtree[0].key, "/test/")
+ self.assertEqual(subtree[1].key, "/test/mid0/")
+ self.assertEqual(subtree[2].key, "/test/mid0/leaf0")
+ self.assertEqual(subtree[3].key, "/test/mid0/leaf1")
+ self.assertEqual(subtree[4].key, "/test/mid1/")
+ self.assertEqual(subtree[5].key, "/test/mid1/leaf2")
+ self.assertEqual(subtree[6].key, "/test/mid1/leaf3")
+ self.assertEqual(len(subtree), 7)