diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..00bb6bfc --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +*.pyc + +.installed.cfg +bin +develop-eggs +eggs +*.egg-info + +tmp +build +dist +.coverage diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 00000000..09012da0 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) 2013 Jose Plana Mario + +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/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..1e7e5684 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include README.rst +include NEWS.txt diff --git a/NEWS.txt b/NEWS.txt new file mode 100644 index 00000000..01033299 --- /dev/null +++ b/NEWS.txt @@ -0,0 +1,9 @@ +News +==== + +0.1 +--- + +*Release date: 18-Sep-2013* + +* Initial release diff --git a/README.rst b/README.rst new file mode 100644 index 00000000..f4e3ef2e --- /dev/null +++ b/README.rst @@ -0,0 +1,125 @@ +python-etcd documentation +========================= + +A python client for Etcd https://github.com/coreos/etcd + +Installation +------------ + +Pre-requirements +~~~~~~~~~~~~~~~~ + +Install etcd + +From source +~~~~~~~~~~~ + +.. code:: bash + + $ python setup.py install + +Usage +----- + +Create a client object +~~~~~~~~~~~~~~~~~~~~~~ + +.. code:: python + + import etcd + + client = etcd.Client() # this will create a client against etcd server running on localhost on port 4001 + client = etcd.Client(port=4002) + client = etcd.Client(host='127.0.0.1', port=4003) + client = etcd.Client(host='127.0.0.1', port=4003, allow_redirect=False) # wont let you run sensitive commands on non-leader machines, default is true + +Set a key +~~~~~~~~~ + +.. code:: python + + client.set('/nodes/n1', 1) + # with ttl + client.set('/nodes/n2', 2, ttl=4) # sets the ttl to 4 seconds + +Get a key +~~~~~~~~~ + +.. code:: python + + client.get('/nodes/n2')['value'] + +Delete a key +~~~~~~~~~~~~ + +.. code:: python + + client.delete('/nodes/n1') + +Test and set +~~~~~~~~~~~~ + +.. code:: python + + client.test_and_set('/nodes/n2', 2, 4) # will set /nodes/n2 's value to 2 only if its previous value was 4 + +Watch a key +~~~~~~~~~~~ + +.. code:: python + + client.watch('/nodes/n1') # will wait till the key is changed, and return once its changed + +List sub keys +~~~~~~~~~~~~~ + +.. code:: python + + client.get('/nodes') + +Get machines in the cluster +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code:: python + + client.machines + +Get leader of the cluster +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code:: python + + client.leader + +Development setup +----------------- + +To create a buildout, + +.. code:: bash + + $ python bootstrap.py + $ bin/buildout + +to test you should have etcd available in your system path: + +.. code:: bash + + $ bin/test + +to generate documentation, + +.. code:: bash + + $ cd docs + $ make + +Release HOWTO +------------- + +To make a release + + 1) Update release date/version in NEWS.txt and setup.py + 2) Run 'python setup.py sdist' + 3) Test the generated source distribution in dist/ + 4) Upload to PyPI: 'python setup.py sdist register upload' diff --git a/bootstrap.py b/bootstrap.py new file mode 100644 index 00000000..1b28969a --- /dev/null +++ b/bootstrap.py @@ -0,0 +1,170 @@ +############################################################################## +# +# Copyright (c) 2006 Zope Foundation and Contributors. +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# +############################################################################## +"""Bootstrap a buildout-based project + +Simply run this script in a directory containing a buildout.cfg. +The script accepts buildout command-line options, so you can +use the -c option to specify an alternate configuration file. +""" + +import os +import shutil +import sys +import tempfile + +from optparse import OptionParser + +tmpeggs = tempfile.mkdtemp() + +usage = '''\ +[DESIRED PYTHON FOR BUILDOUT] bootstrap.py [options] + +Bootstraps a buildout-based project. + +Simply run this script in a directory containing a buildout.cfg, using the +Python that you want bin/buildout to use. + +Note that by using --find-links to point to local resources, you can keep +this script from going over the network. +''' + +parser = OptionParser(usage=usage) +parser.add_option("-v", "--version", help="use a specific zc.buildout version") + +parser.add_option("-t", "--accept-buildout-test-releases", + dest='accept_buildout_test_releases', + action="store_true", default=False, + help=("Normally, if you do not specify a --version, the " + "bootstrap script and buildout gets the newest " + "*final* versions of zc.buildout and its recipes and " + "extensions for you. If you use this flag, " + "bootstrap and buildout will get the newest releases " + "even if they are alphas or betas.")) +parser.add_option("-c", "--config-file", + help=("Specify the path to the buildout configuration " + "file to be used.")) +parser.add_option("-f", "--find-links", + help=("Specify a URL to search for buildout releases")) + + +options, args = parser.parse_args() + +###################################################################### +# load/install setuptools + +to_reload = False +try: + import pkg_resources + import setuptools +except ImportError: + ez = {} + + try: + from urllib.request import urlopen + except ImportError: + from urllib2 import urlopen + + # XXX use a more permanent ez_setup.py URL when available. + exec(urlopen('https://bitbucket.org/pypa/setuptools/raw/0.7.2/ez_setup.py' + ).read(), ez) + setup_args = dict(to_dir=tmpeggs, download_delay=0) + ez['use_setuptools'](**setup_args) + + if to_reload: + reload(pkg_resources) + import pkg_resources + # This does not (always?) update the default working set. We will + # do it. + for path in sys.path: + if path not in pkg_resources.working_set.entries: + pkg_resources.working_set.add_entry(path) + +###################################################################### +# Install buildout + +ws = pkg_resources.working_set + +cmd = [sys.executable, '-c', + 'from setuptools.command.easy_install import main; main()', + '-mZqNxd', tmpeggs] + +find_links = os.environ.get( + 'bootstrap-testing-find-links', + options.find_links or + ('http://downloads.buildout.org/' + if options.accept_buildout_test_releases else None) + ) +if find_links: + cmd.extend(['-f', find_links]) + +setuptools_path = ws.find( + pkg_resources.Requirement.parse('setuptools')).location + +requirement = 'zc.buildout' +version = options.version +if version is None and not options.accept_buildout_test_releases: + # Figure out the most recent final version of zc.buildout. + import setuptools.package_index + _final_parts = '*final-', '*final' + + def _final_version(parsed_version): + for part in parsed_version: + if (part[:1] == '*') and (part not in _final_parts): + return False + return True + index = setuptools.package_index.PackageIndex( + search_path=[setuptools_path]) + if find_links: + index.add_find_links((find_links,)) + req = pkg_resources.Requirement.parse(requirement) + if index.obtain(req) is not None: + best = [] + bestv = None + for dist in index[req.project_name]: + distv = dist.parsed_version + if _final_version(distv): + if bestv is None or distv > bestv: + best = [dist] + bestv = distv + elif distv == bestv: + best.append(dist) + if best: + best.sort() + version = best[-1].version +if version: + requirement = '=='.join((requirement, version)) +cmd.append(requirement) + +import subprocess +if subprocess.call(cmd, env=dict(os.environ, PYTHONPATH=setuptools_path)) != 0: + raise Exception( + "Failed to execute command:\n%s", + repr(cmd)[1:-1]) + +###################################################################### +# Import and run buildout + +ws.add_entry(tmpeggs) +ws.require(requirement) +import zc.buildout.buildout + +if not [a for a in args if '=' not in a]: + args.append('bootstrap') + +# if -c was provided, we push it back into args for buildout' main function +if options.config_file is not None: + args[0:0] = ['-c', options.config_file] + +zc.buildout.buildout.main(args) +shutil.rmtree(tmpeggs) diff --git a/buildout.cfg b/buildout.cfg new file mode 100644 index 00000000..0ede0a38 --- /dev/null +++ b/buildout.cfg @@ -0,0 +1,22 @@ +[buildout] +parts = python + sphinxbuilder + test +develop = . +eggs = + urllib3==1.7 + +[python] +recipe = zc.recipe.egg +interpreter = python +eggs = ${buildout:eggs} + +[test] +recipe = pbp.recipe.noserunner +eggs = ${python:eggs} + mock + +[sphinxbuilder] +recipe = collective.recipe.sphinxbuilder +source = ${buildout:directory}/docs-source +build = ${buildout:directory}/docs diff --git a/docs-source/api.rst b/docs-source/api.rst new file mode 100644 index 00000000..f0ad0d65 --- /dev/null +++ b/docs-source/api.rst @@ -0,0 +1,8 @@ +API Documentation +========================= +.. automodule:: etcd + :members: +.. autoclass:: Client + :special-members: + :members: + :exclude-members: __weakref__ diff --git a/docs-source/conf.py b/docs-source/conf.py new file mode 100644 index 00000000..8a9f3327 --- /dev/null +++ b/docs-source/conf.py @@ -0,0 +1,242 @@ +# -*- coding: utf-8 -*- +# +# python-etcd documentation build configuration file, created by +# sphinx-quickstart on Sat Sep 14 15:58:06 2013. +# +# This file is execfile()d with the current directory set to its containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys, os + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +sys.path.insert(0, os.path.abspath('../src')) + +# -- General configuration ----------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be extensions +# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = ['sphinx.ext.autodoc'] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'python-etcd' +copyright = u'2013, Jose Plana' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '0.1' +# The full version, including alpha/beta/rc tags. +release = '0.1' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + + +# -- Options for HTML output --------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'sphinxdoc' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = 'python-etcddoc' + + +# -- Options for LaTeX output -------------------------------------------------- + +latex_elements = { +# The paper size ('letterpaper' or 'a4paper'). +#'papersize': 'letterpaper', + +# The font size ('10pt', '11pt' or '12pt'). +#'pointsize': '10pt', + +# Additional stuff for the LaTeX preamble. +#'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, documentclass [howto/manual]). +latex_documents = [ + ('index', 'python-etcd.tex', u'python-etcd Documentation', + u'Jose Plana', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output -------------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'python-etcd', u'python-etcd Documentation', + [u'Jose Plana'], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------------ + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ('index', 'python-etcd', u'python-etcd Documentation', + u'Jose Plana', 'python-etcd', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' diff --git a/docs-source/index.rst b/docs-source/index.rst new file mode 100644 index 00000000..3a5488fe --- /dev/null +++ b/docs-source/index.rst @@ -0,0 +1,153 @@ +Python-etcd documentation +========================= + +A python client for Etcd https://github.com/coreos/etcd + + + + +Installation +------------ + +Pre-requirements +................ + +Install etcd + + +From source +........... + +.. code:: bash + + $ python setup.py install + + +Usage +----- + +Create a client object +...................... + +.. code:: python + + import etcd + + client = etcd.Client() # this will create a client against etcd server running on localhost on port 4001 + client = etcd.Client(port=4002) + client = etcd.Client(host='127.0.0.1', port=4003) + client = etcd.Client(host='127.0.0.1', port=4003, allow_redirect=False) # wont let you run sensitive commands on non-leader machines, default is true + + +Set a key +......... + +.. code:: python + + client.set('/nodes/n1', 1) + # with ttl + client.set('/nodes/n2', 2, ttl=4) # sets the ttl to 4 seconds + +Get a key +......... + +.. code:: python + + client.get('/nodes/n2')['value'] + + +Delete a key +............ + +.. code:: python + + client.delete('/nodes/n1') + + +Test and set +............ + +.. code:: python + + client.test_and_set('/nodes/n2', 2, 4) # will set /nodes/n2 's value to 2 only if its previous value was 4 + + +Watch a key +........... + +.. code:: python + + client.watch('/nodes/n1') # will wait till the key is changed, and return once its changed + + +List sub keys +............. + +.. code:: python + + client.get('/nodes') + + +Get machines in the cluster +........................... + +.. code:: python + + client.machines + + +Get leader of the cluster +......................... + +.. code:: python + + client.leader + + + + +Development setup +----------------- + +To create a buildout, + +.. code:: bash + + $ python bootstrap.py + $ bin/buildout + + +to test you should have etcd available in your system path: + +.. code:: bash + + $ bin/test + +to generate documentation, + +.. code:: bash + + $ cd docs + $ make + + + +Release HOWTO +------------- + +To make a release, + + 1) Update release date/version in NEWS.txt and setup.py + 2) Run 'python setup.py sdist' + 3) Test the generated source distribution in dist/ + 4) Upload to PyPI: 'python setup.py sdist register upload' + 5) Increase version in setup.py (for next release) + + +Code documentation +------------------ + +.. toctree:: + :maxdepth: 2 + + api.rst diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..9d35a9d9 --- /dev/null +++ b/setup.py @@ -0,0 +1,35 @@ +from setuptools import setup, find_packages +import sys, os + +here = os.path.abspath(os.path.dirname(__file__)) +README = open(os.path.join(here, 'README.rst')).read() +NEWS = open(os.path.join(here, 'NEWS.txt')).read() + + +version = '0.1' + +install_requires = ['urllib3==1.7'] + + +setup(name='python-etcd', + version=version, + description="A python client for etcd", + long_description=README + '\n\n' + NEWS, + classifiers=[ + "Topic :: System :: Distributed Computing", + "Topic :: Software Development :: Libraries", + "License :: OSI Approved :: MIT License", + "Topic :: Database :: Front-Ends", + ], + keywords='etcd raft distributed log api client', + author='Jose Plana', + author_email='jplana@gmail.com', + url='http://github.com/jplana/python-etcd', + license='MIT', + packages=find_packages('src'), + package_dir = {'': 'src'},include_package_data=True, + zip_safe=False, + install_requires=install_requires, + test_suite='tests.unit', + +) diff --git a/src/etcd/__init__.py b/src/etcd/__init__.py new file mode 100644 index 00000000..06baa134 --- /dev/null +++ b/src/etcd/__init__.py @@ -0,0 +1,44 @@ +import collections +from client import Client + + +class EtcdResult(collections.namedtuple( + 'EtcdResult', + [ + 'action', + 'index', + 'key', + 'prevValue', + 'value', + 'expiration', + 'ttl', + 'newKey'])): + + def __new__( + cls, + action=None, + index=None, + key=None, + prevValue=None, + value=None, + expiration=None, + ttl=None, + newKey=None): + return super(EtcdResult, cls).__new__( + cls, + action, + index, + key, + prevValue, + value, + expiration, + ttl, + newKey) + + +class EtcdException(Exception): + """ + Generic Etcd Exception. + """ + + pass diff --git a/src/etcd/client.py b/src/etcd/client.py new file mode 100644 index 00000000..c7b7a7f7 --- /dev/null +++ b/src/etcd/client.py @@ -0,0 +1,354 @@ +""" +.. module:: python-etcd + :synopsis: A python etcd client. + +.. moduleauthor:: Jose Plana + + +""" +import urllib3 +import json + +import etcd + + +class Client(object): + """ + Client for etcd, the distributed log service using raft. + """ + def __init__( + self, + host='127.0.0.1', + port=4001, + read_timeout=60, + allow_redirect=True, + protocol='http'): + """ + Initialize the client. + + Args: + host (str): IP to connect to. + + port (int): Port used to connect to etcd. + + read_timeout (int): max seconds to wait for a read. + + allow_redirect (bool): allow the client to connect to other nodes. + + protocol (str): Protocol used to connect to etcd. + + """ + self._host = host + self._port = port + self._protocol = protocol + self._base_uri = "%s://%s:%d" % (protocol, host, port) + self.version_prefix = '/v1' + + self._read_timeout = read_timeout + self._allow_redirect = allow_redirect + + self._MGET = 'GET' + self._MPOST = 'POST' + self._MDELETE = 'DELETE' + + # Dictionary of exceptions given an etcd return code. + # 100: Key not found. + # 101: The given PrevValue is not equal to the value of the key + # 102: Not a file if the /foo = Node(bar) exists, + # setting /foo/foo = Node(barbar) + # 103: Reached the max number of machines in the cluster + # 300: Raft Internal Error + # 301: During Leader Election + # 500: Watcher is cleared due to etcd recovery + self.error_codes = { + 100: KeyError, + 101: ValueError, + 102: KeyError, + 103: Exception, + 300: Exception, + 301: Exception, + 500: etcd.EtcdException, + 999: etcd.EtcdException} + + self.http = urllib3.PoolManager(10) + + @property + def base_uri(self): + """URI used by the client to connect to etcd.""" + return self._base_uri + + @property + def host(self): + """Node to connect etcd.""" + return self._host + + @property + def port(self): + """Port to connect etcd.""" + return self._port + + @property + def protocol(self): + """Protocol used to connect etcd.""" + return self._protocol + + @property + def read_timeout(self): + """Max seconds to wait for a read.""" + return self._read_timeout + + @property + def allow_redirect(self): + """Allow the client to connect to other nodes.""" + return self._allow_redirect + + @property + def machines(self): + """ + Members of the cluster. + + Returns: + list. str with all the nodes in the cluster. + + >>> print client.machines + ['http://127.0.0.1:4001', 'http://127.0.0.1:4002'] + """ + return [ + node.strip() for node in self.api_execute( + self.version_prefix + '/machines', + self._MGET).split(',') + ] + + @property + def leader(self): + """ + Returns: + str. the leader of the cluster. + + >>> print client.leader + 'http://127.0.0.1:4001' + """ + return self.api_execute( + self.version_prefix + '/leader', + self._MGET) + + @property + def key_endpoint(self): + """ + REST key endpoint. + """ + return self.version_prefix + '/keys' + + @property + def watch_endpoint(self): + """ + REST watch endpoint. + """ + + return self.version_prefix + '/watch' + + def __contains__(self, key): + """ + Check if a key is available in the cluster. + + >>> print 'key' in client + True + """ + try: + self.get(key) + return True + except KeyError: + return False + + def ethernal_watch(self, key, index=None): + """ + Generator that will yield changes from a key. + Note that this method will block forever until an event is generated. + + Args: + key (str): Key to subcribe to. + index (int): Index from where the changes will be received. + + Yields: + client.EtcdResult + + >>> for event in client.ethernal_watch('/subcription_key'): + ... print event.value + ... + value1 + value2 + + """ + local_index = index + while True: + response = self.watch(key, local_index) + if local_index is not None: + local_index += 1 + yield response + + def test_and_set(self, key, value, prev_value, ttl=None): + """ + Atomic test & set operation. + It will check if the value of 'key' is 'prev_value', + if the the check is correct will change the value for 'key' to 'value' + if the the check is false an exception will be raised. + + Args: + key (str): Key. + value (object): value to set + prev_value (object): previous value. + ttl (int): Time in seconds of expiration (optional). + + Returns: + client.EtcdResult + + Raises: + ValueError: When the 'prev_value' is not the current value. + + >>> print client.test_and_set('/key', 'new', 'old', ttl=60).value + 'new' + + """ + path = self.key_endpoint + key + payload = {'value': value, 'prevValue': prev_value} + if ttl: + payload['ttl'] = ttl + response = self.api_execute(path, self._MPOST, payload) + return self._result_from_response(response) + + def set(self, key, value, ttl=None): + """ + Set value for a key. + + Args: + key (str): Key. + + value (object): value to set + + ttl (int): Time in seconds of expiration (optional). + + Returns: + client.EtcdResult + + >>> print client.set('/key', 'newValue', ttl=60).value + 'newValue' + + """ + + path = self.key_endpoint + key + payload = {'value': value} + if ttl: + payload['ttl'] = ttl + response = self.api_execute(path, self._MPOST, payload) + return self._result_from_response(response) + + def delete(self, key): + """ + Removed a key from etcd. + + Args: + key (str): Key. + + Returns: + client.EtcdResult + + Raises: + KeyValue: If the key doesn't exists. + + >>> print client.delete('/key').key + '/key' + + """ + + response = self.api_execute(self.key_endpoint + key, self._MDELETE) + return self._result_from_response(response) + + def get(self, key): + """ + Returns the value of the key 'key'. + + Args: + key (str): Key. + + Returns: + client.EtcdResult + + Raises: + KeyValue: If the key doesn't exists. + + >>> print client.get('/key').value + 'value' + + """ + + response = self.api_execute(self.key_endpoint + key, self._MGET) + return self._result_from_response(response) + + def watch(self, key, index=None): + """ + Blocks until a new event has been received, starting at index 'index' + + Args: + key (str): Key. + + index (int): Index to start from. + + Returns: + client.EtcdResult + + Raises: + KeyValue: If the key doesn't exists. + + >>> print client.watch('/key').value + 'value' + + """ + + params = None + method = self._MGET + if index: + params = {'index': index} + method = self._MPOST + + response = self.api_execute( + self.watch_endpoint + key, + method, + params=params) + return self._result_from_response(response) + + def _result_from_response(self, response): + """ Creates an EtcdResult from json dictionary """ + try: + return etcd.EtcdResult(**json.loads(response)) + except: + raise etcd.EtcdException('Unable to decode server response') + + def api_execute(self, path, method, params=None): + """ Executes the query. """ + if (method == self._MGET) or (method == self._MDELETE): + response = self.http.request( + method, + self._base_uri + path, + fields=params, + redirect=self.allow_redirect) + + elif method == self._MPOST: + response = self.http.request_encode_body( + method, + self._base_uri+path, + fields=params, + encode_multipart=False, + redirect=self.allow_redirect) + + if response.status == 200: + return response.data + else: + try: + error = json.loads(response.data) + message = "%s : %s" % (error['message'], error['cause']) + error_code = error['errorCode'] + error_exception = self.error_codes[error_code] + except: + message = "Unable to decode server response" + error_exception = etcd.EtcdException + raise error_exception(message) diff --git a/src/etcd/tests/__init__.py b/src/etcd/tests/__init__.py new file mode 100644 index 00000000..69a51c2f --- /dev/null +++ b/src/etcd/tests/__init__.py @@ -0,0 +1 @@ +import unit diff --git a/src/etcd/tests/integration/__init__.py b/src/etcd/tests/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/etcd/tests/integration/helpers.py b/src/etcd/tests/integration/helpers.py new file mode 100644 index 00000000..fff06579 --- /dev/null +++ b/src/etcd/tests/integration/helpers.py @@ -0,0 +1,43 @@ +import shutil +import subprocess +import tempfile +import logging +import time + + +class EtcdProcessHelper(object): + def __init__( + self, + proc_name='etcd', + port_range_start=4001, + internal_port_range_start=7001): + self.proc_name = proc_name + self.port_range_start = port_range_start + self.internal_port_range_start = internal_port_range_start + self.processes = [] + + def run(self, number=1): + log = logging.getLogger() + for i in range(0, number): + directory = tempfile.mkdtemp(prefix='python-etcd.%d' % i) + log.debug('Created directory %s' % directory) + daemon_args = [ + self.proc_name, + '-d', directory, + '-n', 'test-node-%d' % i, + '-s', '127.0.0.1:%d' % (self.internal_port_range_start + i), + '-c', '127.0.0.1:%d' % (self.port_range_start + i), + ] + daemon = subprocess.Popen(daemon_args) + log.debug('Started %d' % daemon.pid) + time.sleep(2) + self.processes.append((directory, daemon)) + + def stop(self): + log = logging.getLogger() + for directory, process in self.processes: + process.kill() + time.sleep(2) + log.debug('Killed etcd pid:%d' % process.pid) + shutil.rmtree(directory) + log.debug('Removed directory %s' % directory) diff --git a/src/etcd/tests/integration/test_simple.py b/src/etcd/tests/integration/test_simple.py new file mode 100644 index 00000000..a9938d3c --- /dev/null +++ b/src/etcd/tests/integration/test_simple.py @@ -0,0 +1,285 @@ +import os +import time +import logging +import unittest +import multiprocessing + +import etcd +import helpers + +from nose.tools import nottest + +log = logging.getLogger() + + +class EtcdIntegrationTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + program = cls._get_exe() + + cls.processHelper = helpers.EtcdProcessHelper( + proc_name=program, + port_range_start=6001, + internal_port_range_start=8001) + cls.processHelper.run(number=3) + cls.client = etcd.Client(port=6001) + + @classmethod + def tearDownClass(cls): + cls.processHelper.stop() + + @classmethod + def _is_exe(cls, fpath): + return os.path.isfile(fpath) and os.access(fpath, os.X_OK) + + @classmethod + def _get_exe(cls): + PROGRAM = 'etcd' + + program_path = None + + for path in os.environ["PATH"].split(os.pathsep): + path = path.strip('"') + exe_file = os.path.join(path, PROGRAM) + if cls._is_exe(exe_file): + program_path = exe_file + break + + if not program_path: + raise Exception('etcd not in path!!') + + return program_path + + +class TestSimple(EtcdIntegrationTest): + + def test_machines(self): + """ INTEGRATION: retrieve machines """ + self.assertEquals(self.client.machines, ['http://127.0.0.1:6001']) + + def test_leader(self): + """ INTEGRATION: retrieve leader """ + self.assertEquals(self.client.leader, 'http://127.0.0.1:8001') + + def test_get_set_delete(self): + """ INTEGRATION: set a new value """ + try: + get_result = self.client.get('/test_set') + assert False + except KeyError, e: + pass + + self.assertNotIn('/test_set', self.client) + + set_result = self.client.set('/test_set', 'test-key') + self.assertEquals('SET', set_result.action) + self.assertEquals('/test_set', set_result.key) + self.assertEquals(True, set_result.newKey) + self.assertEquals('test-key', set_result.value) + + self.assertIn('/test_set', self.client) + + get_result = self.client.get('/test_set') + self.assertEquals('GET', get_result.action) + self.assertEquals('/test_set', get_result.key) + self.assertEquals('test-key', get_result.value) + + delete_result = self.client.delete('/test_set') + self.assertEquals('DELETE', delete_result.action) + self.assertEquals('/test_set', delete_result.key) + self.assertEquals('test-key', delete_result.prevValue) + + self.assertNotIn('/test_set', self.client) + + try: + get_result = self.client.get('/test_set') + assert False + except KeyError, e: + pass + + +class TestErrors(EtcdIntegrationTest): + + def test_is_not_a_file(self): + """ INTEGRATION: try to write value to a directory """ + + set_result = self.client.set('/directory/test-key', 'test-value') + + try: + get_result = self.client.set('/directory', 'test-value') + assert False + except KeyError, e: + pass + + def test_test_and_set(self): + """ INTEGRATION: try test_and_set operation """ + + set_result = self.client.set('/test-key', 'old-test-value') + + set_result = self.client.test_and_set( + '/test-key', + 'test-value', + 'old-test-value') + + try: + set_result = self.client.test_and_set( + '/test-key', + 'new-value', + 'old-test-value') + + assert False + except ValueError, e: + pass + + +class TestWatch(EtcdIntegrationTest): + + def test_watch(self): + """ INTEGRATION: Receive a watch event from other process """ + + set_result = self.client.set('/test-key', 'test-value') + + queue = multiprocessing.Queue() + + def change_value(key, newValue): + c = etcd.Client(port=6001) + c.set(key, newValue) + + def watch_value(key, queue): + c = etcd.Client(port=6001) + queue.put(c.watch(key).value) + + changer = multiprocessing.Process( + target=change_value, args=('/test-key', 'new-test-value',)) + + watcher = multiprocessing.Process( + target=watch_value, args=('/test-key', queue)) + + watcher.start() + time.sleep(1) + + changer.start() + + value = queue.get(timeout=2) + watcher.join(timeout=5) + changer.join(timeout=5) + + assert value == 'new-test-value' + + def test_watch_indexed(self): + """ INTEGRATION: Receive a watch event from other process, indexed """ + + set_result = self.client.set('/test-key', 'test-value') + set_result = self.client.set('/test-key', 'test-value0') + original_index = int(set_result.index) + set_result = self.client.set('/test-key', 'test-value1') + set_result = self.client.set('/test-key', 'test-value2') + + queue = multiprocessing.Queue() + + def change_value(key, newValue): + c = etcd.Client(port=6001) + c.set(key, newValue) + c.get(key) + + def watch_value(key, index, queue): + c = etcd.Client(port=6001) + for i in range(0, 3): + queue.put(c.watch(key, index=index+i).value) + + proc = multiprocessing.Process( + target=change_value, args=('/test-key', 'test-value3',)) + + watcher = multiprocessing.Process( + target=watch_value, args=('/test-key', original_index, queue)) + + watcher.start() + time.sleep(0.5) + + proc.start() + + for i in range(0, 3): + value = queue.get() + log.debug("index: %d: %s" % (i, value)) + self.assertEquals('test-value%d' % i, value) + + watcher.join(timeout=5) + proc.join(timeout=5) + + def test_watch_generator(self): + """ INTEGRATION: Receive a watch event from other process (gen) """ + + set_result = self.client.set('/test-key', 'test-value') + + queue = multiprocessing.Queue() + + def change_value(key): + time.sleep(0.5) + c = etcd.Client(port=6001) + for i in range(0, 3): + c.set(key, 'test-value%d' % i) + c.get(key) + + def watch_value(key, queue): + c = etcd.Client(port=6001) + for i in range(0, 3): + event = c.ethernal_watch(key).next().value + queue.put(event) + + changer = multiprocessing.Process( + target=change_value, args=('/test-key',)) + + watcher = multiprocessing.Process( + target=watch_value, args=('/test-key', queue)) + + watcher.start() + changer.start() + + values = ['test-value0', 'test-value1', 'test-value2'] + for i in range(0, 1): + value = queue.get() + log.debug("index: %d: %s" % (i, value)) + self.assertIn(value, values) + + watcher.join(timeout=5) + changer.join(timeout=5) + + def test_watch_indexed_generator(self): + """ INTEGRATION: Receive a watch event from other process, ixd, (2) """ + + set_result = self.client.set('/test-key', 'test-value') + set_result = self.client.set('/test-key', 'test-value0') + original_index = int(set_result.index) + set_result = self.client.set('/test-key', 'test-value1') + set_result = self.client.set('/test-key', 'test-value2') + + queue = multiprocessing.Queue() + + def change_value(key, newValue): + c = etcd.Client(port=6001) + c.set(key, newValue) + + def watch_value(key, index, queue): + c = etcd.Client(port=6001) + iterevents = c.ethernal_watch(key, index=index) + for i in range(0, 3): + queue.put(iterevents.next().value) + + proc = multiprocessing.Process( + target=change_value, args=('/test-key', 'test-value3',)) + + watcher = multiprocessing.Process( + target=watch_value, args=('/test-key', original_index, queue)) + + watcher.start() + time.sleep(0.5) + proc.start() + + for i in range(0, 3): + value = queue.get() + log.debug("index: %d: %s" % (i, value)) + self.assertEquals('test-value%d' % i, value) + + watcher.join(timeout=5) + proc.join(timeout=5) diff --git a/src/etcd/tests/unit/__init__.py b/src/etcd/tests/unit/__init__.py new file mode 100644 index 00000000..8a7a9cb6 --- /dev/null +++ b/src/etcd/tests/unit/__init__.py @@ -0,0 +1,6 @@ +import test_client +import test_request + + +def test_suite(): + return unittest.makeSuite([test_client.TestClient]) diff --git a/src/etcd/tests/unit/test_client.py b/src/etcd/tests/unit/test_client.py new file mode 100644 index 00000000..298e2c75 --- /dev/null +++ b/src/etcd/tests/unit/test_client.py @@ -0,0 +1,78 @@ +import unittest +import etcd + + +class TestClient(unittest.TestCase): + + def test_instantiate(self): + """ client can be instantiated""" + client = etcd.Client() + assert client is not None + + def test_default_host(self): + """ default host is 127.0.0.1""" + client = etcd.Client() + assert client.host == "127.0.0.1" + + def test_default_port(self): + """ default port is 4001""" + client = etcd.Client() + assert client.port == 4001 + + def test_default_protocol(self): + """ default protocol is http""" + client = etcd.Client() + assert client.port == 'http' + + def test_default_protocol(self): + """ default protocol is http""" + client = etcd.Client() + assert client.protocol == 'http' + + def test_default_read_timeout(self): + """ default read_timeout is 60""" + client = etcd.Client() + assert client.read_timeout == 60 + + def test_default_allow_redirect(self): + """ default allow_redirect is True""" + client = etcd.Client() + assert client.allow_redirect + + def test_set_host(self): + """ can change host """ + client = etcd.Client(host='192.168.1.1') + assert client.host == '192.168.1.1' + + def test_set_port(self): + """ can change port """ + client = etcd.Client(port=4002) + assert client.port == 4002 + + def test_set_protocol(self): + """ can change protocol """ + client = etcd.Client(protocol='https') + assert client.protocol == 'https' + + def test_set_read_timeout(self): + """ can set read_timeout """ + client = etcd.Client(read_timeout=45) + assert client.read_timeout == 45 + + def test_set_allow_redirect(self): + """ can change allow_redirect """ + client = etcd.Client(allow_redirect=False) + assert not client.allow_redirect + + def test_default_base_uri(self): + """ default uri is http://127.0.0.1:4001 """ + client = etcd.Client() + assert client.base_uri == 'http://127.0.0.1:4001' + + def test_set_base_uri(self): + """ can change base uri """ + client = etcd.Client( + host='192.168.1.1', + port=4003, + protocol='https') + assert client.base_uri == 'https://192.168.1.1:4003' diff --git a/src/etcd/tests/unit/test_request.py b/src/etcd/tests/unit/test_request.py new file mode 100644 index 00000000..f0f610e1 --- /dev/null +++ b/src/etcd/tests/unit/test_request.py @@ -0,0 +1,356 @@ +import etcd +import unittest +import mock + +from etcd import EtcdException + + +class TestClientRequest(unittest.TestCase): + + def test_machines(self): + """ Can request machines """ + client = etcd.Client() + client.api_execute = mock.Mock( + return_value= + "http://127.0.0.1:4002," + " http://127.0.0.1:4001," + " http://127.0.0.1:4003," + " http://127.0.0.1:4001" + ) + + assert client.machines == [ + 'http://127.0.0.1:4002', + 'http://127.0.0.1:4001', + 'http://127.0.0.1:4003', + 'http://127.0.0.1:4001' + ] + + def test_leader(self): + """ Can request the leader """ + client = etcd.Client() + client.api_execute = mock.Mock(return_value="http://127.0.0.1:7002") + result = client.leader + self.assertEquals('http://127.0.0.1:7002', result) + + def test_set(self): + """ Can set a value """ + client = etcd.Client() + client.api_execute = mock.Mock( + return_value= + '{"action":"SET",' + '"key":"/testkey",' + '"value":"test",' + '"newKey":true,' + '"expiration":"2013-09-14T00:56:59.316195568+02:00",' + '"ttl":19,"index":183}') + + result = client.set('/testkey', 'test', ttl=19) + + self.assertEquals( + etcd.EtcdResult( + **{u'action': u'SET', + u'expiration': u'2013-09-14T00:56:59.316195568+02:00', + u'index': 183, + u'key': u'/testkey', + u'newKey': True, + u'ttl': 19, + u'value': u'test'}), result) + + def test_test_and_set(self): + """ Can test and set a value """ + client = etcd.Client() + client.api_execute = mock.Mock( + return_value= + '{"action":"SET",' + '"key":"/testkey",' + '"prevValue":"test",' + '"value":"newvalue",' + '"expiration":"2013-09-14T02:09:44.24390976+02:00",' + '"ttl":49,"index":203}') + + result = client.test_and_set('/testkey', 'newvalue', 'test', ttl=19) + self.assertEquals( + etcd.EtcdResult( + **{u'action': u'SET', + u'expiration': u'2013-09-14T02:09:44.24390976+02:00', + u'index': 203, + u'key': u'/testkey', + u'prevValue': u'test', + u'ttl': 49, + u'value': u'newvalue'}), result) + + def test_test_and_test_failure(self): + """ Exception will be raised if prevValue != value in test_set """ + + client = etcd.Client() + client.api_execute = mock.Mock( + side_effect=ValueError( + 'The given PrevValue is not equal' + ' to the value of the key : TestAndSet: 1!=3')) + try: + result = client.test_and_set( + '/testkey', + 'newvalue', + 'test', ttl=19) + except ValueError, e: + #from ipdb import set_trace; set_trace() + self.assertEquals( + 'The given PrevValue is not equal' + ' to the value of the key : TestAndSet: 1!=3', e.message) + + def test_delete(self): + """ Can delete a value """ + client = etcd.Client() + client.api_execute = mock.Mock( + return_value= + '{"action":"DELETE",' + '"key":"/testkey",' + '"prevValue":"test",' + '"expiration":"2013-09-14T01:06:35.5242587+02:00",' + '"index":189}') + + result = client.delete('/testkey') + self.assertEquals(etcd.EtcdResult( + **{u'action': u'DELETE', + u'expiration': u'2013-09-14T01:06:35.5242587+02:00', + u'index': 189, + u'key': u'/testkey', + u'prevValue': u'test'}), result) + + def test_get(self): + """ Can get a value """ + client = etcd.Client() + client.api_execute = mock.Mock( + return_value= + '{"action":"GET",' + '"key":"/testkey",' + '"value":"test",' + '"index":190}') + + result = client.get('/testkey') + self.assertEquals(etcd.EtcdResult( + **{u'action': u'GET', + u'index': 190, + u'key': u'/testkey', + u'value': u'test'}), result) + + def test_not_in(self): + """ Can check if key is not in client """ + client = etcd.Client() + client.get = mock.Mock(side_effect=KeyError()) + result = '/testkey' not in client + self.assertEquals(True, result) + + def test_in(self): + """ Can check if key is in client """ + client = etcd.Client() + client.api_execute = mock.Mock( + return_value= + '{"action":"GET",' + '"key":"/testkey",' + '"value":"test",' + '"index":190}') + result = '/testkey' in client + + self.assertEquals(True, result) + + def test_simple_watch(self): + """ Can watch values """ + client = etcd.Client() + client.api_execute = mock.Mock( + return_value= + '{"action":"SET",' + '"key":"/testkey",' + '"value":"test",' + '"newKey":true,' + '"expiration":"2013-09-14T01:35:07.623681365+02:00",' + '"ttl":19,' + '"index":192}') + result = client.watch('/testkey') + self.assertEquals( + etcd.EtcdResult( + **{u'action': u'SET', + u'expiration': u'2013-09-14T01:35:07.623681365+02:00', + u'index': 192, + u'key': u'/testkey', + u'newKey': True, + u'ttl': 19, + u'value': u'test'}), result) + + def test_index_watch(self): + """ Can watch values from index """ + client = etcd.Client() + client.api_execute = mock.Mock( + return_value= + '{"action":"SET",' + '"key":"/testkey",' + '"value":"test",' + '"newKey":true,' + '"expiration":"2013-09-14T01:35:07.623681365+02:00",' + '"ttl":19,' + '"index":180}') + result = client.watch('/testkey', index=180) + self.assertEquals( + etcd.EtcdResult( + **{u'action': u'SET', + u'expiration': u'2013-09-14T01:35:07.623681365+02:00', + u'index': 180, + u'key': u'/testkey', + u'newKey': True, + u'ttl': 19, + u'value': u'test'}), result) + + +class TestEventGenerator(object): + def check_watch(self, result): + assert etcd.EtcdResult( + **{u'action': u'SET', + u'expiration': u'2013-09-14T01:35:07.623681365+02:00', + u'index': 180, + u'key': u'/testkey', + u'newKey': True, + u'ttl': 19, + u'value': u'test'}) == result + + def test_ethernal_watch(self): + """ Can watch values from generator """ + client = etcd.Client() + client.api_execute = mock.Mock( + return_value= + '{"action":"SET",' + '"key":"/testkey",' + '"value":"test",' + '"newKey":true,' + '"expiration":"2013-09-14T01:35:07.623681365+02:00",' + '"ttl":19,' + '"index":180}') + for result in range(1, 5): + result = client.ethernal_watch('/testkey', index=180).next() + yield self.check_watch, result + + +class FakeHTTPResponse(object): + def __init__(self, status, data=''): + self.status = status + self.data = data + + +class TestClientApiExecutor(unittest.TestCase): + + def test_get(self): + """ http get request """ + client = etcd.Client() + response = FakeHTTPResponse(status=200, data='arbitrary json data') + client.http.request = mock.Mock(return_value=response) + result = client.api_execute('/v1/keys/testkey', client._MGET) + self.assertEquals('arbitrary json data', result) + + def test_delete(self): + """ http delete request """ + client = etcd.Client() + response = FakeHTTPResponse(status=200, data='arbitrary json data') + client.http.request = mock.Mock(return_value=response) + result = client.api_execute('/v1/keys/testkey', client._MDELETE) + self.assertEquals('arbitrary json data', result) + + def test_get_error(self): + """ http get error request 101""" + client = etcd.Client() + response = FakeHTTPResponse(status=400, + data='{"message": "message",' + ' "cause": "cause",' + ' "errorCode": 100}') + client.http.request = mock.Mock(return_value=response) + try: + client.api_execute('v1/keys/testkey', client._MGET) + assert False + except KeyError, e: + self.assertEquals(e.message, "message : cause") + + def test_post(self): + """ http post request """ + client = etcd.Client() + response = FakeHTTPResponse(status=200, data='arbitrary json data') + client.http.request_encode_body = mock.Mock(return_value=response) + result = client.api_execute('v1/keys/testkey', client._MPOST) + self.assertEquals('arbitrary json data', result) + + def test_test_and_set_error(self): + """ http post error request 101 """ + client = etcd.Client() + response = FakeHTTPResponse( + status=400, + data='{"message": "message", "cause": "cause", "errorCode": 101}') + client.http.request_encode_body = mock.Mock(return_value=response) + payload = {'value': 'value', 'prevValue': 'oldValue', 'ttl': '60'} + try: + client.api_execute('v1/keys/testkey', client._MPOST, payload) + self.fail() + except ValueError, e: + self.assertEquals('message : cause', e.message) + + def test_set_error(self): + """ http post error request 102 """ + client = etcd.Client() + response = FakeHTTPResponse( + status=400, + data='{"message": "message", "cause": "cause", "errorCode": 102}') + client.http.request_encode_body = mock.Mock(return_value=response) + payload = {'value': 'value', 'prevValue': 'oldValue', 'ttl': '60'} + try: + client.api_execute('v1/keys/testkey', client._MPOST, payload) + self.fail() + except KeyError, e: + self.assertEquals('message : cause', e.message) + + def test_set_error(self): + """ http post error request 102 """ + client = etcd.Client() + response = FakeHTTPResponse( + status=400, + data='{"message": "message", "cause": "cause", "errorCode": 102}') + client.http.request_encode_body = mock.Mock(return_value=response) + payload = {'value': 'value', 'prevValue': 'oldValue', 'ttl': '60'} + try: + client.api_execute('v1/keys/testkey', client._MPOST, payload) + self.fail() + except KeyError, e: + self.assertEquals('message : cause', e.message) + + def test_get_error_unknown(self): + """ http get error request unknown """ + client = etcd.Client() + response = FakeHTTPResponse(status=400, + data='{"message": "message",' + ' "cause": "cause",' + ' "errorCode": 42}') + client.http.request = mock.Mock(return_value=response) + try: + client.api_execute('v1/keys/testkey', client._MGET) + assert False + except EtcdException, e: + self.assertEquals(e.message, "Unable to decode server response") + + def test_get_error_request_invalid(self): + """ http get error request invalid """ + client = etcd.Client() + response = FakeHTTPResponse(status=200, + data='{){){)*garbage*') + client.http.request = mock.Mock(return_value=response) + try: + client.get('/testkey') + assert False + except EtcdException, e: + self.assertEquals(e.message, "Unable to decode server response") + + def test_get_error_invalid(self): + """ http get error request invalid """ + client = etcd.Client() + response = FakeHTTPResponse(status=400, + data='{){){)*garbage*') + client.http.request = mock.Mock(return_value=response) + try: + client.api_execute('v1/keys/testkey', client._MGET) + assert False + except EtcdException, e: + self.assertEquals(e.message, "Unable to decode server response")