From c95c0ae7fa2616e2cd04b0770b488203a334c4a9 Mon Sep 17 00:00:00 2001 From: Jose Plana Date: Sun, 8 Sep 2013 18:34:00 +0200 Subject: [PATCH] First public release --- .gitignore | 12 + LICENSE.txt | 23 ++ MANIFEST.in | 2 + NEWS.txt | 9 + README.rst | 125 ++++++++ bootstrap.py | 170 +++++++++++ buildout.cfg | 22 ++ docs-source/api.rst | 8 + docs-source/conf.py | 242 +++++++++++++++ docs-source/index.rst | 153 ++++++++++ setup.py | 35 +++ src/etcd/__init__.py | 44 +++ src/etcd/client.py | 354 +++++++++++++++++++++ src/etcd/tests/__init__.py | 1 + src/etcd/tests/integration/__init__.py | 0 src/etcd/tests/integration/helpers.py | 43 +++ src/etcd/tests/integration/test_simple.py | 285 +++++++++++++++++ src/etcd/tests/unit/__init__.py | 6 + src/etcd/tests/unit/test_client.py | 78 +++++ src/etcd/tests/unit/test_request.py | 356 ++++++++++++++++++++++ 20 files changed, 1968 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 MANIFEST.in create mode 100644 NEWS.txt create mode 100644 README.rst create mode 100644 bootstrap.py create mode 100644 buildout.cfg create mode 100644 docs-source/api.rst create mode 100644 docs-source/conf.py create mode 100644 docs-source/index.rst create mode 100644 setup.py create mode 100644 src/etcd/__init__.py create mode 100644 src/etcd/client.py create mode 100644 src/etcd/tests/__init__.py create mode 100644 src/etcd/tests/integration/__init__.py create mode 100644 src/etcd/tests/integration/helpers.py create mode 100644 src/etcd/tests/integration/test_simple.py create mode 100644 src/etcd/tests/unit/__init__.py create mode 100644 src/etcd/tests/unit/test_client.py create mode 100644 src/etcd/tests/unit/test_request.py 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")