Skip to content

Commit c95c0ae

Browse files
committedSep 17, 2013
First public release
0 parents  commit c95c0ae

20 files changed

+1968
-0
lines changed
 

‎.gitignore

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
*.pyc
2+
3+
.installed.cfg
4+
bin
5+
develop-eggs
6+
eggs
7+
*.egg-info
8+
9+
tmp
10+
build
11+
dist
12+
.coverage

‎LICENSE.txt

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
The MIT License (MIT)
2+
3+
Copyright (c) 2013 Jose Plana Mario
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy of
6+
this software and associated documentation files (the "Software"), to deal in
7+
the Software without restriction, including without limitation the rights to
8+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9+
the Software, and to permit persons to whom the Software is furnished to do so,
10+
subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21+
22+
23+

‎MANIFEST.in

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
include README.rst
2+
include NEWS.txt

‎NEWS.txt

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
News
2+
====
3+
4+
0.1
5+
---
6+
7+
*Release date: 18-Sep-2013*
8+
9+
* Initial release

‎README.rst

+125
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
python-etcd documentation
2+
=========================
3+
4+
A python client for Etcd https://github.com/coreos/etcd
5+
6+
Installation
7+
------------
8+
9+
Pre-requirements
10+
~~~~~~~~~~~~~~~~
11+
12+
Install etcd
13+
14+
From source
15+
~~~~~~~~~~~
16+
17+
.. code:: bash
18+
19+
$ python setup.py install
20+
21+
Usage
22+
-----
23+
24+
Create a client object
25+
~~~~~~~~~~~~~~~~~~~~~~
26+
27+
.. code:: python
28+
29+
import etcd
30+
31+
client = etcd.Client() # this will create a client against etcd server running on localhost on port 4001
32+
client = etcd.Client(port=4002)
33+
client = etcd.Client(host='127.0.0.1', port=4003)
34+
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
35+
36+
Set a key
37+
~~~~~~~~~
38+
39+
.. code:: python
40+
41+
client.set('/nodes/n1', 1)
42+
# with ttl
43+
client.set('/nodes/n2', 2, ttl=4) # sets the ttl to 4 seconds
44+
45+
Get a key
46+
~~~~~~~~~
47+
48+
.. code:: python
49+
50+
client.get('/nodes/n2')['value']
51+
52+
Delete a key
53+
~~~~~~~~~~~~
54+
55+
.. code:: python
56+
57+
client.delete('/nodes/n1')
58+
59+
Test and set
60+
~~~~~~~~~~~~
61+
62+
.. code:: python
63+
64+
client.test_and_set('/nodes/n2', 2, 4) # will set /nodes/n2 's value to 2 only if its previous value was 4
65+
66+
Watch a key
67+
~~~~~~~~~~~
68+
69+
.. code:: python
70+
71+
client.watch('/nodes/n1') # will wait till the key is changed, and return once its changed
72+
73+
List sub keys
74+
~~~~~~~~~~~~~
75+
76+
.. code:: python
77+
78+
client.get('/nodes')
79+
80+
Get machines in the cluster
81+
~~~~~~~~~~~~~~~~~~~~~~~~~~~
82+
83+
.. code:: python
84+
85+
client.machines
86+
87+
Get leader of the cluster
88+
~~~~~~~~~~~~~~~~~~~~~~~~~
89+
90+
.. code:: python
91+
92+
client.leader
93+
94+
Development setup
95+
-----------------
96+
97+
To create a buildout,
98+
99+
.. code:: bash
100+
101+
$ python bootstrap.py
102+
$ bin/buildout
103+
104+
to test you should have etcd available in your system path:
105+
106+
.. code:: bash
107+
108+
$ bin/test
109+
110+
to generate documentation,
111+
112+
.. code:: bash
113+
114+
$ cd docs
115+
$ make
116+
117+
Release HOWTO
118+
-------------
119+
120+
To make a release
121+
122+
1) Update release date/version in NEWS.txt and setup.py
123+
2) Run 'python setup.py sdist'
124+
3) Test the generated source distribution in dist/
125+
4) Upload to PyPI: 'python setup.py sdist register upload'

‎bootstrap.py

+170
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
##############################################################################
2+
#
3+
# Copyright (c) 2006 Zope Foundation and Contributors.
4+
# All Rights Reserved.
5+
#
6+
# This software is subject to the provisions of the Zope Public License,
7+
# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution.
8+
# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
9+
# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
10+
# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
11+
# FOR A PARTICULAR PURPOSE.
12+
#
13+
##############################################################################
14+
"""Bootstrap a buildout-based project
15+
16+
Simply run this script in a directory containing a buildout.cfg.
17+
The script accepts buildout command-line options, so you can
18+
use the -c option to specify an alternate configuration file.
19+
"""
20+
21+
import os
22+
import shutil
23+
import sys
24+
import tempfile
25+
26+
from optparse import OptionParser
27+
28+
tmpeggs = tempfile.mkdtemp()
29+
30+
usage = '''\
31+
[DESIRED PYTHON FOR BUILDOUT] bootstrap.py [options]
32+
33+
Bootstraps a buildout-based project.
34+
35+
Simply run this script in a directory containing a buildout.cfg, using the
36+
Python that you want bin/buildout to use.
37+
38+
Note that by using --find-links to point to local resources, you can keep
39+
this script from going over the network.
40+
'''
41+
42+
parser = OptionParser(usage=usage)
43+
parser.add_option("-v", "--version", help="use a specific zc.buildout version")
44+
45+
parser.add_option("-t", "--accept-buildout-test-releases",
46+
dest='accept_buildout_test_releases',
47+
action="store_true", default=False,
48+
help=("Normally, if you do not specify a --version, the "
49+
"bootstrap script and buildout gets the newest "
50+
"*final* versions of zc.buildout and its recipes and "
51+
"extensions for you. If you use this flag, "
52+
"bootstrap and buildout will get the newest releases "
53+
"even if they are alphas or betas."))
54+
parser.add_option("-c", "--config-file",
55+
help=("Specify the path to the buildout configuration "
56+
"file to be used."))
57+
parser.add_option("-f", "--find-links",
58+
help=("Specify a URL to search for buildout releases"))
59+
60+
61+
options, args = parser.parse_args()
62+
63+
######################################################################
64+
# load/install setuptools
65+
66+
to_reload = False
67+
try:
68+
import pkg_resources
69+
import setuptools
70+
except ImportError:
71+
ez = {}
72+
73+
try:
74+
from urllib.request import urlopen
75+
except ImportError:
76+
from urllib2 import urlopen
77+
78+
# XXX use a more permanent ez_setup.py URL when available.
79+
exec(urlopen('https://bitbucket.org/pypa/setuptools/raw/0.7.2/ez_setup.py'
80+
).read(), ez)
81+
setup_args = dict(to_dir=tmpeggs, download_delay=0)
82+
ez['use_setuptools'](**setup_args)
83+
84+
if to_reload:
85+
reload(pkg_resources)
86+
import pkg_resources
87+
# This does not (always?) update the default working set. We will
88+
# do it.
89+
for path in sys.path:
90+
if path not in pkg_resources.working_set.entries:
91+
pkg_resources.working_set.add_entry(path)
92+
93+
######################################################################
94+
# Install buildout
95+
96+
ws = pkg_resources.working_set
97+
98+
cmd = [sys.executable, '-c',
99+
'from setuptools.command.easy_install import main; main()',
100+
'-mZqNxd', tmpeggs]
101+
102+
find_links = os.environ.get(
103+
'bootstrap-testing-find-links',
104+
options.find_links or
105+
('http://downloads.buildout.org/'
106+
if options.accept_buildout_test_releases else None)
107+
)
108+
if find_links:
109+
cmd.extend(['-f', find_links])
110+
111+
setuptools_path = ws.find(
112+
pkg_resources.Requirement.parse('setuptools')).location
113+
114+
requirement = 'zc.buildout'
115+
version = options.version
116+
if version is None and not options.accept_buildout_test_releases:
117+
# Figure out the most recent final version of zc.buildout.
118+
import setuptools.package_index
119+
_final_parts = '*final-', '*final'
120+
121+
def _final_version(parsed_version):
122+
for part in parsed_version:
123+
if (part[:1] == '*') and (part not in _final_parts):
124+
return False
125+
return True
126+
index = setuptools.package_index.PackageIndex(
127+
search_path=[setuptools_path])
128+
if find_links:
129+
index.add_find_links((find_links,))
130+
req = pkg_resources.Requirement.parse(requirement)
131+
if index.obtain(req) is not None:
132+
best = []
133+
bestv = None
134+
for dist in index[req.project_name]:
135+
distv = dist.parsed_version
136+
if _final_version(distv):
137+
if bestv is None or distv > bestv:
138+
best = [dist]
139+
bestv = distv
140+
elif distv == bestv:
141+
best.append(dist)
142+
if best:
143+
best.sort()
144+
version = best[-1].version
145+
if version:
146+
requirement = '=='.join((requirement, version))
147+
cmd.append(requirement)
148+
149+
import subprocess
150+
if subprocess.call(cmd, env=dict(os.environ, PYTHONPATH=setuptools_path)) != 0:
151+
raise Exception(
152+
"Failed to execute command:\n%s",
153+
repr(cmd)[1:-1])
154+
155+
######################################################################
156+
# Import and run buildout
157+
158+
ws.add_entry(tmpeggs)
159+
ws.require(requirement)
160+
import zc.buildout.buildout
161+
162+
if not [a for a in args if '=' not in a]:
163+
args.append('bootstrap')
164+
165+
# if -c was provided, we push it back into args for buildout' main function
166+
if options.config_file is not None:
167+
args[0:0] = ['-c', options.config_file]
168+
169+
zc.buildout.buildout.main(args)
170+
shutil.rmtree(tmpeggs)

‎buildout.cfg

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
[buildout]
2+
parts = python
3+
sphinxbuilder
4+
test
5+
develop = .
6+
eggs =
7+
urllib3==1.7
8+
9+
[python]
10+
recipe = zc.recipe.egg
11+
interpreter = python
12+
eggs = ${buildout:eggs}
13+
14+
[test]
15+
recipe = pbp.recipe.noserunner
16+
eggs = ${python:eggs}
17+
mock
18+
19+
[sphinxbuilder]
20+
recipe = collective.recipe.sphinxbuilder
21+
source = ${buildout:directory}/docs-source
22+
build = ${buildout:directory}/docs

‎docs-source/api.rst

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
API Documentation
2+
=========================
3+
.. automodule:: etcd
4+
:members:
5+
.. autoclass:: Client
6+
:special-members:
7+
:members:
8+
:exclude-members: __weakref__

‎docs-source/conf.py

+242
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# python-etcd documentation build configuration file, created by
4+
# sphinx-quickstart on Sat Sep 14 15:58:06 2013.
5+
#
6+
# This file is execfile()d with the current directory set to its containing dir.
7+
#
8+
# Note that not all possible configuration values are present in this
9+
# autogenerated file.
10+
#
11+
# All configuration values have a default; values that are commented out
12+
# serve to show the default.
13+
14+
import sys, os
15+
16+
# If extensions (or modules to document with autodoc) are in another directory,
17+
# add these directories to sys.path here. If the directory is relative to the
18+
# documentation root, use os.path.abspath to make it absolute, like shown here.
19+
sys.path.insert(0, os.path.abspath('../src'))
20+
21+
# -- General configuration -----------------------------------------------------
22+
23+
# If your documentation needs a minimal Sphinx version, state it here.
24+
#needs_sphinx = '1.0'
25+
26+
# Add any Sphinx extension module names here, as strings. They can be extensions
27+
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
28+
extensions = ['sphinx.ext.autodoc']
29+
30+
# Add any paths that contain templates here, relative to this directory.
31+
templates_path = ['_templates']
32+
33+
# The suffix of source filenames.
34+
source_suffix = '.rst'
35+
36+
# The encoding of source files.
37+
#source_encoding = 'utf-8-sig'
38+
39+
# The master toctree document.
40+
master_doc = 'index'
41+
42+
# General information about the project.
43+
project = u'python-etcd'
44+
copyright = u'2013, Jose Plana'
45+
46+
# The version info for the project you're documenting, acts as replacement for
47+
# |version| and |release|, also used in various other places throughout the
48+
# built documents.
49+
#
50+
# The short X.Y version.
51+
version = '0.1'
52+
# The full version, including alpha/beta/rc tags.
53+
release = '0.1'
54+
55+
# The language for content autogenerated by Sphinx. Refer to documentation
56+
# for a list of supported languages.
57+
#language = None
58+
59+
# There are two options for replacing |today|: either, you set today to some
60+
# non-false value, then it is used:
61+
#today = ''
62+
# Else, today_fmt is used as the format for a strftime call.
63+
#today_fmt = '%B %d, %Y'
64+
65+
# List of patterns, relative to source directory, that match files and
66+
# directories to ignore when looking for source files.
67+
exclude_patterns = ['_build']
68+
69+
# The reST default role (used for this markup: `text`) to use for all documents.
70+
#default_role = None
71+
72+
# If true, '()' will be appended to :func: etc. cross-reference text.
73+
#add_function_parentheses = True
74+
75+
# If true, the current module name will be prepended to all description
76+
# unit titles (such as .. function::).
77+
#add_module_names = True
78+
79+
# If true, sectionauthor and moduleauthor directives will be shown in the
80+
# output. They are ignored by default.
81+
#show_authors = False
82+
83+
# The name of the Pygments (syntax highlighting) style to use.
84+
pygments_style = 'sphinx'
85+
86+
# A list of ignored prefixes for module index sorting.
87+
#modindex_common_prefix = []
88+
89+
90+
# -- Options for HTML output ---------------------------------------------------
91+
92+
# The theme to use for HTML and HTML Help pages. See the documentation for
93+
# a list of builtin themes.
94+
html_theme = 'sphinxdoc'
95+
96+
# Theme options are theme-specific and customize the look and feel of a theme
97+
# further. For a list of options available for each theme, see the
98+
# documentation.
99+
#html_theme_options = {}
100+
101+
# Add any paths that contain custom themes here, relative to this directory.
102+
#html_theme_path = []
103+
104+
# The name for this set of Sphinx documents. If None, it defaults to
105+
# "<project> v<release> documentation".
106+
#html_title = None
107+
108+
# A shorter title for the navigation bar. Default is the same as html_title.
109+
#html_short_title = None
110+
111+
# The name of an image file (relative to this directory) to place at the top
112+
# of the sidebar.
113+
#html_logo = None
114+
115+
# The name of an image file (within the static path) to use as favicon of the
116+
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
117+
# pixels large.
118+
#html_favicon = None
119+
120+
# Add any paths that contain custom static files (such as style sheets) here,
121+
# relative to this directory. They are copied after the builtin static files,
122+
# so a file named "default.css" will overwrite the builtin "default.css".
123+
html_static_path = ['_static']
124+
125+
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
126+
# using the given strftime format.
127+
#html_last_updated_fmt = '%b %d, %Y'
128+
129+
# If true, SmartyPants will be used to convert quotes and dashes to
130+
# typographically correct entities.
131+
#html_use_smartypants = True
132+
133+
# Custom sidebar templates, maps document names to template names.
134+
#html_sidebars = {}
135+
136+
# Additional templates that should be rendered to pages, maps page names to
137+
# template names.
138+
#html_additional_pages = {}
139+
140+
# If false, no module index is generated.
141+
#html_domain_indices = True
142+
143+
# If false, no index is generated.
144+
#html_use_index = True
145+
146+
# If true, the index is split into individual pages for each letter.
147+
#html_split_index = False
148+
149+
# If true, links to the reST sources are added to the pages.
150+
#html_show_sourcelink = True
151+
152+
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
153+
#html_show_sphinx = True
154+
155+
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
156+
#html_show_copyright = True
157+
158+
# If true, an OpenSearch description file will be output, and all pages will
159+
# contain a <link> tag referring to it. The value of this option must be the
160+
# base URL from which the finished HTML is served.
161+
#html_use_opensearch = ''
162+
163+
# This is the file name suffix for HTML files (e.g. ".xhtml").
164+
#html_file_suffix = None
165+
166+
# Output file base name for HTML help builder.
167+
htmlhelp_basename = 'python-etcddoc'
168+
169+
170+
# -- Options for LaTeX output --------------------------------------------------
171+
172+
latex_elements = {
173+
# The paper size ('letterpaper' or 'a4paper').
174+
#'papersize': 'letterpaper',
175+
176+
# The font size ('10pt', '11pt' or '12pt').
177+
#'pointsize': '10pt',
178+
179+
# Additional stuff for the LaTeX preamble.
180+
#'preamble': '',
181+
}
182+
183+
# Grouping the document tree into LaTeX files. List of tuples
184+
# (source start file, target name, title, author, documentclass [howto/manual]).
185+
latex_documents = [
186+
('index', 'python-etcd.tex', u'python-etcd Documentation',
187+
u'Jose Plana', 'manual'),
188+
]
189+
190+
# The name of an image file (relative to this directory) to place at the top of
191+
# the title page.
192+
#latex_logo = None
193+
194+
# For "manual" documents, if this is true, then toplevel headings are parts,
195+
# not chapters.
196+
#latex_use_parts = False
197+
198+
# If true, show page references after internal links.
199+
#latex_show_pagerefs = False
200+
201+
# If true, show URL addresses after external links.
202+
#latex_show_urls = False
203+
204+
# Documents to append as an appendix to all manuals.
205+
#latex_appendices = []
206+
207+
# If false, no module index is generated.
208+
#latex_domain_indices = True
209+
210+
211+
# -- Options for manual page output --------------------------------------------
212+
213+
# One entry per manual page. List of tuples
214+
# (source start file, name, description, authors, manual section).
215+
man_pages = [
216+
('index', 'python-etcd', u'python-etcd Documentation',
217+
[u'Jose Plana'], 1)
218+
]
219+
220+
# If true, show URL addresses after external links.
221+
#man_show_urls = False
222+
223+
224+
# -- Options for Texinfo output ------------------------------------------------
225+
226+
# Grouping the document tree into Texinfo files. List of tuples
227+
# (source start file, target name, title, author,
228+
# dir menu entry, description, category)
229+
texinfo_documents = [
230+
('index', 'python-etcd', u'python-etcd Documentation',
231+
u'Jose Plana', 'python-etcd', 'One line description of project.',
232+
'Miscellaneous'),
233+
]
234+
235+
# Documents to append as an appendix to all manuals.
236+
#texinfo_appendices = []
237+
238+
# If false, no module index is generated.
239+
#texinfo_domain_indices = True
240+
241+
# How to display URL addresses: 'footnote', 'no', or 'inline'.
242+
#texinfo_show_urls = 'footnote'

‎docs-source/index.rst

+153
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
Python-etcd documentation
2+
=========================
3+
4+
A python client for Etcd https://github.com/coreos/etcd
5+
6+
7+
8+
9+
Installation
10+
------------
11+
12+
Pre-requirements
13+
................
14+
15+
Install etcd
16+
17+
18+
From source
19+
...........
20+
21+
.. code:: bash
22+
23+
$ python setup.py install
24+
25+
26+
Usage
27+
-----
28+
29+
Create a client object
30+
......................
31+
32+
.. code:: python
33+
34+
import etcd
35+
36+
client = etcd.Client() # this will create a client against etcd server running on localhost on port 4001
37+
client = etcd.Client(port=4002)
38+
client = etcd.Client(host='127.0.0.1', port=4003)
39+
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
40+
41+
42+
Set a key
43+
.........
44+
45+
.. code:: python
46+
47+
client.set('/nodes/n1', 1)
48+
# with ttl
49+
client.set('/nodes/n2', 2, ttl=4) # sets the ttl to 4 seconds
50+
51+
Get a key
52+
.........
53+
54+
.. code:: python
55+
56+
client.get('/nodes/n2')['value']
57+
58+
59+
Delete a key
60+
............
61+
62+
.. code:: python
63+
64+
client.delete('/nodes/n1')
65+
66+
67+
Test and set
68+
............
69+
70+
.. code:: python
71+
72+
client.test_and_set('/nodes/n2', 2, 4) # will set /nodes/n2 's value to 2 only if its previous value was 4
73+
74+
75+
Watch a key
76+
...........
77+
78+
.. code:: python
79+
80+
client.watch('/nodes/n1') # will wait till the key is changed, and return once its changed
81+
82+
83+
List sub keys
84+
.............
85+
86+
.. code:: python
87+
88+
client.get('/nodes')
89+
90+
91+
Get machines in the cluster
92+
...........................
93+
94+
.. code:: python
95+
96+
client.machines
97+
98+
99+
Get leader of the cluster
100+
.........................
101+
102+
.. code:: python
103+
104+
client.leader
105+
106+
107+
108+
109+
Development setup
110+
-----------------
111+
112+
To create a buildout,
113+
114+
.. code:: bash
115+
116+
$ python bootstrap.py
117+
$ bin/buildout
118+
119+
120+
to test you should have etcd available in your system path:
121+
122+
.. code:: bash
123+
124+
$ bin/test
125+
126+
to generate documentation,
127+
128+
.. code:: bash
129+
130+
$ cd docs
131+
$ make
132+
133+
134+
135+
Release HOWTO
136+
-------------
137+
138+
To make a release,
139+
140+
1) Update release date/version in NEWS.txt and setup.py
141+
2) Run 'python setup.py sdist'
142+
3) Test the generated source distribution in dist/
143+
4) Upload to PyPI: 'python setup.py sdist register upload'
144+
5) Increase version in setup.py (for next release)
145+
146+
147+
Code documentation
148+
------------------
149+
150+
.. toctree::
151+
:maxdepth: 2
152+
153+
api.rst

‎setup.py

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from setuptools import setup, find_packages
2+
import sys, os
3+
4+
here = os.path.abspath(os.path.dirname(__file__))
5+
README = open(os.path.join(here, 'README.rst')).read()
6+
NEWS = open(os.path.join(here, 'NEWS.txt')).read()
7+
8+
9+
version = '0.1'
10+
11+
install_requires = ['urllib3==1.7']
12+
13+
14+
setup(name='python-etcd',
15+
version=version,
16+
description="A python client for etcd",
17+
long_description=README + '\n\n' + NEWS,
18+
classifiers=[
19+
"Topic :: System :: Distributed Computing",
20+
"Topic :: Software Development :: Libraries",
21+
"License :: OSI Approved :: MIT License",
22+
"Topic :: Database :: Front-Ends",
23+
],
24+
keywords='etcd raft distributed log api client',
25+
author='Jose Plana',
26+
author_email='jplana@gmail.com',
27+
url='http://github.com/jplana/python-etcd',
28+
license='MIT',
29+
packages=find_packages('src'),
30+
package_dir = {'': 'src'},include_package_data=True,
31+
zip_safe=False,
32+
install_requires=install_requires,
33+
test_suite='tests.unit',
34+
35+
)

‎src/etcd/__init__.py

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import collections
2+
from client import Client
3+
4+
5+
class EtcdResult(collections.namedtuple(
6+
'EtcdResult',
7+
[
8+
'action',
9+
'index',
10+
'key',
11+
'prevValue',
12+
'value',
13+
'expiration',
14+
'ttl',
15+
'newKey'])):
16+
17+
def __new__(
18+
cls,
19+
action=None,
20+
index=None,
21+
key=None,
22+
prevValue=None,
23+
value=None,
24+
expiration=None,
25+
ttl=None,
26+
newKey=None):
27+
return super(EtcdResult, cls).__new__(
28+
cls,
29+
action,
30+
index,
31+
key,
32+
prevValue,
33+
value,
34+
expiration,
35+
ttl,
36+
newKey)
37+
38+
39+
class EtcdException(Exception):
40+
"""
41+
Generic Etcd Exception.
42+
"""
43+
44+
pass

‎src/etcd/client.py

+354
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
"""
2+
.. module:: python-etcd
3+
:synopsis: A python etcd client.
4+
5+
.. moduleauthor:: Jose Plana <jplana@gmail.com>
6+
7+
8+
"""
9+
import urllib3
10+
import json
11+
12+
import etcd
13+
14+
15+
class Client(object):
16+
"""
17+
Client for etcd, the distributed log service using raft.
18+
"""
19+
def __init__(
20+
self,
21+
host='127.0.0.1',
22+
port=4001,
23+
read_timeout=60,
24+
allow_redirect=True,
25+
protocol='http'):
26+
"""
27+
Initialize the client.
28+
29+
Args:
30+
host (str): IP to connect to.
31+
32+
port (int): Port used to connect to etcd.
33+
34+
read_timeout (int): max seconds to wait for a read.
35+
36+
allow_redirect (bool): allow the client to connect to other nodes.
37+
38+
protocol (str): Protocol used to connect to etcd.
39+
40+
"""
41+
self._host = host
42+
self._port = port
43+
self._protocol = protocol
44+
self._base_uri = "%s://%s:%d" % (protocol, host, port)
45+
self.version_prefix = '/v1'
46+
47+
self._read_timeout = read_timeout
48+
self._allow_redirect = allow_redirect
49+
50+
self._MGET = 'GET'
51+
self._MPOST = 'POST'
52+
self._MDELETE = 'DELETE'
53+
54+
# Dictionary of exceptions given an etcd return code.
55+
# 100: Key not found.
56+
# 101: The given PrevValue is not equal to the value of the key
57+
# 102: Not a file if the /foo = Node(bar) exists,
58+
# setting /foo/foo = Node(barbar)
59+
# 103: Reached the max number of machines in the cluster
60+
# 300: Raft Internal Error
61+
# 301: During Leader Election
62+
# 500: Watcher is cleared due to etcd recovery
63+
self.error_codes = {
64+
100: KeyError,
65+
101: ValueError,
66+
102: KeyError,
67+
103: Exception,
68+
300: Exception,
69+
301: Exception,
70+
500: etcd.EtcdException,
71+
999: etcd.EtcdException}
72+
73+
self.http = urllib3.PoolManager(10)
74+
75+
@property
76+
def base_uri(self):
77+
"""URI used by the client to connect to etcd."""
78+
return self._base_uri
79+
80+
@property
81+
def host(self):
82+
"""Node to connect etcd."""
83+
return self._host
84+
85+
@property
86+
def port(self):
87+
"""Port to connect etcd."""
88+
return self._port
89+
90+
@property
91+
def protocol(self):
92+
"""Protocol used to connect etcd."""
93+
return self._protocol
94+
95+
@property
96+
def read_timeout(self):
97+
"""Max seconds to wait for a read."""
98+
return self._read_timeout
99+
100+
@property
101+
def allow_redirect(self):
102+
"""Allow the client to connect to other nodes."""
103+
return self._allow_redirect
104+
105+
@property
106+
def machines(self):
107+
"""
108+
Members of the cluster.
109+
110+
Returns:
111+
list. str with all the nodes in the cluster.
112+
113+
>>> print client.machines
114+
['http://127.0.0.1:4001', 'http://127.0.0.1:4002']
115+
"""
116+
return [
117+
node.strip() for node in self.api_execute(
118+
self.version_prefix + '/machines',
119+
self._MGET).split(',')
120+
]
121+
122+
@property
123+
def leader(self):
124+
"""
125+
Returns:
126+
str. the leader of the cluster.
127+
128+
>>> print client.leader
129+
'http://127.0.0.1:4001'
130+
"""
131+
return self.api_execute(
132+
self.version_prefix + '/leader',
133+
self._MGET)
134+
135+
@property
136+
def key_endpoint(self):
137+
"""
138+
REST key endpoint.
139+
"""
140+
return self.version_prefix + '/keys'
141+
142+
@property
143+
def watch_endpoint(self):
144+
"""
145+
REST watch endpoint.
146+
"""
147+
148+
return self.version_prefix + '/watch'
149+
150+
def __contains__(self, key):
151+
"""
152+
Check if a key is available in the cluster.
153+
154+
>>> print 'key' in client
155+
True
156+
"""
157+
try:
158+
self.get(key)
159+
return True
160+
except KeyError:
161+
return False
162+
163+
def ethernal_watch(self, key, index=None):
164+
"""
165+
Generator that will yield changes from a key.
166+
Note that this method will block forever until an event is generated.
167+
168+
Args:
169+
key (str): Key to subcribe to.
170+
index (int): Index from where the changes will be received.
171+
172+
Yields:
173+
client.EtcdResult
174+
175+
>>> for event in client.ethernal_watch('/subcription_key'):
176+
... print event.value
177+
...
178+
value1
179+
value2
180+
181+
"""
182+
local_index = index
183+
while True:
184+
response = self.watch(key, local_index)
185+
if local_index is not None:
186+
local_index += 1
187+
yield response
188+
189+
def test_and_set(self, key, value, prev_value, ttl=None):
190+
"""
191+
Atomic test & set operation.
192+
It will check if the value of 'key' is 'prev_value',
193+
if the the check is correct will change the value for 'key' to 'value'
194+
if the the check is false an exception will be raised.
195+
196+
Args:
197+
key (str): Key.
198+
value (object): value to set
199+
prev_value (object): previous value.
200+
ttl (int): Time in seconds of expiration (optional).
201+
202+
Returns:
203+
client.EtcdResult
204+
205+
Raises:
206+
ValueError: When the 'prev_value' is not the current value.
207+
208+
>>> print client.test_and_set('/key', 'new', 'old', ttl=60).value
209+
'new'
210+
211+
"""
212+
path = self.key_endpoint + key
213+
payload = {'value': value, 'prevValue': prev_value}
214+
if ttl:
215+
payload['ttl'] = ttl
216+
response = self.api_execute(path, self._MPOST, payload)
217+
return self._result_from_response(response)
218+
219+
def set(self, key, value, ttl=None):
220+
"""
221+
Set value for a key.
222+
223+
Args:
224+
key (str): Key.
225+
226+
value (object): value to set
227+
228+
ttl (int): Time in seconds of expiration (optional).
229+
230+
Returns:
231+
client.EtcdResult
232+
233+
>>> print client.set('/key', 'newValue', ttl=60).value
234+
'newValue'
235+
236+
"""
237+
238+
path = self.key_endpoint + key
239+
payload = {'value': value}
240+
if ttl:
241+
payload['ttl'] = ttl
242+
response = self.api_execute(path, self._MPOST, payload)
243+
return self._result_from_response(response)
244+
245+
def delete(self, key):
246+
"""
247+
Removed a key from etcd.
248+
249+
Args:
250+
key (str): Key.
251+
252+
Returns:
253+
client.EtcdResult
254+
255+
Raises:
256+
KeyValue: If the key doesn't exists.
257+
258+
>>> print client.delete('/key').key
259+
'/key'
260+
261+
"""
262+
263+
response = self.api_execute(self.key_endpoint + key, self._MDELETE)
264+
return self._result_from_response(response)
265+
266+
def get(self, key):
267+
"""
268+
Returns the value of the key 'key'.
269+
270+
Args:
271+
key (str): Key.
272+
273+
Returns:
274+
client.EtcdResult
275+
276+
Raises:
277+
KeyValue: If the key doesn't exists.
278+
279+
>>> print client.get('/key').value
280+
'value'
281+
282+
"""
283+
284+
response = self.api_execute(self.key_endpoint + key, self._MGET)
285+
return self._result_from_response(response)
286+
287+
def watch(self, key, index=None):
288+
"""
289+
Blocks until a new event has been received, starting at index 'index'
290+
291+
Args:
292+
key (str): Key.
293+
294+
index (int): Index to start from.
295+
296+
Returns:
297+
client.EtcdResult
298+
299+
Raises:
300+
KeyValue: If the key doesn't exists.
301+
302+
>>> print client.watch('/key').value
303+
'value'
304+
305+
"""
306+
307+
params = None
308+
method = self._MGET
309+
if index:
310+
params = {'index': index}
311+
method = self._MPOST
312+
313+
response = self.api_execute(
314+
self.watch_endpoint + key,
315+
method,
316+
params=params)
317+
return self._result_from_response(response)
318+
319+
def _result_from_response(self, response):
320+
""" Creates an EtcdResult from json dictionary """
321+
try:
322+
return etcd.EtcdResult(**json.loads(response))
323+
except:
324+
raise etcd.EtcdException('Unable to decode server response')
325+
326+
def api_execute(self, path, method, params=None):
327+
""" Executes the query. """
328+
if (method == self._MGET) or (method == self._MDELETE):
329+
response = self.http.request(
330+
method,
331+
self._base_uri + path,
332+
fields=params,
333+
redirect=self.allow_redirect)
334+
335+
elif method == self._MPOST:
336+
response = self.http.request_encode_body(
337+
method,
338+
self._base_uri+path,
339+
fields=params,
340+
encode_multipart=False,
341+
redirect=self.allow_redirect)
342+
343+
if response.status == 200:
344+
return response.data
345+
else:
346+
try:
347+
error = json.loads(response.data)
348+
message = "%s : %s" % (error['message'], error['cause'])
349+
error_code = error['errorCode']
350+
error_exception = self.error_codes[error_code]
351+
except:
352+
message = "Unable to decode server response"
353+
error_exception = etcd.EtcdException
354+
raise error_exception(message)

‎src/etcd/tests/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import unit

‎src/etcd/tests/integration/__init__.py

Whitespace-only changes.

‎src/etcd/tests/integration/helpers.py

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import shutil
2+
import subprocess
3+
import tempfile
4+
import logging
5+
import time
6+
7+
8+
class EtcdProcessHelper(object):
9+
def __init__(
10+
self,
11+
proc_name='etcd',
12+
port_range_start=4001,
13+
internal_port_range_start=7001):
14+
self.proc_name = proc_name
15+
self.port_range_start = port_range_start
16+
self.internal_port_range_start = internal_port_range_start
17+
self.processes = []
18+
19+
def run(self, number=1):
20+
log = logging.getLogger()
21+
for i in range(0, number):
22+
directory = tempfile.mkdtemp(prefix='python-etcd.%d' % i)
23+
log.debug('Created directory %s' % directory)
24+
daemon_args = [
25+
self.proc_name,
26+
'-d', directory,
27+
'-n', 'test-node-%d' % i,
28+
'-s', '127.0.0.1:%d' % (self.internal_port_range_start + i),
29+
'-c', '127.0.0.1:%d' % (self.port_range_start + i),
30+
]
31+
daemon = subprocess.Popen(daemon_args)
32+
log.debug('Started %d' % daemon.pid)
33+
time.sleep(2)
34+
self.processes.append((directory, daemon))
35+
36+
def stop(self):
37+
log = logging.getLogger()
38+
for directory, process in self.processes:
39+
process.kill()
40+
time.sleep(2)
41+
log.debug('Killed etcd pid:%d' % process.pid)
42+
shutil.rmtree(directory)
43+
log.debug('Removed directory %s' % directory)
+285
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
import os
2+
import time
3+
import logging
4+
import unittest
5+
import multiprocessing
6+
7+
import etcd
8+
import helpers
9+
10+
from nose.tools import nottest
11+
12+
log = logging.getLogger()
13+
14+
15+
class EtcdIntegrationTest(unittest.TestCase):
16+
17+
@classmethod
18+
def setUpClass(cls):
19+
program = cls._get_exe()
20+
21+
cls.processHelper = helpers.EtcdProcessHelper(
22+
proc_name=program,
23+
port_range_start=6001,
24+
internal_port_range_start=8001)
25+
cls.processHelper.run(number=3)
26+
cls.client = etcd.Client(port=6001)
27+
28+
@classmethod
29+
def tearDownClass(cls):
30+
cls.processHelper.stop()
31+
32+
@classmethod
33+
def _is_exe(cls, fpath):
34+
return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
35+
36+
@classmethod
37+
def _get_exe(cls):
38+
PROGRAM = 'etcd'
39+
40+
program_path = None
41+
42+
for path in os.environ["PATH"].split(os.pathsep):
43+
path = path.strip('"')
44+
exe_file = os.path.join(path, PROGRAM)
45+
if cls._is_exe(exe_file):
46+
program_path = exe_file
47+
break
48+
49+
if not program_path:
50+
raise Exception('etcd not in path!!')
51+
52+
return program_path
53+
54+
55+
class TestSimple(EtcdIntegrationTest):
56+
57+
def test_machines(self):
58+
""" INTEGRATION: retrieve machines """
59+
self.assertEquals(self.client.machines, ['http://127.0.0.1:6001'])
60+
61+
def test_leader(self):
62+
""" INTEGRATION: retrieve leader """
63+
self.assertEquals(self.client.leader, 'http://127.0.0.1:8001')
64+
65+
def test_get_set_delete(self):
66+
""" INTEGRATION: set a new value """
67+
try:
68+
get_result = self.client.get('/test_set')
69+
assert False
70+
except KeyError, e:
71+
pass
72+
73+
self.assertNotIn('/test_set', self.client)
74+
75+
set_result = self.client.set('/test_set', 'test-key')
76+
self.assertEquals('SET', set_result.action)
77+
self.assertEquals('/test_set', set_result.key)
78+
self.assertEquals(True, set_result.newKey)
79+
self.assertEquals('test-key', set_result.value)
80+
81+
self.assertIn('/test_set', self.client)
82+
83+
get_result = self.client.get('/test_set')
84+
self.assertEquals('GET', get_result.action)
85+
self.assertEquals('/test_set', get_result.key)
86+
self.assertEquals('test-key', get_result.value)
87+
88+
delete_result = self.client.delete('/test_set')
89+
self.assertEquals('DELETE', delete_result.action)
90+
self.assertEquals('/test_set', delete_result.key)
91+
self.assertEquals('test-key', delete_result.prevValue)
92+
93+
self.assertNotIn('/test_set', self.client)
94+
95+
try:
96+
get_result = self.client.get('/test_set')
97+
assert False
98+
except KeyError, e:
99+
pass
100+
101+
102+
class TestErrors(EtcdIntegrationTest):
103+
104+
def test_is_not_a_file(self):
105+
""" INTEGRATION: try to write value to a directory """
106+
107+
set_result = self.client.set('/directory/test-key', 'test-value')
108+
109+
try:
110+
get_result = self.client.set('/directory', 'test-value')
111+
assert False
112+
except KeyError, e:
113+
pass
114+
115+
def test_test_and_set(self):
116+
""" INTEGRATION: try test_and_set operation """
117+
118+
set_result = self.client.set('/test-key', 'old-test-value')
119+
120+
set_result = self.client.test_and_set(
121+
'/test-key',
122+
'test-value',
123+
'old-test-value')
124+
125+
try:
126+
set_result = self.client.test_and_set(
127+
'/test-key',
128+
'new-value',
129+
'old-test-value')
130+
131+
assert False
132+
except ValueError, e:
133+
pass
134+
135+
136+
class TestWatch(EtcdIntegrationTest):
137+
138+
def test_watch(self):
139+
""" INTEGRATION: Receive a watch event from other process """
140+
141+
set_result = self.client.set('/test-key', 'test-value')
142+
143+
queue = multiprocessing.Queue()
144+
145+
def change_value(key, newValue):
146+
c = etcd.Client(port=6001)
147+
c.set(key, newValue)
148+
149+
def watch_value(key, queue):
150+
c = etcd.Client(port=6001)
151+
queue.put(c.watch(key).value)
152+
153+
changer = multiprocessing.Process(
154+
target=change_value, args=('/test-key', 'new-test-value',))
155+
156+
watcher = multiprocessing.Process(
157+
target=watch_value, args=('/test-key', queue))
158+
159+
watcher.start()
160+
time.sleep(1)
161+
162+
changer.start()
163+
164+
value = queue.get(timeout=2)
165+
watcher.join(timeout=5)
166+
changer.join(timeout=5)
167+
168+
assert value == 'new-test-value'
169+
170+
def test_watch_indexed(self):
171+
""" INTEGRATION: Receive a watch event from other process, indexed """
172+
173+
set_result = self.client.set('/test-key', 'test-value')
174+
set_result = self.client.set('/test-key', 'test-value0')
175+
original_index = int(set_result.index)
176+
set_result = self.client.set('/test-key', 'test-value1')
177+
set_result = self.client.set('/test-key', 'test-value2')
178+
179+
queue = multiprocessing.Queue()
180+
181+
def change_value(key, newValue):
182+
c = etcd.Client(port=6001)
183+
c.set(key, newValue)
184+
c.get(key)
185+
186+
def watch_value(key, index, queue):
187+
c = etcd.Client(port=6001)
188+
for i in range(0, 3):
189+
queue.put(c.watch(key, index=index+i).value)
190+
191+
proc = multiprocessing.Process(
192+
target=change_value, args=('/test-key', 'test-value3',))
193+
194+
watcher = multiprocessing.Process(
195+
target=watch_value, args=('/test-key', original_index, queue))
196+
197+
watcher.start()
198+
time.sleep(0.5)
199+
200+
proc.start()
201+
202+
for i in range(0, 3):
203+
value = queue.get()
204+
log.debug("index: %d: %s" % (i, value))
205+
self.assertEquals('test-value%d' % i, value)
206+
207+
watcher.join(timeout=5)
208+
proc.join(timeout=5)
209+
210+
def test_watch_generator(self):
211+
""" INTEGRATION: Receive a watch event from other process (gen) """
212+
213+
set_result = self.client.set('/test-key', 'test-value')
214+
215+
queue = multiprocessing.Queue()
216+
217+
def change_value(key):
218+
time.sleep(0.5)
219+
c = etcd.Client(port=6001)
220+
for i in range(0, 3):
221+
c.set(key, 'test-value%d' % i)
222+
c.get(key)
223+
224+
def watch_value(key, queue):
225+
c = etcd.Client(port=6001)
226+
for i in range(0, 3):
227+
event = c.ethernal_watch(key).next().value
228+
queue.put(event)
229+
230+
changer = multiprocessing.Process(
231+
target=change_value, args=('/test-key',))
232+
233+
watcher = multiprocessing.Process(
234+
target=watch_value, args=('/test-key', queue))
235+
236+
watcher.start()
237+
changer.start()
238+
239+
values = ['test-value0', 'test-value1', 'test-value2']
240+
for i in range(0, 1):
241+
value = queue.get()
242+
log.debug("index: %d: %s" % (i, value))
243+
self.assertIn(value, values)
244+
245+
watcher.join(timeout=5)
246+
changer.join(timeout=5)
247+
248+
def test_watch_indexed_generator(self):
249+
""" INTEGRATION: Receive a watch event from other process, ixd, (2) """
250+
251+
set_result = self.client.set('/test-key', 'test-value')
252+
set_result = self.client.set('/test-key', 'test-value0')
253+
original_index = int(set_result.index)
254+
set_result = self.client.set('/test-key', 'test-value1')
255+
set_result = self.client.set('/test-key', 'test-value2')
256+
257+
queue = multiprocessing.Queue()
258+
259+
def change_value(key, newValue):
260+
c = etcd.Client(port=6001)
261+
c.set(key, newValue)
262+
263+
def watch_value(key, index, queue):
264+
c = etcd.Client(port=6001)
265+
iterevents = c.ethernal_watch(key, index=index)
266+
for i in range(0, 3):
267+
queue.put(iterevents.next().value)
268+
269+
proc = multiprocessing.Process(
270+
target=change_value, args=('/test-key', 'test-value3',))
271+
272+
watcher = multiprocessing.Process(
273+
target=watch_value, args=('/test-key', original_index, queue))
274+
275+
watcher.start()
276+
time.sleep(0.5)
277+
proc.start()
278+
279+
for i in range(0, 3):
280+
value = queue.get()
281+
log.debug("index: %d: %s" % (i, value))
282+
self.assertEquals('test-value%d' % i, value)
283+
284+
watcher.join(timeout=5)
285+
proc.join(timeout=5)

‎src/etcd/tests/unit/__init__.py

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import test_client
2+
import test_request
3+
4+
5+
def test_suite():
6+
return unittest.makeSuite([test_client.TestClient])

‎src/etcd/tests/unit/test_client.py

+78
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import unittest
2+
import etcd
3+
4+
5+
class TestClient(unittest.TestCase):
6+
7+
def test_instantiate(self):
8+
""" client can be instantiated"""
9+
client = etcd.Client()
10+
assert client is not None
11+
12+
def test_default_host(self):
13+
""" default host is 127.0.0.1"""
14+
client = etcd.Client()
15+
assert client.host == "127.0.0.1"
16+
17+
def test_default_port(self):
18+
""" default port is 4001"""
19+
client = etcd.Client()
20+
assert client.port == 4001
21+
22+
def test_default_protocol(self):
23+
""" default protocol is http"""
24+
client = etcd.Client()
25+
assert client.port == 'http'
26+
27+
def test_default_protocol(self):
28+
""" default protocol is http"""
29+
client = etcd.Client()
30+
assert client.protocol == 'http'
31+
32+
def test_default_read_timeout(self):
33+
""" default read_timeout is 60"""
34+
client = etcd.Client()
35+
assert client.read_timeout == 60
36+
37+
def test_default_allow_redirect(self):
38+
""" default allow_redirect is True"""
39+
client = etcd.Client()
40+
assert client.allow_redirect
41+
42+
def test_set_host(self):
43+
""" can change host """
44+
client = etcd.Client(host='192.168.1.1')
45+
assert client.host == '192.168.1.1'
46+
47+
def test_set_port(self):
48+
""" can change port """
49+
client = etcd.Client(port=4002)
50+
assert client.port == 4002
51+
52+
def test_set_protocol(self):
53+
""" can change protocol """
54+
client = etcd.Client(protocol='https')
55+
assert client.protocol == 'https'
56+
57+
def test_set_read_timeout(self):
58+
""" can set read_timeout """
59+
client = etcd.Client(read_timeout=45)
60+
assert client.read_timeout == 45
61+
62+
def test_set_allow_redirect(self):
63+
""" can change allow_redirect """
64+
client = etcd.Client(allow_redirect=False)
65+
assert not client.allow_redirect
66+
67+
def test_default_base_uri(self):
68+
""" default uri is http://127.0.0.1:4001 """
69+
client = etcd.Client()
70+
assert client.base_uri == 'http://127.0.0.1:4001'
71+
72+
def test_set_base_uri(self):
73+
""" can change base uri """
74+
client = etcd.Client(
75+
host='192.168.1.1',
76+
port=4003,
77+
protocol='https')
78+
assert client.base_uri == 'https://192.168.1.1:4003'

‎src/etcd/tests/unit/test_request.py

+356
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
import etcd
2+
import unittest
3+
import mock
4+
5+
from etcd import EtcdException
6+
7+
8+
class TestClientRequest(unittest.TestCase):
9+
10+
def test_machines(self):
11+
""" Can request machines """
12+
client = etcd.Client()
13+
client.api_execute = mock.Mock(
14+
return_value=
15+
"http://127.0.0.1:4002,"
16+
" http://127.0.0.1:4001,"
17+
" http://127.0.0.1:4003,"
18+
" http://127.0.0.1:4001"
19+
)
20+
21+
assert client.machines == [
22+
'http://127.0.0.1:4002',
23+
'http://127.0.0.1:4001',
24+
'http://127.0.0.1:4003',
25+
'http://127.0.0.1:4001'
26+
]
27+
28+
def test_leader(self):
29+
""" Can request the leader """
30+
client = etcd.Client()
31+
client.api_execute = mock.Mock(return_value="http://127.0.0.1:7002")
32+
result = client.leader
33+
self.assertEquals('http://127.0.0.1:7002', result)
34+
35+
def test_set(self):
36+
""" Can set a value """
37+
client = etcd.Client()
38+
client.api_execute = mock.Mock(
39+
return_value=
40+
'{"action":"SET",'
41+
'"key":"/testkey",'
42+
'"value":"test",'
43+
'"newKey":true,'
44+
'"expiration":"2013-09-14T00:56:59.316195568+02:00",'
45+
'"ttl":19,"index":183}')
46+
47+
result = client.set('/testkey', 'test', ttl=19)
48+
49+
self.assertEquals(
50+
etcd.EtcdResult(
51+
**{u'action': u'SET',
52+
u'expiration': u'2013-09-14T00:56:59.316195568+02:00',
53+
u'index': 183,
54+
u'key': u'/testkey',
55+
u'newKey': True,
56+
u'ttl': 19,
57+
u'value': u'test'}), result)
58+
59+
def test_test_and_set(self):
60+
""" Can test and set a value """
61+
client = etcd.Client()
62+
client.api_execute = mock.Mock(
63+
return_value=
64+
'{"action":"SET",'
65+
'"key":"/testkey",'
66+
'"prevValue":"test",'
67+
'"value":"newvalue",'
68+
'"expiration":"2013-09-14T02:09:44.24390976+02:00",'
69+
'"ttl":49,"index":203}')
70+
71+
result = client.test_and_set('/testkey', 'newvalue', 'test', ttl=19)
72+
self.assertEquals(
73+
etcd.EtcdResult(
74+
**{u'action': u'SET',
75+
u'expiration': u'2013-09-14T02:09:44.24390976+02:00',
76+
u'index': 203,
77+
u'key': u'/testkey',
78+
u'prevValue': u'test',
79+
u'ttl': 49,
80+
u'value': u'newvalue'}), result)
81+
82+
def test_test_and_test_failure(self):
83+
""" Exception will be raised if prevValue != value in test_set """
84+
85+
client = etcd.Client()
86+
client.api_execute = mock.Mock(
87+
side_effect=ValueError(
88+
'The given PrevValue is not equal'
89+
' to the value of the key : TestAndSet: 1!=3'))
90+
try:
91+
result = client.test_and_set(
92+
'/testkey',
93+
'newvalue',
94+
'test', ttl=19)
95+
except ValueError, e:
96+
#from ipdb import set_trace; set_trace()
97+
self.assertEquals(
98+
'The given PrevValue is not equal'
99+
' to the value of the key : TestAndSet: 1!=3', e.message)
100+
101+
def test_delete(self):
102+
""" Can delete a value """
103+
client = etcd.Client()
104+
client.api_execute = mock.Mock(
105+
return_value=
106+
'{"action":"DELETE",'
107+
'"key":"/testkey",'
108+
'"prevValue":"test",'
109+
'"expiration":"2013-09-14T01:06:35.5242587+02:00",'
110+
'"index":189}')
111+
112+
result = client.delete('/testkey')
113+
self.assertEquals(etcd.EtcdResult(
114+
**{u'action': u'DELETE',
115+
u'expiration': u'2013-09-14T01:06:35.5242587+02:00',
116+
u'index': 189,
117+
u'key': u'/testkey',
118+
u'prevValue': u'test'}), result)
119+
120+
def test_get(self):
121+
""" Can get a value """
122+
client = etcd.Client()
123+
client.api_execute = mock.Mock(
124+
return_value=
125+
'{"action":"GET",'
126+
'"key":"/testkey",'
127+
'"value":"test",'
128+
'"index":190}')
129+
130+
result = client.get('/testkey')
131+
self.assertEquals(etcd.EtcdResult(
132+
**{u'action': u'GET',
133+
u'index': 190,
134+
u'key': u'/testkey',
135+
u'value': u'test'}), result)
136+
137+
def test_not_in(self):
138+
""" Can check if key is not in client """
139+
client = etcd.Client()
140+
client.get = mock.Mock(side_effect=KeyError())
141+
result = '/testkey' not in client
142+
self.assertEquals(True, result)
143+
144+
def test_in(self):
145+
""" Can check if key is in client """
146+
client = etcd.Client()
147+
client.api_execute = mock.Mock(
148+
return_value=
149+
'{"action":"GET",'
150+
'"key":"/testkey",'
151+
'"value":"test",'
152+
'"index":190}')
153+
result = '/testkey' in client
154+
155+
self.assertEquals(True, result)
156+
157+
def test_simple_watch(self):
158+
""" Can watch values """
159+
client = etcd.Client()
160+
client.api_execute = mock.Mock(
161+
return_value=
162+
'{"action":"SET",'
163+
'"key":"/testkey",'
164+
'"value":"test",'
165+
'"newKey":true,'
166+
'"expiration":"2013-09-14T01:35:07.623681365+02:00",'
167+
'"ttl":19,'
168+
'"index":192}')
169+
result = client.watch('/testkey')
170+
self.assertEquals(
171+
etcd.EtcdResult(
172+
**{u'action': u'SET',
173+
u'expiration': u'2013-09-14T01:35:07.623681365+02:00',
174+
u'index': 192,
175+
u'key': u'/testkey',
176+
u'newKey': True,
177+
u'ttl': 19,
178+
u'value': u'test'}), result)
179+
180+
def test_index_watch(self):
181+
""" Can watch values from index """
182+
client = etcd.Client()
183+
client.api_execute = mock.Mock(
184+
return_value=
185+
'{"action":"SET",'
186+
'"key":"/testkey",'
187+
'"value":"test",'
188+
'"newKey":true,'
189+
'"expiration":"2013-09-14T01:35:07.623681365+02:00",'
190+
'"ttl":19,'
191+
'"index":180}')
192+
result = client.watch('/testkey', index=180)
193+
self.assertEquals(
194+
etcd.EtcdResult(
195+
**{u'action': u'SET',
196+
u'expiration': u'2013-09-14T01:35:07.623681365+02:00',
197+
u'index': 180,
198+
u'key': u'/testkey',
199+
u'newKey': True,
200+
u'ttl': 19,
201+
u'value': u'test'}), result)
202+
203+
204+
class TestEventGenerator(object):
205+
def check_watch(self, result):
206+
assert etcd.EtcdResult(
207+
**{u'action': u'SET',
208+
u'expiration': u'2013-09-14T01:35:07.623681365+02:00',
209+
u'index': 180,
210+
u'key': u'/testkey',
211+
u'newKey': True,
212+
u'ttl': 19,
213+
u'value': u'test'}) == result
214+
215+
def test_ethernal_watch(self):
216+
""" Can watch values from generator """
217+
client = etcd.Client()
218+
client.api_execute = mock.Mock(
219+
return_value=
220+
'{"action":"SET",'
221+
'"key":"/testkey",'
222+
'"value":"test",'
223+
'"newKey":true,'
224+
'"expiration":"2013-09-14T01:35:07.623681365+02:00",'
225+
'"ttl":19,'
226+
'"index":180}')
227+
for result in range(1, 5):
228+
result = client.ethernal_watch('/testkey', index=180).next()
229+
yield self.check_watch, result
230+
231+
232+
class FakeHTTPResponse(object):
233+
def __init__(self, status, data=''):
234+
self.status = status
235+
self.data = data
236+
237+
238+
class TestClientApiExecutor(unittest.TestCase):
239+
240+
def test_get(self):
241+
""" http get request """
242+
client = etcd.Client()
243+
response = FakeHTTPResponse(status=200, data='arbitrary json data')
244+
client.http.request = mock.Mock(return_value=response)
245+
result = client.api_execute('/v1/keys/testkey', client._MGET)
246+
self.assertEquals('arbitrary json data', result)
247+
248+
def test_delete(self):
249+
""" http delete request """
250+
client = etcd.Client()
251+
response = FakeHTTPResponse(status=200, data='arbitrary json data')
252+
client.http.request = mock.Mock(return_value=response)
253+
result = client.api_execute('/v1/keys/testkey', client._MDELETE)
254+
self.assertEquals('arbitrary json data', result)
255+
256+
def test_get_error(self):
257+
""" http get error request 101"""
258+
client = etcd.Client()
259+
response = FakeHTTPResponse(status=400,
260+
data='{"message": "message",'
261+
' "cause": "cause",'
262+
' "errorCode": 100}')
263+
client.http.request = mock.Mock(return_value=response)
264+
try:
265+
client.api_execute('v1/keys/testkey', client._MGET)
266+
assert False
267+
except KeyError, e:
268+
self.assertEquals(e.message, "message : cause")
269+
270+
def test_post(self):
271+
""" http post request """
272+
client = etcd.Client()
273+
response = FakeHTTPResponse(status=200, data='arbitrary json data')
274+
client.http.request_encode_body = mock.Mock(return_value=response)
275+
result = client.api_execute('v1/keys/testkey', client._MPOST)
276+
self.assertEquals('arbitrary json data', result)
277+
278+
def test_test_and_set_error(self):
279+
""" http post error request 101 """
280+
client = etcd.Client()
281+
response = FakeHTTPResponse(
282+
status=400,
283+
data='{"message": "message", "cause": "cause", "errorCode": 101}')
284+
client.http.request_encode_body = mock.Mock(return_value=response)
285+
payload = {'value': 'value', 'prevValue': 'oldValue', 'ttl': '60'}
286+
try:
287+
client.api_execute('v1/keys/testkey', client._MPOST, payload)
288+
self.fail()
289+
except ValueError, e:
290+
self.assertEquals('message : cause', e.message)
291+
292+
def test_set_error(self):
293+
""" http post error request 102 """
294+
client = etcd.Client()
295+
response = FakeHTTPResponse(
296+
status=400,
297+
data='{"message": "message", "cause": "cause", "errorCode": 102}')
298+
client.http.request_encode_body = mock.Mock(return_value=response)
299+
payload = {'value': 'value', 'prevValue': 'oldValue', 'ttl': '60'}
300+
try:
301+
client.api_execute('v1/keys/testkey', client._MPOST, payload)
302+
self.fail()
303+
except KeyError, e:
304+
self.assertEquals('message : cause', e.message)
305+
306+
def test_set_error(self):
307+
""" http post error request 102 """
308+
client = etcd.Client()
309+
response = FakeHTTPResponse(
310+
status=400,
311+
data='{"message": "message", "cause": "cause", "errorCode": 102}')
312+
client.http.request_encode_body = mock.Mock(return_value=response)
313+
payload = {'value': 'value', 'prevValue': 'oldValue', 'ttl': '60'}
314+
try:
315+
client.api_execute('v1/keys/testkey', client._MPOST, payload)
316+
self.fail()
317+
except KeyError, e:
318+
self.assertEquals('message : cause', e.message)
319+
320+
def test_get_error_unknown(self):
321+
""" http get error request unknown """
322+
client = etcd.Client()
323+
response = FakeHTTPResponse(status=400,
324+
data='{"message": "message",'
325+
' "cause": "cause",'
326+
' "errorCode": 42}')
327+
client.http.request = mock.Mock(return_value=response)
328+
try:
329+
client.api_execute('v1/keys/testkey', client._MGET)
330+
assert False
331+
except EtcdException, e:
332+
self.assertEquals(e.message, "Unable to decode server response")
333+
334+
def test_get_error_request_invalid(self):
335+
""" http get error request invalid """
336+
client = etcd.Client()
337+
response = FakeHTTPResponse(status=200,
338+
data='{){){)*garbage*')
339+
client.http.request = mock.Mock(return_value=response)
340+
try:
341+
client.get('/testkey')
342+
assert False
343+
except EtcdException, e:
344+
self.assertEquals(e.message, "Unable to decode server response")
345+
346+
def test_get_error_invalid(self):
347+
""" http get error request invalid """
348+
client = etcd.Client()
349+
response = FakeHTTPResponse(status=400,
350+
data='{){){)*garbage*')
351+
client.http.request = mock.Mock(return_value=response)
352+
try:
353+
client.api_execute('v1/keys/testkey', client._MGET)
354+
assert False
355+
except EtcdException, e:
356+
self.assertEquals(e.message, "Unable to decode server response")

0 commit comments

Comments
 (0)
Please sign in to comment.