diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d20b64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.pyc diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..33d6d54 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Liu Xiaohui + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..b88034e --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[metadata] +description-file = README.md diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..66e216b --- /dev/null +++ b/setup.py @@ -0,0 +1,13 @@ +from distutils.core import setup +setup( + name = 'simpletcd', + packages = ['simpletcd'], + version = '0.1.0', + description = 'Simple etcd client make recursive get/put easy', + author = 'Liu Xiaohui', + author_email = 'herolxh@gmail.com', + url = 'https://github.com/oreh/simpletcd', + download_url = 'https://github.com/oreh/simpletcd/tarball/0.1.0', + keywords = ['client', 'etcd'], + classifiers = [], +) diff --git a/simpletcd/__init__.py b/simpletcd/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/simpletcd/etcd.py b/simpletcd/etcd.py new file mode 100644 index 0000000..f2b1535 --- /dev/null +++ b/simpletcd/etcd.py @@ -0,0 +1,142 @@ +import os +import json +import httplib +import urllib +import urllib2 + + +DEFAULT_ETCD_PROTOCOL = 'http' +DEFAULT_ETCD_HOST = "127.0.0.1" +DEFAULT_ETCD_PORT = 4001 + + +class EtcdOpsException(Exception): + def __init__(self, status, content): + msg = json.dumps({'status': status, 'content': content}) + super(EtcdOpsException, self).__init__(msg) + + +def parse_etcd_response(data, parent=None): + result = {} + + if parent is None: + key = data['key'].split('/')[-1] + if key == '': + key = '/' + else: + if parent == '/': + key = data['key'][1:] + else: + key = os.path.relpath(data['key'], parent) + + if 'dir' in data and data['dir']: + result[key] = {} + for n in data['nodes']: + result[key].update(parse_etcd_response(n, data['key'])) + else: + result[key] = data['value'] + + return result + + +def dict_to_records(data, parent_key='/'): + ''' ToDo: Add doc tests + ''' + + records = {} + for k, v in data.iteritems(): + key = parent_key + '/' + k + + if type(v) in (str, unicode, int, float): + records[key] = v + elif type(v) is dict: + records.update(dict_to_records(v, parent_key=key)) + else: + raise ValueError('Cannot convert type %s to etcd data' % type(v)) + return records + +class EtcdHTTPConnect(object): + + def request(self, method, uri, params=None): + method = method.upper() + + opener = urllib2.build_opener(urllib2.HTTPHandler) + if params is None: + request = urllib2.Request(uri) + else: + request = urllib2.Request(uri, data=urllib.urlencode(params)) + + if method in ('PUT', 'DELETE'): + request.get_method = lambda: method + + rep = opener.open(request) + return (rep.code, rep.read()) + + +class Etcd(): + def __init__(self, host=DEFAULT_ETCD_HOST, port=DEFAULT_ETCD_PORT, + protocol=DEFAULT_ETCD_PROTOCOL, conn_cls=EtcdHTTPConnect, **kwargs): + + self.protocol = protocol + self.host = host + self.port = port + self.conn = conn_cls(**kwargs) + + + def get_uri(self, key): + if key.startswith('/'): + key = key[1:] + return '%s://%s:%s/v2/keys/%s' % (self.protocol, self.host, self.port, key) + + + def get(self, key, raw=False): + if key.startswith('/'): + key = key[1:] + + uri = self.get_uri(key)+'?recursive=true' + status, content = self.conn.request('get', uri) + + if status == 200: + data = json.loads(content) + if raw: + return data + + node = data['node'] + if 'dir' in node and node['dir']: + return parse_etcd_response(node) + else: + key = node['key'].split('/')[-1] + if key == '': + key = '/' + return {key: node['value']} + + raise EtcdOpsException(status, content) + + + def delete(self, key): + uri = self.get_uri(key) + + status, content = self.conn.request("delete", uri+"?recursive=true") + if status == 200: + return True + raise EtcdOpsException(status, content) + + + def put(self, key, data): + if type(data) in (str, unicode, int, float): + params = {"value": data} + status, content = self.conn.request("put", self.get_uri(key), params) + if status == 200 or status == 201: + return True + raise EtcdOpsException(status, content) + elif isinstance(data, dict): + records = dict_to_records(data, parent_key=key) + + for k, v in records.iteritems(): + if not self.put(k, v): + self.delete_dir(key) + return False + return True + else: + raise ValueError('Cannot convert type %s to etcd data' % type(value)) + diff --git a/tests/etcd_test.py b/tests/etcd_test.py new file mode 100644 index 0000000..60e73be --- /dev/null +++ b/tests/etcd_test.py @@ -0,0 +1,68 @@ +import os +import unittest +import uuid +import json +import mock + +from simpletcd.etcd import EtcdHTTPConnect, Etcd + +class MockEtcdHTTPConnect(EtcdHTTPConnect): + + def __init__(self, fixture=None): + super(MockEtcdHTTPConnect, self).__init__() + + self.fixture = fixture + + + def request(self, method, uri, data=None): + data = self.fixture.get(method.lower(), {}) + r = None + for key, item in data.iteritems(): + if item['uri'] == uri: + r = json.dumps(item['response']) + break + + if r is None: + raise KeyError('Response for uri "%s" has not been defined in test fixture' % uri) + + return (200, r) + + +class EtcdTest(unittest.TestCase): + + def setUp(self): + cwd = os.path.dirname(os.path.abspath(__file__)) + fname = os.path.join(cwd, 'http_response_fixture.json') + + with open(fname) as f: + self.fixture = json.load(f) + + self.etcd = Etcd(conn_cls=MockEtcdHTTPConnect, fixture=self.fixture) + + + def tearDown(self): + pass + + + def test_get_key(self): + requests = self.fixture.get('get') + + for key, data in requests.iteritems(): + result = self.etcd.get(key) + self.assertTrue(result == data['obj']) + + + def test_get_key_raw(self): + requests = self.fixture.get('get') + + for key, data in requests.iteritems(): + result = self.etcd.get(key, raw=True) + self.assertTrue(result == data['response']) + + def test_put_key(self): + requests = self.fixture.get('put') + + for key, data in requests.iteritems(): + result = self.etcd.put(key, data['data']) + self.assertTrue(result) + diff --git a/tests/http_response_fixture.json b/tests/http_response_fixture.json new file mode 100644 index 0000000..74e1ad5 --- /dev/null +++ b/tests/http_response_fixture.json @@ -0,0 +1,70 @@ +{ + "get":{ + "key_0": { + "uri": "http://127.0.0.1:4001/v2/keys/key_0?recursive=true", + "response": { + "action": "get", + "node": { + "key": "/test", + "value": "key 0 value", + "modifiedIndex": 199, + "createdIndex":199 + } + }, + "obj": { + "test": "key 0 value" + } + }, + "test_dir": { + "uri": "http://127.0.0.1:4001/v2/keys/test_dir?recursive=true", + "response": { + "action": "get", + "node": { + "createdIndex": 200, + "dir": true, + "key": "/test_dir", + "modifiedIndex": 200, + "nodes": [ + { + "createdIndex": 200, + "key": "/test_dir/key_0", + "modifiedIndex": 200, + "value": "key 0 value" + }, + { + "createdIndex": 201, + "key": "/test_dir/key_1", + "modifiedIndex": 201, + "value": "key 1 value" + } + ] + } + }, + "obj": { + "test_dir": { + "key_0": "key 0 value", + "key_1": "key 1 value" + } + } + } + }, + "put": { + "test_key": { + "uri": "http://127.0.0.1:4001/v2/keys/test_key", + "data": "value of test_key", + "response":{ + "action": "set", + "node": { + "key": "/test_key", + "value": "'value of test_key'", + "modifiedIndex": 229, + "createdIndex":229 + } + }, + "obj": { + "test_key": "value of test_key" + } + } + }, + "delete": {} +}