summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Hurley <gabriel@strikeawe.com>2011-10-25 16:50:08 -0700
committerGabriel Hurley <gabriel@strikeawe.com>2011-10-25 16:50:08 -0700
commit17f6b83ee6157371104b065d7fb9cb6e5b03c386 (patch)
tree61e4b41e2edf001f6023b305f37d3c61131e9708
downloadpython-keystoneclient-17f6b83ee6157371104b065d7fb9cb6e5b03c386.tar.gz
Initial commit.
-rw-r--r--.gitignore11
-rw-r--r--AUTHORS18
-rw-r--r--HACKING77
-rw-r--r--LICENSE209
-rw-r--r--MANIFEST.in3
-rw-r--r--README.rst93
-rw-r--r--docs/.gitignore1
-rw-r--r--docs/Makefile89
-rw-r--r--docs/api.rst19
-rw-r--r--docs/conf.py200
-rw-r--r--docs/index.rst36
-rw-r--r--docs/ref/exceptions.rst8
-rw-r--r--docs/ref/index.rst9
-rw-r--r--docs/releases.rst106
-rw-r--r--docs/shell.rst57
-rw-r--r--keystoneclient/__init__.py0
-rw-r--r--keystoneclient/base.py191
-rw-r--r--keystoneclient/client.py189
-rw-r--r--keystoneclient/exceptions.py129
-rw-r--r--keystoneclient/service_catalog.py53
-rw-r--r--keystoneclient/shell.py228
-rw-r--r--keystoneclient/utils.py69
-rw-r--r--keystoneclient/v2_0/__init__.py2
-rw-r--r--keystoneclient/v2_0/client.py111
-rw-r--r--keystoneclient/v2_0/roles.py65
-rw-r--r--keystoneclient/v2_0/services.py39
-rw-r--r--keystoneclient/v2_0/shell.py27
-rw-r--r--keystoneclient/v2_0/tenants.py76
-rw-r--r--keystoneclient/v2_0/tokens.py37
-rw-r--r--keystoneclient/v2_0/users.py101
-rwxr-xr-xrun_tests.sh116
-rw-r--r--setup.cfg13
-rw-r--r--setup.py40
-rw-r--r--tests/__init__.py0
-rw-r--r--tests/test_base.py48
-rw-r--r--tests/test_http.py63
-rw-r--r--tests/test_service_catalog.py108
-rw-r--r--tests/test_shell.py39
-rw-r--r--tests/test_utils.py64
-rw-r--r--tests/utils.py81
-rw-r--r--tests/v2_0/__init__.py0
-rw-r--r--tests/v2_0/test_auth.py207
-rw-r--r--tests/v2_0/test_roles.py99
-rw-r--r--tests/v2_0/test_services.py111
-rw-r--r--tests/v2_0/test_tenants.py150
-rw-r--r--tests/v2_0/test_tokens.py47
-rw-r--r--tests/v2_0/test_users.py153
-rwxr-xr-xtools/generate_authors.sh3
-rw-r--r--tox.ini8
49 files changed, 3603 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d87775c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+.coverage
+.keystoneclient-venv
+*,cover
+cover
+*.pyc
+.idea
+*.swp
+*~
+build
+dist
+python_keystoneclient.egg-info
diff --git a/AUTHORS b/AUTHORS
new file mode 100644
index 0000000..eab098c
--- /dev/null
+++ b/AUTHORS
@@ -0,0 +1,18 @@
+Andrey Brindeyev <abrindeyev@griddynamics.com>
+Andy Smith <github@anarkystic.com>
+Antony Messerli <amesserl@rackspace.com>
+Brian Lamar <brian.lamar@rackspace.com>
+Brian Waldon <brian.waldon@rackspace.com>
+Chris Behrens <cbehrens+github@codestud.com>
+Christopher MacGown <ignoti+github@gmail.com>
+Ed Leafe <ed@leafe.com>
+Eldar Nugaev <eldr@ya.ru>
+Ilya Alekseyev <ilyaalekseyev@acm.org>
+Johannes Erdfelt <johannes.erdfelt@rackspace.com>
+Josh Kearney <josh@jk0.org>
+Kevin L. Mitchell <kevin.mitchell@rackspace.com>
+Kirill Shileev <kshileev@griddynamics.com>
+Lvov Maxim <mlvov@mirantis.com>
+Matt Dietz <matt.dietz@rackspace.com>
+Sandy Walsh <sandy@darksecretsoftware.com>
+Gabriel Hurley <gabriel@strikeawe.com>
diff --git a/HACKING b/HACKING
new file mode 100644
index 0000000..cb6623e
--- /dev/null
+++ b/HACKING
@@ -0,0 +1,77 @@
+Keystone Style Commandments
+===========================
+
+Step 1: Read http://www.python.org/dev/peps/pep-0008/
+Step 2: Read http://www.python.org/dev/peps/pep-0008/ again
+Step 3: Read on
+
+Imports
+-------
+- thou shalt not import objects, only modules
+- thou shalt not import more than one module per line
+- thou shalt not make relative imports
+- thou shalt organize your imports according to the following template
+
+::
+ # vim: tabstop=4 shiftwidth=4 softtabstop=4
+ {{stdlib imports in human alphabetical order}}
+ \n
+ {{third-party library imports in human alphabetical order}}
+ \n
+ {{keystoneclient imports in human alphabetical order}}
+ \n
+ \n
+ {{begin your code}}
+
+
+General
+-------
+- thou shalt put two newlines twixt toplevel code (funcs, classes, etc)
+- thou shalt put one newline twixt methods in classes and anywhere else
+- thou shalt not write "except:", use "except Exception:" at the very least
+- thou shalt include your name with TODOs as in "TODO(termie)"
+- thou shalt not name anything the same name as a builtin or reserved word
+- thou shalt not violate causality in our time cone, or else
+
+
+Human Alphabetical Order Examples
+---------------------------------
+::
+ import httplib
+ import logging
+ import random
+ import StringIO
+ import time
+ import unittest
+
+ import httplib2
+
+ from keystoneclient import exceptions
+ from keystoneclient import service_catalog
+ from keystoneclient.v2_0 import client
+
+Docstrings
+----------
+ """A one line docstring looks like this and ends in a period."""
+
+
+ """A multiline docstring has a one-line summary, less than 80 characters.
+
+ Then a new paragraph after a newline that explains in more detail any
+ general information about the function, class or method. Example usages
+ are also great to have here if it is a complex class for function. After
+ you have finished your descriptions add an extra newline and close the
+ quotations.
+
+ When writing the docstring for a class, an extra line should be placed
+ after the closing quotations. For more in-depth explanations for these
+ decisions see http://www.python.org/dev/peps/pep-0257/
+
+ If you are going to describe parameters and return values, use Sphinx, the
+ appropriate syntax is as follows.
+
+ :param foo: the foo parameter
+ :param bar: the bar parameter
+ :returns: description of the return value
+
+ """
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..32b6611
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,209 @@
+Copyright (c) 2009 Jacob Kaplan-Moss - initial codebase (< v2.1)
+Copyright (c) 2011 Rackspace - OpenStack extensions (>= v2.1)
+Copyright (c) 2011 Nebula, Inc - Keystone refactor (>= v2.7)
+All rights reserved.
+
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+--- License for python-keystoneclient versions prior to 2.1 ---
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+ 1. Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+
+ 2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+
+ 3. Neither the name of this project nor the names of its contributors may
+ be used to endorse or promote products derived from this software without
+ specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..2526eeb
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,3 @@
+include README.rst
+recursive-include docs *
+recursive-include tests * \ No newline at end of file
diff --git a/README.rst b/README.rst
new file mode 100644
index 0000000..3c95150
--- /dev/null
+++ b/README.rst
@@ -0,0 +1,93 @@
+Python bindings to the OpenStack Keystone API
+=============================================
+
+This is a client for the OpenStack Keystone API. There's a Python API (the
+``keystoneclient`` module), and a command-line script (``keystone``). The
+Keystone 2.0 API is still a moving target, so this module will remain in
+"Beta" status until the API is finalized and fully implemented.
+
+Development takes place on GitHub__. Bug reports and patches may be filed there.
+
+__ https://github.com/4P/python-keystoneclient
+
+This code a fork of `Rackspace's python-novaclient`__ which is in turn a fork of
+`Jacobian's python-cloudservers`__. The python-keystoneclient is licensed under
+the Apache License like the rest of OpenStack.
+
+__ http://github.com/rackspace/python-novaclient
+__ http://github.com/jacobian/python-cloudservers
+
+.. contents:: Contents:
+ :local:
+
+Python API
+----------
+
+By way of a quick-start::
+
+ # use v2.0 auth with http://example.com:5000/v2.0/")
+ >>> from keystoneclient.v2_0 import client
+ >>> keystone = client.Client(USERNAME, API_KEY, PROJECT_ID)
+ >>> keystone.tenants.list()
+ >>> tenant = keystone.tenants.create(name="test", descrption="My new tenant!", enabled=True)
+ >>> tenant.delete()
+
+
+Command-line API
+----------------
+
+.. attention:: COMING SOON
+
+ The API is not yet implemented, but will follow the pattern laid
+ out below.
+
+Installing this package gets you a shell command, ``keystone``, that you
+can use to interact with Keystone's API.
+
+You'll need to provide your OpenStack username and API key. You can do this
+with the ``--username``, ``--apikey`` and ``--projectid`` params, but it's
+easier to just set them as environment variables::
+
+ export KEYSTONE_USERNAME=openstack
+ export KEYSTONE_API_KEY=yadayada
+ export KEYSTONE_PROJECTID=yadayada
+
+You will also need to define the authentication url with ``--url`` and the
+version of the API with ``--version``. Or set them as an environment
+variables as well::
+
+ export KEYSTONE_URL=http://example.com:5000/v2.0
+ export KEYSTONE_ADMIN_URL=http://example.com:35357/v2.0
+ export KEYSTONE_VERSION=2.0
+
+Since Keystone can return multiple regions in the Service Catalog, you
+can specify the one you want with ``--region_name`` (or
+``export KEYSTONE_REGION_NAME``). It defaults to the first in the list returned.
+
+You'll find complete documentation on the shell by running
+``keystone help``::
+
+ usage: keystone [--username USERNAME] [--apikey APIKEY] [--projectid PROJECTID]
+ [--url URL] [--version VERSION] [--region_name NAME]
+ <subcommand> ...
+
+ Command-line interface to the OpenStack Keystone API.
+
+ Positional arguments:
+ <subcommand>
+ add-fixed-ip Add a new fixed IP address to a servers network.
+
+
+ Optional arguments:
+ --username USERNAME Defaults to env[KEYSTONE_USERNAME].
+ --apikey APIKEY Defaults to env[KEYSTONE_API_KEY].
+ --apikey PROJECTID Defaults to env[KEYSTONE_PROJECT_ID].
+ --url AUTH_URL Defaults to env[KEYSTONE_URL] or
+ --url ADMIN_URL Defaults to env[KEYSTONE_ADMIN_URL]
+ --version VERSION Defaults to env[KEYSTONE_VERSION] or 2.0.
+ --region_name NAME The region name in the Keystone Service Catalog
+ to use after authentication. Defaults to
+ env[KEYSTONE_REGION_NAME] or the first item
+ in the list returned.
+
+ See "keystone help COMMAND" for help on a specific command.
diff --git a/docs/.gitignore b/docs/.gitignore
new file mode 100644
index 0000000..c6a151b
--- /dev/null
+++ b/docs/.gitignore
@@ -0,0 +1 @@
+_build/ \ No newline at end of file
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..56f0ded
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,89 @@
+# Makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS =
+SPHINXBUILD = sphinx-build
+PAPER =
+BUILDDIR = _build
+
+# Internal variables.
+PAPEROPT_a4 = -D latex_paper_size=a4
+PAPEROPT_letter = -D latex_paper_size=letter
+ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
+
+.PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest
+
+help:
+ @echo "Please use \`make <target>' where <target> is one of"
+ @echo " html to make standalone HTML files"
+ @echo " dirhtml to make HTML files named index.html in directories"
+ @echo " pickle to make pickle files"
+ @echo " json to make JSON files"
+ @echo " htmlhelp to make HTML files and a HTML help project"
+ @echo " qthelp to make HTML files and a qthelp project"
+ @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
+ @echo " changes to make an overview of all changed/added/deprecated items"
+ @echo " linkcheck to check all external links for integrity"
+ @echo " doctest to run all doctests embedded in the documentation (if enabled)"
+
+clean:
+ -rm -rf $(BUILDDIR)/*
+
+html:
+ $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
+ @echo
+ @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
+
+dirhtml:
+ $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
+ @echo
+ @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
+
+pickle:
+ $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
+ @echo
+ @echo "Build finished; now you can process the pickle files."
+
+json:
+ $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
+ @echo
+ @echo "Build finished; now you can process the JSON files."
+
+htmlhelp:
+ $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
+ @echo
+ @echo "Build finished; now you can run HTML Help Workshop with the" \
+ ".hhp project file in $(BUILDDIR)/htmlhelp."
+
+qthelp:
+ $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
+ @echo
+ @echo "Build finished; now you can run "qcollectiongenerator" with the" \
+ ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
+ @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/python-keystoneclient.qhcp"
+ @echo "To view the help file:"
+ @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/python-keystoneclient.qhc"
+
+latex:
+ $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+ @echo
+ @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
+ @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \
+ "run these through (pdf)latex."
+
+changes:
+ $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
+ @echo
+ @echo "The overview file is in $(BUILDDIR)/changes."
+
+linkcheck:
+ $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
+ @echo
+ @echo "Link check complete; look for any errors in the above output " \
+ "or in $(BUILDDIR)/linkcheck/output.txt."
+
+doctest:
+ $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
+ @echo "Testing of doctests in the sources finished, look at the " \
+ "results in $(BUILDDIR)/doctest/output.txt."
diff --git a/docs/api.rst b/docs/api.rst
new file mode 100644
index 0000000..5e38c8b
--- /dev/null
+++ b/docs/api.rst
@@ -0,0 +1,19 @@
+The :mod:`keystoneclient` Python API
+====================================
+
+.. module:: keystoneclient
+ :synopsis: A client for the OpenStack Keystone API.
+
+.. currentmodule:: keystoneclient.v2_0.client
+
+.. autoclass:: Client
+
+ .. automethod:: authenticate
+
+
+For more information, see the reference documentation:
+
+.. toctree::
+ :maxdepth: 2
+
+ ref/index
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 0000000..cfaf189
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,200 @@
+# -*- coding: utf-8 -*-
+#
+# python-keystoneclient documentation build configuration file, created by
+# sphinx-quickstart on Sun Dec 6 14:19:25 2009.
+#
+# 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
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+# 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.append(os.path.abspath('.'))
+
+# -- General configuration -----------------------------------------------------
+
+# 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', 'sphinx.ext.intersphinx']
+
+# 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'
+
+# The master toctree document.
+master_doc = 'index'
+
+# General information about the project.
+project = u'python-keystoneclient'
+copyright = u'Rackspace, based on work by Jacob Kaplan-Moss'
+
+# 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 = '2.7'
+# The full version, including alpha/beta/rc tags.
+release = '2.7.0'
+
+# 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 documents that shouldn't be included in the build.
+#unused_docs = []
+
+# List of directories, relative to source directory, that shouldn't be searched
+# for source files.
+exclude_trees = ['_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. Major themes that come with
+# Sphinx are currently 'default' and 'sphinxdoc'.
+html_theme = 'nature'
+
+# 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
+# "<project> v<release> 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_use_modindex = 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, an OpenSearch description file will be output, and all pages will
+# contain a <link> tag referring to it. The value of this option must be the
+# base URL from which the finished HTML is served.
+#html_use_opensearch = ''
+
+# If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml").
+#html_file_suffix = ''
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = 'python-keystoneclientdoc'
+
+
+# -- Options for LaTeX output --------------------------------------------------
+
+# The paper size ('letter' or 'a4').
+#latex_paper_size = 'letter'
+
+# The font size ('10pt', '11pt' or '12pt').
+#latex_font_size = '10pt'
+
+# Grouping the document tree into LaTeX files. List of tuples
+# (source start file, target name, title, author, documentclass [howto/manual]).
+latex_documents = [
+ ('index', 'python-keystoneclient.tex', u'python-keystoneclient Documentation',
+ u'Nebula Inc, based on work by Rackspace and Jacob Kaplan-Moss', '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
+
+# Additional stuff for the LaTeX preamble.
+#latex_preamble = ''
+
+# Documents to append as an appendix to all manuals.
+#latex_appendices = []
+
+# If false, no module index is generated.
+#latex_use_modindex = True
+
+
+# Example configuration for intersphinx: refer to the Python standard library.
+intersphinx_mapping = {'http://docs.python.org/': None}
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 0000000..87e5f5e
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,36 @@
+Python bindings to the OpenStack Keystone API
+==================================================
+
+This is a client for OpenStack Keystone API. There's :doc:`a Python API
+<api>` (the :mod:`keystoneclient` module), and a :doc:`command-line script
+<shell>` (installed as :program:`keystone`).
+
+You'll need an `OpenStack Keystone` account, which you can get by
+using `keystone-manage`.
+
+Contents:
+
+.. toctree::
+ :maxdepth: 2
+
+ shell
+ api
+ ref/index
+ releases
+
+Contributing
+============
+
+Development takes place `on GitHub`__; please file bugs/pull requests there.
+
+__ https://github.com/4P/python-keystoneclient
+
+Run tests with ``python setup.py test``.
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
+
diff --git a/docs/ref/exceptions.rst b/docs/ref/exceptions.rst
new file mode 100644
index 0000000..988f7d0
--- /dev/null
+++ b/docs/ref/exceptions.rst
@@ -0,0 +1,8 @@
+Exceptions
+==========
+
+.. currentmodule:: keystoneclient.exceptions
+
+.. automodule:: keystoneclient.exceptions
+ :members:
+
diff --git a/docs/ref/index.rst b/docs/ref/index.rst
new file mode 100644
index 0000000..677a9c2
--- /dev/null
+++ b/docs/ref/index.rst
@@ -0,0 +1,9 @@
+API Reference
+=============
+
+The following API reference documents are available:
+
+.. toctree::
+ :maxdepth: 1
+
+ exceptions
diff --git a/docs/releases.rst b/docs/releases.rst
new file mode 100644
index 0000000..4a9d281
--- /dev/null
+++ b/docs/releases.rst
@@ -0,0 +1,106 @@
+=============
+Release notes
+=============
+
+2.7.0 (October 21, 2011)
+========================
+* Forked from http://github.com/rackspace/python-novaclient
+* Rebranded to python-keystoneclient
+* Refactored to support Keystone API (auth, tokens, services, roles, tenants,
+ users, etc.)
+
+2.5.8 (July 11, 2011)
+=====================
+* returns all public/private ips, not just first one
+* better 'nova list' search options
+
+2.5.7 - 2.5.6 = minor tweaks
+
+2.5.5 (June 21, 2011)
+=====================
+* zone-boot min/max instance count added thanks to comstud
+* create for user added thanks to cerberus
+* fixed tests
+
+2.5.3 (June 15, 2011)
+=====================
+* ProjectID can be None for backwards compatability.
+* README/docs updated for projectId thanks to usrleon
+
+2.5.1 (June 10, 2011)
+=====================
+* ProjectID now part of authentication
+
+2.5.0 (June 3, 2011)
+====================
+
+* better logging thanks to GridDynamics
+
+2.4.4 (June 1, 2011)
+====================
+
+* added support for GET /servers with reservation_id (and /servers/detail)
+
+2.4.3 (May 27, 2011)
+====================
+
+* added support for POST /zones/select (client only, not cmdline)
+
+2.4 (March 7, 2011)
+===================
+
+* added Jacob Kaplan-Moss copyright notices to older/untouched files.
+
+
+2.3 (March 2, 2011)
+===================
+
+* package renamed to python-novaclient. Module to novaclient
+
+
+2.2 (March 1, 2011)
+===================
+
+* removed some license/copywrite notices from source that wasn't
+ significantly changed.
+
+
+2.1 (Feb 28, 2011)
+==================
+
+* shell renamed to nova from novatools
+
+* license changed from BSD to Apache
+
+2.0 (Feb 7, 2011)
+=================
+
+* Forked from https://github.com/jacobian/python-cloudservers
+
+* Rebranded to python-novatools
+
+* Auth URL support
+
+* New OpenStack specific commands added (pause, suspend, etc)
+
+1.2 (August 15, 2010)
+=====================
+
+* Support for Python 2.4 - 2.7.
+
+* Improved output of :program:`cloudservers ipgroup-list`.
+
+* Made ``cloudservers boot --ipgroup <name>`` work (as well as ``--ipgroup
+ <id>``).
+
+1.1 (May 6, 2010)
+=================
+
+* Added a ``--files`` option to :program:`cloudservers boot` supporting
+ the upload of (up to five) files at boot time.
+
+* Added a ``--key`` option to :program:`cloudservers boot` to key the server
+ with an SSH public key at boot time. This is just a shortcut for ``--files``,
+ but it's a useful shortcut.
+
+* Changed the default server image to Ubuntu 10.04 LTS.
diff --git a/docs/shell.rst b/docs/shell.rst
new file mode 100644
index 0000000..fb1ea74
--- /dev/null
+++ b/docs/shell.rst
@@ -0,0 +1,57 @@
+The :program:`keystone` shell utility
+=========================================
+
+.. program:: keystone
+.. highlight:: bash
+
+.. warning:: COMING SOON
+
+ The command line interface is not yet completed. This document serves
+ as a reference for the implementation.
+
+The :program:`keystone` shell utility interacts with OpenStack Keystone API
+from the command line. It supports the entirety of the OpenStack Keystone API.
+
+First, you'll need an OpenStack Keystone account and an API key. You get this
+by using the `keystone-manage` command in OpenStack Keystone.
+
+You'll need to provide :program:`keystone` with your OpenStack username and
+API key. You can do this with the :option:`--username`, :option:`--apikey`
+and :option:`--projectid` options, but it's easier to just set them as
+environment variables by setting two environment variables:
+
+.. envvar:: KEYSTONE_USERNAME
+
+ Your Keystone username.
+
+.. envvar:: KEYSTONE_API_KEY
+
+ Your API key.
+
+.. envvar:: KEYSTONE_PROJECT_ID
+
+ Project for work.
+
+.. envvar:: KEYSTONE_URL
+
+ The OpenStack API server URL.
+
+.. envvar:: KEYSTONE_VERSION
+
+ The OpenStack API version.
+
+For example, in Bash you'd use::
+
+ export KEYSTONE_USERNAME=yourname
+ export KEYSTONE_API_KEY=yadayadayada
+ export KEYSTONE_PROJECT_ID=myproject
+ export KEYSTONE_URL=http://...
+ export KEYSTONE_VERSION=2.0
+
+From there, all shell commands take the form::
+
+ keystone <command> [arguments...]
+
+Run :program:`keystone help` to get a full list of all possible commands,
+and run :program:`keystone help <command>` to get detailed help for that
+command.
diff --git a/keystoneclient/__init__.py b/keystoneclient/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/keystoneclient/__init__.py
diff --git a/keystoneclient/base.py b/keystoneclient/base.py
new file mode 100644
index 0000000..7daf997
--- /dev/null
+++ b/keystoneclient/base.py
@@ -0,0 +1,191 @@
+# Copyright 2010 Jacob Kaplan-Moss
+# Copyright 2011 OpenStack LLC.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+"""
+Base utilities to build API operation managers and objects on top of.
+"""
+
+from keystoneclient import exceptions
+
+
+# Python 2.4 compat
+try:
+ all
+except NameError:
+ def all(iterable):
+ return True not in (not x for x in iterable)
+
+
+def getid(obj):
+ """
+ Abstracts the common pattern of allowing both an object or an object's ID
+ (UUID) as a parameter when dealing with relationships.
+ """
+
+ # Try to return the object's UUID first, if we have a UUID.
+ try:
+ if obj.uuid:
+ return obj.uuid
+ except AttributeError:
+ pass
+ try:
+ return obj.id
+ except AttributeError:
+ return obj
+
+
+class Manager(object):
+ """
+ Managers interact with a particular type of API (servers, flavors, images,
+ etc.) and provide CRUD operations for them.
+ """
+ resource_class = None
+
+ def __init__(self, api):
+ self.api = api
+
+ def _list(self, url, response_key, obj_class=None, body=None):
+ resp = None
+ if body:
+ resp, body = self.api.post(url, body=body)
+ else:
+ resp, body = self.api.get(url)
+
+ if obj_class is None:
+ obj_class = self.resource_class
+
+ data = body[response_key]
+ # NOTE(ja): keystone returns values as list as {'values': [ ... ]}
+ # unlike other services which just return the list...
+ if type(data) is dict:
+ data = data['values']
+ return [obj_class(self, res, loaded=True) for res in data if res]
+
+ def _get(self, url, response_key):
+ resp, body = self.api.get(url)
+ return self.resource_class(self, body[response_key])
+
+ def _create(self, url, body, response_key, return_raw=False):
+ resp, body = self.api.post(url, body=body)
+ if return_raw:
+ return body[response_key]
+ return self.resource_class(self, body[response_key])
+
+ def _delete(self, url):
+ resp, body = self.api.delete(url)
+
+ def _update(self, url, body, response_key):
+ resp, body = self.api.put(url, body=body)
+ return self.resource_class(self, body[response_key])
+
+
+class ManagerWithFind(Manager):
+ """
+ Like a `Manager`, but with additional `find()`/`findall()` methods.
+ """
+ def find(self, **kwargs):
+ """
+ Find a single item with attributes matching ``**kwargs``.
+
+ This isn't very efficient: it loads the entire list then filters on
+ the Python side.
+ """
+ rl = self.findall(**kwargs)
+ try:
+ return rl[0]
+ except IndexError:
+ msg = "No %s matching %s." % (self.resource_class.__name__, kwargs)
+ raise exceptions.NotFound(404, msg)
+
+ def findall(self, **kwargs):
+ """
+ Find all items with attributes matching ``**kwargs``.
+
+ This isn't very efficient: it loads the entire list then filters on
+ the Python side.
+ """
+ found = []
+ searches = kwargs.items()
+
+ for obj in self.list():
+ try:
+ if all(getattr(obj, attr) == value
+ for (attr, value) in searches):
+ found.append(obj)
+ except AttributeError:
+ continue
+
+ return found
+
+
+class Resource(object):
+ """
+ A resource represents a particular instance of an object (tenant, user,
+ etc). This is pretty much just a bag for attributes.
+
+ :param manager: Manager object
+ :param info: dictionary representing resource attributes
+ :param loaded: prevent lazy-loading if set to True
+ """
+ def __init__(self, manager, info, loaded=False):
+ self.manager = manager
+ self._info = info
+ self._add_details(info)
+ self._loaded = loaded
+
+ def _add_details(self, info):
+ for (k, v) in info.iteritems():
+ setattr(self, k, v)
+
+ def __getattr__(self, k):
+ if k not in self.__dict__:
+ #NOTE(bcwaldon): disallow lazy-loading if already loaded once
+ if not self.is_loaded():
+ self.get()
+ return self.__getattr__(k)
+
+ raise AttributeError(k)
+ else:
+ return self.__dict__[k]
+
+ def __repr__(self):
+ reprkeys = sorted(k for k in self.__dict__.keys() if k[0] != '_' and
+ k != 'manager')
+ info = ", ".join("%s=%s" % (k, getattr(self, k)) for k in reprkeys)
+ return "<%s %s>" % (self.__class__.__name__, info)
+
+ def get(self):
+ # set_loaded() first ... so if we have to bail, we know we tried.
+ self.set_loaded(True)
+ if not hasattr(self.manager, 'get'):
+ return
+
+ new = self.manager.get(self.id)
+ if new:
+ self._add_details(new._info)
+
+ def __eq__(self, other):
+ if not isinstance(other, self.__class__):
+ return False
+ if hasattr(self, 'id') and hasattr(other, 'id'):
+ return self.id == other.id
+ return self._info == other._info
+
+ def is_loaded(self):
+ return self._loaded
+
+ def set_loaded(self, val):
+ self._loaded = val
diff --git a/keystoneclient/client.py b/keystoneclient/client.py
new file mode 100644
index 0000000..7eb9002
--- /dev/null
+++ b/keystoneclient/client.py
@@ -0,0 +1,189 @@
+# Copyright 2010 Jacob Kaplan-Moss
+# Copyright 2011 OpenStack LLC.
+# Copyright 2011 Piston Cloud Computing, Inc.
+# Copyright 2011 Nebula, Inc.
+
+# All Rights Reserved.
+"""
+OpenStack Client interface. Handles the REST calls and responses.
+"""
+
+import copy
+import logging
+import os
+import time
+import urllib
+import urlparse
+
+import httplib2
+
+try:
+ import json
+except ImportError:
+ import simplejson as json
+
+# Python 2.5 compat fix
+if not hasattr(urlparse, 'parse_qsl'):
+ import cgi
+ urlparse.parse_qsl = cgi.parse_qsl
+
+
+from keystoneclient import exceptions
+
+
+_logger = logging.getLogger(__name__)
+
+
+class HTTPClient(httplib2.Http):
+
+ USER_AGENT = 'python-keystoneclient'
+
+ def __init__(self, username=None, password=None, token=None,
+ project_id=None, auth_url=None, region_name=None,
+ timeout=None, endpoint=None):
+ super(HTTPClient, self).__init__(timeout=timeout)
+ self.user = username
+ self.password = password
+ self.project_id = project_id
+ self.auth_url = auth_url
+ self.version = 'v2.0'
+ self.region_name = region_name
+
+ self.management_url = endpoint
+ self.auth_token = token or password
+
+ # httplib2 overrides
+ self.force_exception_to_status_code = True
+
+ def authenticate(self):
+ """ Authenticate against the keystone API.
+
+ Not implemented here because auth protocols should be API
+ version-specific.
+ """
+ raise NotImplementedError
+
+ def _extract_service_catalog(self, url, body):
+ """ Set the client's service catalog from the response data.
+
+ Not implemented here because data returned may be API
+ version-specific.
+ """
+ raise NotImplementedError
+
+ def http_log(self, args, kwargs, resp, body):
+ if os.environ.get('KEYSTONECLIENT_DEBUG', False):
+ ch = logging.StreamHandler()
+ _logger.setLevel(logging.DEBUG)
+ _logger.addHandler(ch)
+ elif not _logger.isEnabledFor(logging.DEBUG):
+ return
+
+ string_parts = ['curl -i']
+ for element in args:
+ if element in ('GET', 'POST'):
+ string_parts.append(' -X %s' % element)
+ else:
+ string_parts.append(' %s' % element)
+
+ for element in kwargs['headers']:
+ header = ' -H "%s: %s"' % (element, kwargs['headers'][element])
+ string_parts.append(header)
+
+ _logger.debug("REQ: %s\n" % "".join(string_parts))
+ if 'body' in kwargs:
+ _logger.debug("REQ BODY: %s\n" % (kwargs['body']))
+ _logger.debug("RESP: %s\nRESP BODY: %s\n", resp, body)
+
+ def request(self, url, method, **kwargs):
+ """ Send an http request with the specified characteristics.
+
+ Wrapper around httplib2.Http.request to handle tasks such as
+ setting headers, JSON encoding/decoding, and error handling.
+ """
+ # Copy the kwargs so we can reuse the original in case of redirects
+ request_kwargs = copy.copy(kwargs)
+ request_kwargs.setdefault('headers', kwargs.get('headers', {}))
+ request_kwargs['headers']['User-Agent'] = self.USER_AGENT
+ if 'body' in kwargs:
+ request_kwargs['headers']['Content-Type'] = 'application/json'
+ request_kwargs['body'] = json.dumps(kwargs['body'])
+
+ resp, body = super(HTTPClient, self).request(url, method, **request_kwargs)
+
+ self.http_log((url, method,), request_kwargs, resp, body)
+
+ if body:
+ try:
+ body = json.loads(body)
+ except ValueError, e:
+ _logger.debug("Could not decode JSON from body: %s" % body)
+ else:
+ _logger.debug("No body was returned.")
+ body = None
+
+ if resp.status in (400, 401, 403, 404, 408, 409, 413, 500, 501):
+ raise exceptions.from_response(resp, body)
+ elif resp.status in (301, 302, 305):
+ # Redirected. Reissue the request to the new location.
+ return self.request(resp['location'], method, **kwargs)
+
+ return resp, body
+
+ def _cs_request(self, url, method, **kwargs):
+ if not self.management_url:
+ self.authenticate()
+
+ kwargs.setdefault('headers', {})
+ if self.auth_token and self.auth_token != self.password:
+ kwargs['headers']['X-Auth-Token'] = self.auth_token
+ if self.project_id:
+ kwargs['headers']['X-Auth-Project-Id'] = self.project_id
+
+ # Perform the request once. If we get a 401 back then it
+ # might be because the auth token expired, so try to
+ # re-authenticate and try again. If it still fails, bail.
+ try:
+ resp, body = self.request(self.management_url + url, method,
+ **kwargs)
+ return resp, body
+ except exceptions.Unauthorized:
+ try:
+ if getattr(self, '_failures', 0) < 1:
+ self._failures = getattr(self, '_failures', 0) + 1
+ self.authenticate()
+ resp, body = self.request(self.management_url + url,
+ method, **kwargs)
+ return resp, body
+ else:
+ raise
+ except exceptions.Unauthorized:
+ raise
+
+ def _munge_get_url(self, url):
+ """
+ Munge GET URLs to always return uncached content.
+
+ The OpenStack Compute API caches data *very* agressively and doesn't
+ respect cache headers. To avoid stale data, then, we append a little
+ bit of nonsense onto GET parameters; this appears to force the data not
+ to be cached.
+ """
+ scheme, netloc, path, query, frag = urlparse.urlsplit(url)
+ query = urlparse.parse_qsl(query)
+ query.append(('fresh', str(time.time())))
+ query = urllib.urlencode(query)
+ return urlparse.urlunsplit((scheme, netloc, path, query, frag))
+
+ def get(self, url, **kwargs):
+ url = self._munge_get_url(url)
+ return self._cs_request(url, 'GET', **kwargs)
+
+ def post(self, url, **kwargs):
+ return self._cs_request(url, 'POST', **kwargs)
+
+ def put(self, url, **kwargs):
+ return self._cs_request(url, 'PUT', **kwargs)
+
+ def delete(self, url, **kwargs):
+ return self._cs_request(url, 'DELETE', **kwargs)
diff --git a/keystoneclient/exceptions.py b/keystoneclient/exceptions.py
new file mode 100644
index 0000000..8f8ea57
--- /dev/null
+++ b/keystoneclient/exceptions.py
@@ -0,0 +1,129 @@
+# Copyright 2010 Jacob Kaplan-Moss
+# Copyright 2011 Nebula, Inc.
+"""
+Exception definitions.
+"""
+
+
+class CommandError(Exception):
+ pass
+
+
+class AuthorizationFailure(Exception):
+ pass
+
+
+class NoTokenLookupException(Exception):
+ """This form of authentication does not support looking up
+ endpoints from an existing token."""
+ pass
+
+
+class EndpointNotFound(Exception):
+ """Could not find Service or Region in Service Catalog."""
+ pass
+
+
+class ClientException(Exception):
+ """
+ The base exception class for all exceptions this library raises.
+ """
+ def __init__(self, code, message=None, details=None):
+ self.code = code
+ self.message = message or self.__class__.message
+ self.details = details
+
+ def __str__(self):
+ return "%s (HTTP %s)" % (self.message, self.code)
+
+
+class BadRequest(ClientException):
+ """
+ HTTP 400 - Bad request: you sent some malformed data.
+ """
+ http_status = 400
+ message = "Bad request"
+
+
+class Unauthorized(ClientException):
+ """
+ HTTP 401 - Unauthorized: bad credentials.
+ """
+ http_status = 401
+ message = "Unauthorized"
+
+
+class Forbidden(ClientException):
+ """
+ HTTP 403 - Forbidden: your credentials don't give you access to this
+ resource.
+ """
+ http_status = 403
+ message = "Forbidden"
+
+
+class NotFound(ClientException):
+ """
+ HTTP 404 - Not found
+ """
+ http_status = 404
+ message = "Not found"
+
+
+class Conflict(ClientException):
+ """
+ HTTP 409 - Conflict
+ """
+ http_status = 409
+ message = "Conflict"
+
+
+class OverLimit(ClientException):
+ """
+ HTTP 413 - Over limit: you're over the API limits for this time period.
+ """
+ http_status = 413
+ message = "Over limit"
+
+
+# NotImplemented is a python keyword.
+class HTTPNotImplemented(ClientException):
+ """
+ HTTP 501 - Not Implemented: the server does not support this operation.
+ """
+ http_status = 501
+ message = "Not Implemented"
+
+
+# In Python 2.4 Exception is old-style and thus doesn't have a __subclasses__()
+# so we can do this:
+# _code_map = dict((c.http_status, c)
+# for c in ClientException.__subclasses__())
+#
+# Instead, we have to hardcode it:
+_code_map = dict((c.http_status, c) for c in [BadRequest, Unauthorized,
+ Forbidden, NotFound, OverLimit, HTTPNotImplemented])
+
+
+def from_response(response, body):
+ """
+ Return an instance of an ClientException or subclass
+ based on an httplib2 response.
+
+ Usage::
+
+ resp, body = http.request(...)
+ if resp.status != 200:
+ raise exception_from_response(resp, body)
+ """
+ cls = _code_map.get(response.status, ClientException)
+ if body:
+ message = "n/a"
+ details = "n/a"
+ if hasattr(body, 'keys'):
+ error = body[body.keys()[0]]
+ message = error.get('message', None)
+ details = error.get('details', None)
+ return cls(code=response.status, message=message, details=details)
+ else:
+ return cls(code=response.status)
diff --git a/keystoneclient/service_catalog.py b/keystoneclient/service_catalog.py
new file mode 100644
index 0000000..0e7cd88
--- /dev/null
+++ b/keystoneclient/service_catalog.py
@@ -0,0 +1,53 @@
+# Copyright 2011 OpenStack LLC.
+# Copyright 2011, Piston Cloud Computing, Inc.
+# Copyright 2011 Nebula, Inc.
+#
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+from keystoneclient import exceptions
+
+
+class ServiceCatalog(object):
+ """Helper methods for dealing with a Keystone Service Catalog."""
+
+ def __init__(self, resource_dict):
+ self.catalog = resource_dict
+
+ def get_token(self):
+ return self.catalog['token']['id']
+
+ def url_for(self, attr=None, filter_value=None,
+ service_type='identity', endpoint_type='publicURL'):
+ """Fetch an endpoint from the service catalog.
+
+ Fetch the specified endpoint from the service catalog for
+ a particular endpoint attribute. If no attribute is given, return
+ the first endpoint of the specified type.
+
+ See tests for a sample service catalog.
+ """
+ catalog = self.catalog.get('serviceCatalog', [])
+
+ for service in catalog:
+ if service['type'] != service_type:
+ continue
+
+ endpoints = service['endpoints']
+ for endpoint in endpoints:
+ if not filter_value or endpoint[attr] == filter_value:
+ return endpoint[endpoint_type]
+
+ raise exceptions.EndpointNotFound('Endpoint not found.')
diff --git a/keystoneclient/shell.py b/keystoneclient/shell.py
new file mode 100644
index 0000000..182a9c5
--- /dev/null
+++ b/keystoneclient/shell.py
@@ -0,0 +1,228 @@
+# Copyright 2010 Jacob Kaplan-Moss
+# Copyright 2011 OpenStack LLC.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+"""
+Command-line interface to the OpenStack Keystone API.
+"""
+
+import argparse
+import httplib2
+import os
+import prettytable
+import sys
+
+from keystoneclient import exceptions as exc
+from keystoneclient import utils
+from keystoneclient.v2_0 import shell as shell_v2_0
+
+
+def env(e):
+ return os.environ.get(e, '')
+
+
+class OpenStackIdentityShell(object):
+
+ def get_base_parser(self):
+ parser = argparse.ArgumentParser(
+ prog='keystone',
+ description=__doc__.strip(),
+ epilog='See "keystone help COMMAND" '\
+ 'for help on a specific command.',
+ add_help=False,
+ formatter_class=OpenStackHelpFormatter,
+ )
+
+ # Global arguments
+ parser.add_argument('-h', '--help',
+ action='help',
+ help=argparse.SUPPRESS,
+ )
+
+ parser.add_argument('--debug',
+ default=False,
+ action='store_true',
+ help=argparse.SUPPRESS)
+
+ parser.add_argument('--username',
+ default=env('KEYSTONE_USERNAME'),
+ help='Defaults to env[KEYSTONE_USERNAME].')
+
+ parser.add_argument('--apikey',
+ default=env('KEYSTONE_API_KEY'),
+ help='Defaults to env[KEYSTONE_API_KEY].')
+
+ parser.add_argument('--projectid',
+ default=env('KEYSTONE_PROJECT_ID'),
+ help='Defaults to env[KEYSTONE_PROJECT_ID].')
+
+ parser.add_argument('--url',
+ default=env('KEYSTONE_URL'),
+ help='Defaults to env[KEYSTONE_URL].')
+
+ parser.add_argument('--region_name',
+ default=env('KEYSTONE_REGION_NAME'),
+ help='Defaults to env[KEYSTONE_REGION_NAME].')
+
+ parser.add_argument('--version',
+ default=env('KEYSTONE_VERSION'),
+ help='Accepts 1.0 or 1.1, defaults to env[KEYSTONE_VERSION].')
+
+ return parser
+
+ def get_subcommand_parser(self, version):
+ parser = self.get_base_parser()
+
+ self.subcommands = {}
+ subparsers = parser.add_subparsers(metavar='<subcommand>')
+
+ try:
+ actions_module = {
+ '2.0': shell_v2_0,
+ }[version]
+ except KeyError:
+ actions_module = shell_v2_0
+
+ self._find_actions(subparsers, actions_module)
+ self._find_actions(subparsers, self)
+
+ return parser
+
+ def _find_actions(self, subparsers, actions_module):
+ for attr in (a for a in dir(actions_module) if a.startswith('do_')):
+ # I prefer to be hypen-separated instead of underscores.
+ command = attr[3:].replace('_', '-')
+ callback = getattr(actions_module, attr)
+ desc = callback.__doc__ or ''
+ help = desc.strip().split('\n')[0]
+ arguments = getattr(callback, 'arguments', [])
+
+ subparser = subparsers.add_parser(command,
+ help=help,
+ description=desc,
+ add_help=False,
+ formatter_class=OpenStackHelpFormatter
+ )
+ subparser.add_argument('-h', '--help',
+ action='help',
+ help=argparse.SUPPRESS,
+ )
+ self.subcommands[command] = subparser
+ for (args, kwargs) in arguments:
+ subparser.add_argument(*args, **kwargs)
+ subparser.set_defaults(func=callback)
+
+ def main(self, argv):
+ # Parse args once to find version
+ parser = self.get_base_parser()
+ (options, args) = parser.parse_known_args(argv)
+
+ # build available subcommands based on version
+ subcommand_parser = self.get_subcommand_parser(options.version)
+ self.parser = subcommand_parser
+
+ # Parse args again and call whatever callback was selected
+ args = subcommand_parser.parse_args(argv)
+
+ # Deal with global arguments
+ if args.debug:
+ httplib2.debuglevel = 1
+
+ # Short-circuit and deal with help right away.
+ if args.func == self.do_help:
+ self.do_help(args)
+ return 0
+
+ user, apikey, projectid, url, region_name = \
+ args.username, args.apikey, args.projectid, args.url, \
+ args.region_name
+
+ #FIXME(usrleon): Here should be restrict for project id same as
+ # for username or apikey but for compatibility it is not.
+
+ if not user:
+ raise exc.CommandError("You must provide a username, either"
+ "via --username or via "
+ "env[KEYSTONE_USERNAME]")
+ if not apikey:
+ raise exc.CommandError("You must provide an API key, either"
+ "via --apikey or via"
+ "env[KEYSTONE_API_KEY]")
+ if options.version and options.version != '1.0':
+ if not projectid:
+ raise exc.CommandError("You must provide an projectid, either"
+ "via --projectid or via"
+ "env[KEYSTONE_PROJECT_ID")
+
+ if not url:
+ raise exc.CommandError("You must provide a auth url, either"
+ "via --url or via"
+ "env[KEYSTONE_URL")
+
+ self.cs = self.get_api_class(options.version) \
+ (user, apikey, projectid, url,
+ region_name=region_name)
+
+ try:
+ self.cs.authenticate()
+ except exc.Unauthorized:
+ raise exc.CommandError("Invalid OpenStack Keystone credentials.")
+ except exc.AuthorizationFailure:
+ raise exc.CommandError("Unable to authorize user")
+
+ args.func(self.cs, args)
+
+ def get_api_class(self, version):
+ try:
+ return {
+ "2.0": shell_v2_0.CLIENT_CLASS,
+ }[version]
+ except KeyError:
+ return shell_v2_0.CLIENT_CLASS
+
+ @utils.arg('command', metavar='<subcommand>', nargs='?',
+ help='Display help for <subcommand>')
+ def do_help(self, args):
+ """
+ Display help about this program or one of its subcommands.
+ """
+ if args.command:
+ if args.command in self.subcommands:
+ self.subcommands[args.command].print_help()
+ else:
+ raise exc.CommandError("'%s' is not a valid subcommand" %
+ args.command)
+ else:
+ self.parser.print_help()
+
+
+# I'm picky about my shell help.
+class OpenStackHelpFormatter(argparse.HelpFormatter):
+ def start_section(self, heading):
+ # Title-case the headings
+ heading = '%s%s' % (heading[0].upper(), heading[1:])
+ super(OpenStackHelpFormatter, self).start_section(heading)
+
+
+def main():
+ try:
+ OpenStackIdentityShell().main(sys.argv[1:])
+
+ except Exception, e:
+ if httplib2.debuglevel == 1:
+ raise # dump stack.
+ else:
+ print >> sys.stderr, e
+ sys.exit(1)
diff --git a/keystoneclient/utils.py b/keystoneclient/utils.py
new file mode 100644
index 0000000..75a9e08
--- /dev/null
+++ b/keystoneclient/utils.py
@@ -0,0 +1,69 @@
+import uuid
+
+import prettytable
+
+from keystoneclient import exceptions
+
+
+# Decorator for cli-args
+def arg(*args, **kwargs):
+ def _decorator(func):
+ # Because of the sematics of decorator composition if we just append
+ # to the options list positional options will appear to be backwards.
+ func.__dict__.setdefault('arguments', []).insert(0, (args, kwargs))
+ return func
+ return _decorator
+
+
+def pretty_choice_list(l):
+ return ', '.join("'%s'" % i for i in l)
+
+
+def print_list(objs, fields, formatters={}):
+ pt = prettytable.PrettyTable([f for f in fields], caching=False)
+ pt.aligns = ['l' for f in fields]
+
+ for o in objs:
+ row = []
+ for field in fields:
+ if field in formatters:
+ row.append(formatters[field](o))
+ else:
+ field_name = field.lower().replace(' ', '_')
+ data = getattr(o, field_name, '')
+ row.append(data)
+ pt.add_row(row)
+
+ pt.printt(sortby=fields[0])
+
+
+def print_dict(d):
+ pt = prettytable.PrettyTable(['Property', 'Value'], caching=False)
+ pt.aligns = ['l', 'l']
+ [pt.add_row(list(r)) for r in d.iteritems()]
+ pt.printt(sortby='Property')
+
+
+def find_resource(manager, name_or_id):
+ """Helper for the _find_* methods."""
+ # first try to get entity as integer id
+ try:
+ if isinstance(name_or_id, int) or name_or_id.isdigit():
+ return manager.get(int(name_or_id))
+ except exceptions.NotFound:
+ pass
+
+ # now try to get entity as uuid
+ try:
+ uuid.UUID(str(name_or_id))
+ return manager.get(name_or_id)
+ except (ValueError, exceptions.NotFound):
+ pass
+
+ # finally try to find entity by name
+ try:
+ return manager.find(name=name_or_id)
+ except exceptions.NotFound:
+ msg = "No %s with a name or ID of '%s' exists." % \
+ (manager.resource_class.__name__.lower(), name_or_id)
+ raise exceptions.CommandError(msg)
diff --git a/keystoneclient/v2_0/__init__.py b/keystoneclient/v2_0/__init__.py
new file mode 100644
index 0000000..71b40c2
--- /dev/null
+++ b/keystoneclient/v2_0/__init__.py
@@ -0,0 +1,2 @@
+from keystoneclient.v2_0.client import Client
+
diff --git a/keystoneclient/v2_0/client.py b/keystoneclient/v2_0/client.py
new file mode 100644
index 0000000..314a3a6
--- /dev/null
+++ b/keystoneclient/v2_0/client.py
@@ -0,0 +1,111 @@
+# Copyright 2011 Nebula, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+import urlparse
+import logging
+
+from keystoneclient import client
+from keystoneclient import exceptions
+from keystoneclient import service_catalog
+from keystoneclient.v2_0 import roles
+from keystoneclient.v2_0 import services
+from keystoneclient.v2_0 import tenants
+from keystoneclient.v2_0 import tokens
+from keystoneclient.v2_0 import users
+
+
+_logger = logging.getLogger(__name__)
+
+
+class Client(client.HTTPClient):
+ """Client for the OpenStack Keystone v2.0 API.
+
+ :param string username: Username for authentication. (optional)
+ :param string password: Password for authentication. (optional)
+ :param string token: Token for authentication. (optional)
+ :param string project_id: Tenant/Project id. (optional)
+ :param string auth_url: Keystone service endpoint for authorization.
+ :param string region_name: Name of a region to select when choosing an
+ endpoint from the service catalog.
+ :param string endpoint: A user-supplied endpoint URL for the keystone service.
+ Lazy-authentication is possible for API service calls
+ if endpoint is set at instantiation.(optional)
+ :param integer timeout: Allows customization of the timeout for client
+ http requests. (optional)
+
+ Example::
+
+ >>> from keystoneclient.v2_0 import client
+ >>> keystone = client.Client(username=USER, password=PASS, project_id=TENANT, auth_url=KEYSTONE_URL)
+ >>> keystone.tenants.list()
+ ...
+ >>> user = keystone.users.get(USER_ID)
+ >>> user.delete()
+
+ """
+
+ def __init__(self, endpoint=None, **kwargs):
+ """ Initialize a new client for the Keystone v2.0 API. """
+ super(Client, self).__init__(endpoint=endpoint, **kwargs)
+ self.roles = roles.RoleManager(self)
+ self.services = services.ServiceManager(self)
+ self.tenants = tenants.TenantManager(self)
+ self.tokens = tokens.TokenManager(self)
+ self.users = users.UserManager(self)
+ # NOTE(gabriel): If we have a pre-defined endpoint then we can
+ # get away with lazy auth. Otherwise auth immediately.
+ if endpoint is None:
+ self.authenticate()
+ else:
+ self.management_url = endpoint
+
+ def authenticate(self):
+ """ Authenticate against the Keystone API.
+
+ Uses the data provided at instantiation to authenticate against
+ the Keystone server. This may use either a username and password
+ or token for authentication. If a tenant id was provided
+ then the resulting authenticated client will be scoped to that
+ tenant and contain a service catalog of available endpoints.
+
+ Returns ``True`` if authentication was successful.
+ """
+ self.management_url = self.auth_url
+ try:
+ raw_token = self.tokens.authenticate(username=self.user,
+ password=self.password,
+ tenant=self.project_id,
+ token=self.auth_token,
+ return_raw=True)
+ self._extract_service_catalog(self.auth_url, raw_token)
+ return True
+ except (exceptions.AuthorizationFailure, exceptions.Unauthorized):
+ raise
+ except Exception, e:
+ _logger.exception("Authorization Failed.")
+ raise exceptions.AuthorizationFailure("Authorization Failed: %s" % e)
+
+ def _extract_service_catalog(self, url, body):
+ """ Set the client's service catalog from the response data. """
+ self.service_catalog = service_catalog.ServiceCatalog(body)
+ try:
+ self.auth_token = self.service_catalog.get_token()
+ except KeyError:
+ raise exceptions.AuthorizationFailure()
+ if self.project_id:
+ # Unscoped tokens don't return a service catalog
+ self.management_url = self.service_catalog.url_for(
+ attr='region',
+ filter_value=self.region_name)
+ return self.service_catalog
diff --git a/keystoneclient/v2_0/roles.py b/keystoneclient/v2_0/roles.py
new file mode 100644
index 0000000..dc4af01
--- /dev/null
+++ b/keystoneclient/v2_0/roles.py
@@ -0,0 +1,65 @@
+# Copyright 2011 OpenStack LLC.
+# Copyright 2011 Nebula, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from keystoneclient import base
+
+
+class Role(base.Resource):
+ def __repr__(self):
+ return "<Role %s>" % self._info
+
+ def delete(self):
+ return self.manager.delete(self)
+
+
+class RoleManager(base.ManagerWithFind):
+ resource_class = Role
+
+ def get(self, role):
+ return self._get("/OS-KSADM/roles/%s" % base.getid(role), "role")
+
+ def create(self, name):
+ """
+ Create a role.
+ """
+ params = {"role": {"name": name}}
+ return self._create('/OS-KSADM/roles', params, "role")
+
+ def delete(self, role):
+ """
+ Delete a role.
+ """
+ return self._delete("/OS-KSADM/roles/%s" % base.getid(role))
+
+ def list(self):
+ """
+ List all available roles.
+ """
+ return self._list("/OS-KSADM/roles", "roles")
+
+ # FIXME(ja): finialize roles once finalized in keystone
+ # right now the only way to add/remove a tenant is to
+ # give them a role within a project
+ def get_user_role_refs(self, user_id):
+ return self._list("/users/%s/roleRefs" % user_id, "roles")
+
+ def add_user_to_tenant(self, tenant_id, user_id, role_id):
+ params = {"role": {"tenantId": tenant_id, "roleId": role_id}}
+ return self._create("/users/%s/roleRefs" % user_id, params, "role")
+
+ def remove_user_from_tenant(self, tenant_id, user_id, role_id):
+ params = {"role": {"tenantId": tenant_id, "roleId": role_id}}
+ return self._delete("/users/%s/roleRefs/%s" % (user_id, role_id))
diff --git a/keystoneclient/v2_0/services.py b/keystoneclient/v2_0/services.py
new file mode 100644
index 0000000..378f403
--- /dev/null
+++ b/keystoneclient/v2_0/services.py
@@ -0,0 +1,39 @@
+# Copyright 2011 OpenStack LLC.
+# Copyright 2011 Nebula, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from keystoneclient import base
+
+
+class Service(base.Resource):
+ def __repr__(self):
+ return "<Service %s>" % self._info
+
+
+class ServiceManager(base.ManagerWithFind):
+ resource_class = Service
+
+ def list(self):
+ return self._list("/OS-KSADM/services", "OS-KSADM:services")
+
+ def get(self, id):
+ return self._get("/OS-KSADM/services/%s" % id, "OS-KSADM:service")
+
+ def create(self, name, service_type, description):
+ body = {"OS-KSADM:service": {'name': name, 'type':service_type, 'description': description}}
+ return self._create("/OS-KSADM/services", body, "OS-KSADM:service")
+
+ def delete(self, id):
+ return self._delete("/OS-KSADM/services/%s" % id)
diff --git a/keystoneclient/v2_0/shell.py b/keystoneclient/v2_0/shell.py
new file mode 100644
index 0000000..2fe9659
--- /dev/null
+++ b/keystoneclient/v2_0/shell.py
@@ -0,0 +1,27 @@
+# Copyright 2010 Jacob Kaplan-Moss
+# Copyright 2011 OpenStack LLC.
+# Copyright 2011 Nebula, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import getpass
+import os
+
+from keystoneclient import exceptions
+from keystoneclient import utils
+from keystoneclient.v2_0 import client
+
+
+CLIENT_CLASS = client.Client
+
diff --git a/keystoneclient/v2_0/tenants.py b/keystoneclient/v2_0/tenants.py
new file mode 100644
index 0000000..04f4108
--- /dev/null
+++ b/keystoneclient/v2_0/tenants.py
@@ -0,0 +1,76 @@
+# Copyright 2011 OpenStack LLC.
+# Copyright 2011 Nebula, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from keystoneclient import base
+
+
+class Tenant(base.Resource):
+ def __repr__(self):
+ return "<Tenant %s>" % self._info
+
+ def delete(self):
+ return self.manager.delete(self)
+
+ def update(self, description=None, enabled=None):
+ # FIXME(ja): set the attributes in this object if successful
+ return self.manager.update(self.id, description, enabled)
+
+ def add_user(self, user):
+ return self.manager.add_user_to_tenant(self.id, base.getid(user))
+
+
+class TenantManager(base.ManagerWithFind):
+ resource_class = Tenant
+
+ def get(self, tenant_id):
+ return self._get("/tenants/%s" % tenant_id, "tenant")
+
+ def create(self, tenant_name, description=None, enabled=True):
+ """
+ Create a new tenant.
+ """
+ params = {"tenant": {"name": tenant_name,
+ "description": description,
+ "enabled": enabled}}
+
+ return self._create('/tenants', params, "tenant")
+
+ def list(self):
+ """
+ Get a list of tenants.
+ :rtype: list of :class:`Tenant`
+ """
+ return self._list("/tenants", "tenants")
+
+ def update(self, tenant_id, tenant_name=None, description=None, enabled=None):
+ """
+ update a tenant with a new name and description
+ """
+ body = {"tenant": {'id': tenant_id }}
+ if tenant_name is not None:
+ body['tenant']['name'] = tenant_name
+ if enabled is not None:
+ body['tenant']['enabled'] = enabled
+ if description:
+ body['tenant']['description'] = description
+
+ return self._update("/tenants/%s" % tenant_id, body, "tenant")
+
+ def delete(self, tenant):
+ """
+ Delete a tenant.
+ """
+ return self._delete("/tenants/%s" % (base.getid(tenant)))
diff --git a/keystoneclient/v2_0/tokens.py b/keystoneclient/v2_0/tokens.py
new file mode 100644
index 0000000..3aa165f
--- /dev/null
+++ b/keystoneclient/v2_0/tokens.py
@@ -0,0 +1,37 @@
+from keystoneclient import base
+
+class Token(base.Resource):
+ def __repr__(self):
+ return "<Token %s>" % self._info
+
+ @property
+ def id(self):
+ return self._info['token']['id']
+
+ @property
+ def username(self):
+ return self._info['user'].get('username', None)
+
+ @property
+ def tenant_id(self):
+ return self._info['user'].get('tenantId', None)
+
+
+class TokenManager(base.ManagerWithFind):
+ resource_class = Token
+
+ def authenticate(self, username=None, password=None, tenant=None,
+ token=None, return_raw=False):
+ if token and token != password:
+ params = {"auth": {"token": {"id": token}}}
+ elif username and password:
+ params = {"auth": {"passwordCredentials": {"username": username,
+ "password": password}}}
+ else:
+ raise ValueError('A username and password or token is required.')
+ if tenant:
+ params['auth']['tenantId'] = tenant
+ return self._create('/tokens', params, "access", return_raw=return_raw)
+
+ def endpoints(self, token):
+ return self._get("/tokens/%s/endpoints" % base.getid(token), "token")
diff --git a/keystoneclient/v2_0/users.py b/keystoneclient/v2_0/users.py
new file mode 100644
index 0000000..c44939e
--- /dev/null
+++ b/keystoneclient/v2_0/users.py
@@ -0,0 +1,101 @@
+# Copyright 2011 OpenStack LLC.
+# Copyright 2011 Nebula, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from keystoneclient import base
+
+
+class User(base.Resource):
+ def __repr__(self):
+ return "<User %s>" % self._info
+
+ def delete(self):
+ return self.manager.delete(self)
+
+
+class UserManager(base.ManagerWithFind):
+ resource_class = User
+
+ def get(self, user):
+ return self._get("/users/%s" % base.getid(user), "user")
+
+ def update_email(self, user, email):
+ """
+ Update email
+ """
+ # FIXME(ja): why do we have to send id in params and url?
+ params = {"user": {"id": base.getid(user),
+ "email": email }}
+
+ return self._update("/users/%s" % base.getid(user), params, "user")
+
+ def update_enabled(self, user, enabled):
+ """
+ Update enabled-ness
+ """
+ params = {"user": {"id": base.getid(user),
+ "enabled": enabled }}
+
+ self._update("/users/%s/enabled" % base.getid(user), params, "user")
+
+ def update_password(self, user, password):
+ """
+ Update password
+ """
+ params = {"user": {"id": base.getid(user),
+ "password": password }}
+
+ return self._update("/users/%s/password" % base.getid(user), params, "user")
+
+ def update_tenant(self, user, tenant):
+ """
+ Update default tenant.
+ """
+ params = {"user": {"id": base.getid(user),
+ "tenantId": base.getid(tenant) }}
+
+ # FIXME(ja): seems like a bad url - default tenant is an attribute
+ # not a subresource!???
+ return self._update("/users/%s/tenant" % base.getid(user), params, "user")
+
+ def create(self, name, password, email, tenant_id=None, enabled=True):
+ """
+ Create a user.
+ """
+ # FIXME(ja): email should be optional but keystone currently requires it
+ params = {"user": {"name": name,
+ "password": password,
+ "tenantId": tenant_id,
+ "email": email,
+ "enabled": enabled}}
+ return self._create('/users', params, "user")
+
+ def delete(self, user):
+ """
+ Delete a user.
+ """
+ return self._delete("/users/%s" % base.getid(user))
+
+ def list(self, tenant_id=None):
+ """
+ Get a list of users (optionally limited to a tenant)
+
+ :rtype: list of :class:`User`
+ """
+
+ if not tenant_id:
+ return self._list("/users", "users")
+ else:
+ return self._list("/tenants/%s/users" % tenant_id, "users")
diff --git a/run_tests.sh b/run_tests.sh
new file mode 100755
index 0000000..dc110cd
--- /dev/null
+++ b/run_tests.sh
@@ -0,0 +1,116 @@
+#!/bin/bash
+#
+# Copyright 2011, Piston Cloud Computing, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+function usage {
+ echo "Usage: $0 [OPTION] [nosearg1[=val]] [nosearg2[=val]]..."
+ echo
+ echo "Run python-keystoneclient test suite"
+ echo
+ echo " -f, --force Delete the virtualenv before running tests."
+ echo " -h, --help Print this usage message"
+ echo " -N, --no-virtual-env Don't use a virtualenv"
+ echo " -p, --pep8 Run pep8 in addition"
+}
+
+
+function die {
+ echo $@
+ exit 1
+}
+
+
+function process_args {
+ case "$1" in
+ -h|--help) usage && exit ;;
+ -p|--pep8) let just_pep8=1; let use_venv=0 ;;
+ -N|--no-virtual-env) let use_venv=0;;
+ -f|--force) let force=1;;
+ *) noseargs="$noseargs $1"
+ esac
+}
+
+
+function run-command {
+ res=$($@)
+
+ if [ $? -ne 0 ]; then
+ die "Command failed:", $res
+ fi
+}
+
+
+function install-dependency {
+ echo -n "installing $@..."
+ run-command "pip install -E $venv $@"
+ echo done
+}
+
+
+function build-venv-if-necessary {
+ if [ $force -eq 1 ]; then
+ echo -n "Removing virtualenv..."
+ rm -rf $venv
+ echo done
+ fi
+
+ if [ -d $venv ]; then
+ echo -n # nothing to be done
+ else
+ if [ -z $(which virtualenv) ]; then
+ echo "Installing virtualenv"
+ run-command "easy_install virtualenv"
+ fi
+
+ echo -n "creating virtualenv..."
+ run-command "virtualenv -q --no-site-packages ${venv}"
+ echo done
+
+ for dep in $dependencies; do
+ install-dependency $dep
+ done
+ fi
+}
+
+
+function wrapper {
+ if [ $use_venv -eq 1 ]; then
+ build-venv-if-necessary
+ source "$(dirname $0)/${venv}/bin/activate" && $@
+ else
+ $@
+ fi
+}
+
+
+dependencies="httplib2 argparse prettytable simplejson nose mock coverage mox"
+force=0
+venv=.keystoneclient-venv
+use_venv=1
+verbose=0
+noseargs=
+just_pep8=0
+
+for arg in "$@"; do
+ process_args $arg
+done
+
+NOSETESTS="nosetests ${noseargs}"
+
+if [ $just_pep8 -ne 0 ]; then
+ wrapper "pep8 -r --show-pep8 keystoneclient tests"
+else
+ wrapper $NOSETESTS
+fi
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..15da167
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,13 @@
+[nosetests]
+cover-package = keystoneclient
+cover-html = true
+cover-erase = true
+cover-inclusive = true
+
+[build_sphinx]
+source-dir = docs/
+build-dir = docs/_build
+all_files = 1
+
+[upload_sphinx]
+upload-dir = docs/_build/html
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..19d9e3e
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,40 @@
+import os
+import sys
+from setuptools import setup, find_packages
+
+
+def read(fname):
+ return open(os.path.join(os.path.dirname(__file__), fname)).read()
+
+requirements = ['httplib2', 'argparse', 'prettytable']
+if sys.version_info < (2, 6):
+ requirements.append('simplejson')
+
+setup(
+ name = "python-keystoneclient",
+ version = "2.7",
+ description = "Client library for OpenStack Keystone API",
+ long_description = read('README.rst'),
+ url = 'https://github.com/4P/python-keystoneclient',
+ license = 'Apache',
+ author = 'Nebula Inc, based on work by Rackspace and Jacob Kaplan-Moss',
+ author_email = 'gabriel.hurley@nebula.com',
+ packages = find_packages(exclude=['tests', 'tests.*']),
+ classifiers = [
+ 'Development Status :: 4 - Beta',
+ 'Environment :: Console',
+ 'Intended Audience :: Developers',
+ 'Intended Audience :: Information Technology',
+ 'License :: OSI Approved :: Apache Software License',
+ 'Operating System :: OS Independent',
+ 'Programming Language :: Python',
+ ],
+ install_requires = requirements,
+
+ tests_require = ["nose", "mock", "mox"],
+ test_suite = "nose.collector",
+
+ entry_points = {
+ 'console_scripts': ['keystone = keystoneclient.shell:main']
+ }
+)
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/__init__.py
diff --git a/tests/test_base.py b/tests/test_base.py
new file mode 100644
index 0000000..e28b651
--- /dev/null
+++ b/tests/test_base.py
@@ -0,0 +1,48 @@
+import mock
+import mox
+
+from keystoneclient import base
+from keystoneclient import exceptions
+from keystoneclient.v2_0 import roles
+from tests import utils
+
+
+class BaseTest(utils.TestCase):
+
+ def test_resource_repr(self):
+ r = base.Resource(None, dict(foo="bar", baz="spam"))
+ self.assertEqual(repr(r), "<Resource baz=spam, foo=bar>")
+
+ def test_getid(self):
+ self.assertEqual(base.getid(4), 4)
+
+ class TmpObject(object):
+ id = 4
+ self.assertEqual(base.getid(TmpObject), 4)
+
+ def test_resource_lazy_getattr(self):
+ self.client.get = self.mox.CreateMockAnything()
+ self.client.get('/OS-KSADM/roles/1').AndRaise(AttributeError)
+ self.mox.ReplayAll()
+
+ f = roles.Role(self.client.roles, {'id': 1, 'name': 'Member'})
+ self.assertEqual(f.name, 'Member')
+
+ # Missing stuff still fails after a second get
+ self.assertRaises(AttributeError, getattr, f, 'blahblah')
+
+ def test_eq(self):
+ # Two resources of the same type with the same id: equal
+ r1 = base.Resource(None, {'id': 1, 'name': 'hi'})
+ r2 = base.Resource(None, {'id': 1, 'name': 'hello'})
+ self.assertEqual(r1, r2)
+
+ # Two resoruces of different types: never equal
+ r1 = base.Resource(None, {'id': 1})
+ r2 = roles.Role(None, {'id': 1})
+ self.assertNotEqual(r1, r2)
+
+ # Two resources with no ID: equal if their info is equal
+ r1 = base.Resource(None, {'name': 'joe', 'age': 12})
+ r2 = base.Resource(None, {'name': 'joe', 'age': 12})
+ self.assertEqual(r1, r2)
diff --git a/tests/test_http.py b/tests/test_http.py
new file mode 100644
index 0000000..1a677df
--- /dev/null
+++ b/tests/test_http.py
@@ -0,0 +1,63 @@
+import httplib2
+import mock
+
+from keystoneclient import client
+from keystoneclient import exceptions
+from tests import utils
+
+
+fake_response = httplib2.Response({"status": 200})
+fake_body = '{"hi": "there"}'
+mock_request = mock.Mock(return_value=(fake_response, fake_body))
+
+
+def get_client():
+ cl = client.HTTPClient(username="username", password="apikey",
+ project_id="project_id", auth_url="auth_test")
+ return cl
+
+
+def get_authed_client():
+ cl = get_client()
+ cl.management_url = "http://127.0.0.1:5000"
+ cl.auth_token = "token"
+ return cl
+
+
+class ClientTest(utils.TestCase):
+
+ def test_get(self):
+ cl = get_authed_client()
+
+ @mock.patch.object(httplib2.Http, "request", mock_request)
+ @mock.patch('time.time', mock.Mock(return_value=1234))
+ def test_get_call():
+ resp, body = cl.get("/hi")
+ headers = {"X-Auth-Token": "token",
+ "X-Auth-Project-Id": "project_id",
+ "User-Agent": cl.USER_AGENT,
+ }
+ mock_request.assert_called_with("http://127.0.0.1:5000/hi?fresh=1234",
+ "GET", headers=headers)
+ # Automatic JSON parsing
+ self.assertEqual(body, {"hi": "there"})
+
+ test_get_call()
+
+ def test_post(self):
+ cl = get_authed_client()
+
+ @mock.patch.object(httplib2.Http, "request", mock_request)
+ def test_post_call():
+ cl.post("/hi", body=[1, 2, 3])
+ headers = {
+ "X-Auth-Token": "token",
+ "X-Auth-Project-Id": "project_id",
+ "Content-Type": "application/json",
+ "User-Agent": cl.USER_AGENT
+ }
+ mock_request.assert_called_with("http://127.0.0.1:5000/hi", "POST",
+ headers=headers, body='[1, 2, 3]')
+
+ test_post_call()
+
diff --git a/tests/test_service_catalog.py b/tests/test_service_catalog.py
new file mode 100644
index 0000000..23b66ad
--- /dev/null
+++ b/tests/test_service_catalog.py
@@ -0,0 +1,108 @@
+from keystoneclient import exceptions
+from keystoneclient import service_catalog
+from tests import utils
+
+
+# Taken directly from keystone/content/common/samples/auth.json
+# Do not edit this structure. Instead, grab the latest from there.
+
+SERVICE_CATALOG = {
+ "access":{
+ "token":{
+ "id":"ab48a9efdfedb23ty3494",
+ "expires":"2010-11-01T03:32:15-05:00",
+ "tenant":{
+ "id": "345",
+ "name": "My Project"
+ }
+ },
+ "user":{
+ "id":"123",
+ "name":"jqsmith",
+ "roles":[{
+ "id":"234",
+ "name":"compute:admin"
+ },
+ {
+ "id":"235",
+ "name":"object-store:admin",
+ "tenantId":"1"
+ }
+ ],
+ "roles_links":[]
+ },
+ "serviceCatalog":[{
+ "name":"Cloud Servers",
+ "type":"compute",
+ "endpoints":[{
+ "tenantId":"1",
+ "publicURL":"https://compute.north.host/v1/1234",
+ "internalURL":"https://compute.north.host/v1/1234",
+ "region":"North",
+ "versionId":"1.0",
+ "versionInfo":"https://compute.north.host/v1.0/",
+ "versionList":"https://compute.north.host/"
+ },
+ {
+ "tenantId":"2",
+ "publicURL":"https://compute.north.host/v1.1/3456",
+ "internalURL":"https://compute.north.host/v1.1/3456",
+ "region":"North",
+ "versionId":"1.1",
+ "versionInfo":"https://compute.north.host/v1.1/",
+ "versionList":"https://compute.north.host/"
+ }
+ ],
+ "endpoints_links":[]
+ },
+ {
+ "name":"Cloud Files",
+ "type":"object-store",
+ "endpoints":[{
+ "tenantId":"11",
+ "publicURL":"https://compute.north.host/v1/blah-blah",
+ "internalURL":"https://compute.north.host/v1/blah-blah",
+ "region":"South",
+ "versionId":"1.0",
+ "versionInfo":"uri",
+ "versionList":"uri"
+ },
+ {
+ "tenantId":"2",
+ "publicURL":"https://compute.north.host/v1.1/blah-blah",
+ "internalURL":"https://compute.north.host/v1.1/blah-blah",
+ "region":"South",
+ "versionId":"1.1",
+ "versionInfo":"https://compute.north.host/v1.1/",
+ "versionList":"https://compute.north.host/"
+ }
+ ],
+ "endpoints_links":[{
+ "rel":"next",
+ "href":"https://identity.north.host/v2.0/endpoints?marker=2"
+ }
+ ]
+ }
+ ],
+ "serviceCatalog_links":[{
+ "rel":"next",
+ "href":"https://identity.host/v2.0/endpoints?session=2hfh8Ar&marker=2"
+ }
+ ]
+ }
+}
+
+
+class ServiceCatalogTest(utils.TestCase):
+ def test_building_a_service_catalog(self):
+ sc = service_catalog.ServiceCatalog(SERVICE_CATALOG['access'])
+
+ self.assertEquals(sc.url_for(service_type='compute'),
+ "https://compute.north.host/v1/1234")
+ self.assertEquals(sc.url_for('tenantId', '1', service_type='compute'),
+ "https://compute.north.host/v1/1234")
+ self.assertEquals(sc.url_for('tenantId', '2', service_type='compute'),
+ "https://compute.north.host/v1.1/3456")
+
+ self.assertRaises(exceptions.EndpointNotFound,
+ sc.url_for, "region", "South", service_type='compute')
diff --git a/tests/test_shell.py b/tests/test_shell.py
new file mode 100644
index 0000000..227b120
--- /dev/null
+++ b/tests/test_shell.py
@@ -0,0 +1,39 @@
+import os
+import mock
+import httplib2
+
+from keystoneclient import shell as openstack_shell
+from keystoneclient import exceptions
+from tests import utils
+
+
+class ShellTest(utils.TestCase):
+
+ # Patch os.environ to avoid required auth info.
+ def setUp(self):
+ global _old_env
+ fake_env = {
+ 'KEYSTONE_USERNAME': 'username',
+ 'KEYSTONE_API_KEY': 'password',
+ 'KEYSTONE_PROJECT_ID': 'project_id',
+ 'KEYSTONE_URL': 'http://127.0.0.1:5000',
+ }
+ _old_env, os.environ = os.environ, fake_env.copy()
+
+ # Make a fake shell object, a helping wrapper to call it, and a quick
+ # way of asserting that certain API calls were made.
+ global shell, _shell, assert_called, assert_called_anytime
+ _shell = openstack_shell.OpenStackIdentityShell()
+ shell = lambda cmd: _shell.main(cmd.split())
+
+ def tearDown(self):
+ global _old_env
+ os.environ = _old_env
+
+ def test_help_unknown_command(self):
+ self.assertRaises(exceptions.CommandError, shell, 'help foofoo')
+
+ def test_debug(self):
+ httplib2.debuglevel = 0
+ shell('--debug help')
+ assert httplib2.debuglevel == 1
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..9f6c62e
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,64 @@
+from keystoneclient import exceptions
+from keystoneclient import utils
+from tests import utils as test_utils
+
+
+class FakeResource(object):
+ pass
+
+
+class FakeManager(object):
+
+ resource_class = FakeResource
+
+ resources = {
+ '1234': {'name': 'entity_one'},
+ '8e8ec658-c7b0-4243-bdf8-6f7f2952c0d0': {'name': 'entity_two'},
+ '5678': {'name': '9876'}
+ }
+
+ def get(self, resource_id):
+ try:
+ return self.resources[str(resource_id)]
+ except KeyError:
+ raise exceptions.NotFound(resource_id)
+
+ def find(self, name=None):
+ for resource_id, resource in self.resources.items():
+ if resource['name'] == str(name):
+ return resource
+ raise exceptions.NotFound(name)
+
+
+class FindResourceTestCase(test_utils.TestCase):
+
+ def setUp(self):
+ super(FindResourceTestCase, self).setUp()
+ self.manager = FakeManager()
+
+ def test_find_none(self):
+ self.assertRaises(exceptions.CommandError,
+ utils.find_resource,
+ self.manager,
+ 'asdf')
+
+ def test_find_by_integer_id(self):
+ output = utils.find_resource(self.manager, 1234)
+ self.assertEqual(output, self.manager.resources['1234'])
+
+ def test_find_by_str_id(self):
+ output = utils.find_resource(self.manager, '1234')
+ self.assertEqual(output, self.manager.resources['1234'])
+
+ def test_find_by_uuid(self):
+ uuid = '8e8ec658-c7b0-4243-bdf8-6f7f2952c0d0'
+ output = utils.find_resource(self.manager, uuid)
+ self.assertEqual(output, self.manager.resources[uuid])
+
+ def test_find_by_str_name(self):
+ output = utils.find_resource(self.manager, 'entity_one')
+ self.assertEqual(output, self.manager.resources['1234'])
+
+ def test_find_by_int_name(self):
+ output = utils.find_resource(self.manager, 9876)
+ self.assertEqual(output, self.manager.resources['5678'])
diff --git a/tests/utils.py b/tests/utils.py
new file mode 100644
index 0000000..bf20e21
--- /dev/null
+++ b/tests/utils.py
@@ -0,0 +1,81 @@
+import time
+import unittest
+
+import httplib2
+import mox
+
+from keystoneclient.v2_0 import client
+
+
+class TestCase(unittest.TestCase):
+ TEST_TENANT = '1'
+ TEST_TENANT_NAME = 'aTenant'
+ TEST_TOKEN = 'aToken'
+ TEST_USER = 'test'
+ TEST_URL = 'http://127.0.0.1:5000/v2.0'
+ TEST_ADMIN_URL = 'http://127.0.0.1:35357/v2.0'
+
+ TEST_SERVICE_CATALOG = [{
+ "endpoints": [{
+ "adminURL": "http://cdn.admin-nets.local:8774/v1.0",
+ "region": "RegionOne",
+ "internalURL": "http://127.0.0.1:8774/v1.0",
+ "publicURL": "http://cdn.admin-nets.local:8774/v1.0/"
+ }],
+ "type": "nova_compat",
+ "name": "nova_compat"
+ }, {
+ "endpoints": [{
+ "adminURL": "http://nova/novapi/admin",
+ "region": "RegionOne",
+ "internalURL": "http://nova/novapi/internal",
+ "publicURL": "http://nova/novapi/public"
+ }],
+ "type": "compute",
+ "name": "nova"
+ }, {
+ "endpoints": [{
+ "adminURL": "http://glance/glanceapi/admin",
+ "region": "RegionOne",
+ "internalURL": "http://glance/glanceapi/internal",
+ "publicURL": "http://glance/glanceapi/public"
+ }],
+ "type": "image",
+ "name": "glance"
+ }, {
+ "endpoints": [{
+ "adminURL": "http://127.0.0.1:35357/v2.0",
+ "region": "RegionOne",
+ "internalURL": "http://127.0.0.1:5000/v2.0",
+ "publicURL": "http://127.0.0.1:5000/v2.0"
+ }],
+ "type": "identity",
+ "name": "keystone"
+ }, {
+ "endpoints": [{
+ "adminURL": "http://swift/swiftapi/admin",
+ "region": "RegionOne",
+ "internalURL": "http://swift/swiftapi/internal",
+ "publicURL": "http://swift/swiftapi/public"
+ }],
+ "type": "object-store",
+ "name": "swift"
+ }]
+
+ def setUp(self):
+ super(TestCase, self).setUp()
+ self.mox = mox.Mox()
+ self._original_time = time.time
+ time.time = lambda: 1234
+ httplib2.Http.request = self.mox.CreateMockAnything()
+ self.client = client.Client(username=self.TEST_USER,
+ token=self.TEST_TOKEN,
+ project_id=self.TEST_TENANT,
+ auth_url=self.TEST_URL,
+ endpoint=self.TEST_URL)
+
+ def tearDown(self):
+ time.time = self._original_time
+ super(TestCase, self).tearDown()
+ self.mox.UnsetStubs()
+ self.mox.VerifyAll()
diff --git a/tests/v2_0/__init__.py b/tests/v2_0/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/v2_0/__init__.py
diff --git a/tests/v2_0/test_auth.py b/tests/v2_0/test_auth.py
new file mode 100644
index 0000000..3a23998
--- /dev/null
+++ b/tests/v2_0/test_auth.py
@@ -0,0 +1,207 @@
+import httplib2
+import json
+
+from keystoneclient.v2_0 import client
+from keystoneclient import exceptions
+from tests import utils
+
+
+def to_http_response(resp_dict):
+ """
+ Utility function to convert a python dictionary
+ (e.g. {'status':status, 'body': body, 'headers':headers}
+ to an httplib2 response.
+ """
+ resp = httplib2.Response(resp_dict)
+ for k, v in resp_dict['headers'].items():
+ resp[k] = v
+ return resp
+
+
+class AuthenticateAgainstKeystoneTests(utils.TestCase):
+ def setUp(self):
+ super(AuthenticateAgainstKeystoneTests, self).setUp()
+ self.TEST_RESPONSE_DICT = {
+ "access": {
+ "token": {
+ "expires": "12345",
+ "id": self.TEST_TOKEN
+ },
+ "serviceCatalog": self.TEST_SERVICE_CATALOG
+ }
+ }
+ self.TEST_REQUEST_BODY = {
+ "auth": {
+ "passwordCredentials": {
+ "username": self.TEST_USER,
+ "password": self.TEST_TOKEN,
+ },
+ "tenantId": self.TEST_TENANT
+ }
+ }
+ self.TEST_REQUEST_HEADERS = {
+ 'Content-Type': 'application/json',
+ 'User-Agent': 'python-keystoneclient'
+ }
+
+ def test_authenticate_failure(self):
+ self.TEST_REQUEST_BODY['auth']['passwordCredentials']['password'] = 'bad_key'
+ self.TEST_REQUEST_HEADERS['X-Auth-Project-Id'] = '1'
+ resp = httplib2.Response({
+ "status": 401,
+ "body": json.dumps({"unauthorized": {
+ "message": "Unauthorized", "code": "401"}}),
+ })
+
+ # Implicit retry on API calls, so it gets called twice
+ httplib2.Http.request(self.TEST_URL + "/tokens",
+ 'POST',
+ body=json.dumps(self.TEST_REQUEST_BODY),
+ headers=self.TEST_REQUEST_HEADERS) \
+ .AndReturn((resp, resp['body']))
+ httplib2.Http.request(self.TEST_URL + "/tokens",
+ 'POST',
+ body=json.dumps(self.TEST_REQUEST_BODY),
+ headers=self.TEST_REQUEST_HEADERS) \
+ .AndReturn((resp, resp['body']))
+ self.mox.ReplayAll()
+
+ with self.assertRaises(exceptions.Unauthorized):
+ client.Client(username=self.TEST_USER,
+ password="bad_key",
+ project_id=self.TEST_TENANT,
+ auth_url=self.TEST_URL)
+
+
+ def test_auth_redirect(self):
+ self.TEST_REQUEST_HEADERS['X-Auth-Project-Id'] = '1'
+ correct_response = json.dumps(self.TEST_RESPONSE_DICT)
+ dict_responses = [
+ {"headers": {'location': self.TEST_ADMIN_URL + "/tokens"},
+ "status": 305,
+ "body": "Use proxy"},
+ {"headers": {},
+ "status": 200,
+ "body": correct_response}
+ ]
+ responses = [(to_http_response(resp), resp['body']) for
+ resp in dict_responses]
+
+ httplib2.Http.request(self.TEST_URL + "/tokens",
+ 'POST',
+ body=json.dumps(self.TEST_REQUEST_BODY),
+ headers=self.TEST_REQUEST_HEADERS) \
+ .AndReturn(responses[0])
+ httplib2.Http.request(self.TEST_ADMIN_URL + "/tokens",
+ 'POST',
+ body=json.dumps(self.TEST_REQUEST_BODY),
+ headers=self.TEST_REQUEST_HEADERS) \
+ .AndReturn(responses[1])
+ self.mox.ReplayAll()
+
+ cs = client.Client(username=self.TEST_USER,
+ password=self.TEST_TOKEN,
+ project_id=self.TEST_TENANT,
+ auth_url=self.TEST_URL)
+
+ self.assertEqual(cs.management_url,
+ self.TEST_RESPONSE_DICT["access"]["serviceCatalog"][3]
+ ['endpoints'][0]["publicURL"])
+ self.assertEqual(cs.auth_token,
+ self.TEST_RESPONSE_DICT["access"]["token"]["id"])
+
+ def test_authenticate_success_password_scoped(self):
+ self.TEST_REQUEST_HEADERS['X-Auth-Project-Id'] = '1'
+ resp = httplib2.Response({
+ "status": 200,
+ "body": json.dumps(self.TEST_RESPONSE_DICT),
+ })
+
+ httplib2.Http.request(self.TEST_URL + "/tokens",
+ 'POST',
+ body=json.dumps(self.TEST_REQUEST_BODY),
+ headers=self.TEST_REQUEST_HEADERS) \
+ .AndReturn((resp, resp['body']))
+ self.mox.ReplayAll()
+
+ cs = client.Client(username=self.TEST_USER,
+ password=self.TEST_TOKEN,
+ project_id=self.TEST_TENANT,
+ auth_url=self.TEST_URL)
+ self.assertEqual(cs.management_url,
+ self.TEST_RESPONSE_DICT["access"]["serviceCatalog"][3]
+ ['endpoints'][0]["publicURL"])
+ self.assertEqual(cs.auth_token,
+ self.TEST_RESPONSE_DICT["access"]["token"]["id"])
+
+ def test_authenticate_success_password_unscoped(self):
+ del self.TEST_RESPONSE_DICT['access']['serviceCatalog']
+ del self.TEST_REQUEST_BODY['auth']['tenantId']
+ resp = httplib2.Response({
+ "status": 200,
+ "body": json.dumps(self.TEST_RESPONSE_DICT),
+ })
+
+ httplib2.Http.request(self.TEST_URL + "/tokens",
+ 'POST',
+ body=json.dumps(self.TEST_REQUEST_BODY),
+ headers=self.TEST_REQUEST_HEADERS) \
+ .AndReturn((resp, resp['body']))
+ self.mox.ReplayAll()
+
+ cs = client.Client(username=self.TEST_USER,
+ password=self.TEST_TOKEN,
+ auth_url=self.TEST_URL)
+ self.assertEqual(cs.auth_token,
+ self.TEST_RESPONSE_DICT["access"]["token"]["id"])
+ self.assertFalse(cs.service_catalog.catalog.has_key('serviceCatalog'))
+
+ def test_authenticate_success_token_scoped(self):
+ del self.TEST_REQUEST_BODY['auth']['passwordCredentials']
+ self.TEST_REQUEST_BODY['auth']['token'] = {'id': self.TEST_TOKEN}
+ self.TEST_REQUEST_HEADERS['X-Auth-Project-Id'] = '1'
+ self.TEST_REQUEST_HEADERS['X-Auth-Token'] = self.TEST_TOKEN
+ resp = httplib2.Response({
+ "status": 200,
+ "body": json.dumps(self.TEST_RESPONSE_DICT),
+ })
+
+ httplib2.Http.request(self.TEST_URL + "/tokens",
+ 'POST',
+ body=json.dumps(self.TEST_REQUEST_BODY),
+ headers=self.TEST_REQUEST_HEADERS) \
+ .AndReturn((resp, resp['body']))
+ self.mox.ReplayAll()
+
+ cs = client.Client(token=self.TEST_TOKEN,
+ project_id=self.TEST_TENANT,
+ auth_url=self.TEST_URL)
+ self.assertEqual(cs.management_url,
+ self.TEST_RESPONSE_DICT["access"]["serviceCatalog"][3]
+ ['endpoints'][0]["publicURL"])
+ self.assertEqual(cs.auth_token,
+ self.TEST_RESPONSE_DICT["access"]["token"]["id"])
+
+ def test_authenticate_success_token_unscoped(self):
+ del self.TEST_REQUEST_BODY['auth']['passwordCredentials']
+ del self.TEST_REQUEST_BODY['auth']['tenantId']
+ del self.TEST_RESPONSE_DICT['access']['serviceCatalog']
+ self.TEST_REQUEST_BODY['auth']['token'] = {'id': self.TEST_TOKEN}
+ self.TEST_REQUEST_HEADERS['X-Auth-Token'] = self.TEST_TOKEN
+ resp = httplib2.Response({
+ "status": 200,
+ "body": json.dumps(self.TEST_RESPONSE_DICT),
+ })
+
+ httplib2.Http.request(self.TEST_URL + "/tokens",
+ 'POST',
+ body=json.dumps(self.TEST_REQUEST_BODY),
+ headers=self.TEST_REQUEST_HEADERS) \
+ .AndReturn((resp, resp['body']))
+ self.mox.ReplayAll()
+
+ cs = client.Client(token=self.TEST_TOKEN,
+ auth_url=self.TEST_URL)
+ self.assertEqual(cs.auth_token,
+ self.TEST_RESPONSE_DICT["access"]["token"]["id"])
+ self.assertFalse(cs.service_catalog.catalog.has_key('serviceCatalog'))
diff --git a/tests/v2_0/test_roles.py b/tests/v2_0/test_roles.py
new file mode 100644
index 0000000..3e7bfd9
--- /dev/null
+++ b/tests/v2_0/test_roles.py
@@ -0,0 +1,99 @@
+import urlparse
+import json
+
+import httplib2
+
+from keystoneclient.v2_0 import roles
+from tests import utils
+
+
+class RoleTests(utils.TestCase):
+ def setUp(self):
+ super(RoleTests, self).setUp()
+ self.TEST_REQUEST_HEADERS = {'X-Auth-Project-Id': '1',
+ 'X-Auth-Token': 'aToken',
+ 'User-Agent': 'python-keystoneclient',}
+ self.TEST_POST_HEADERS = {'X-Auth-Project-Id': '1',
+ 'Content-Type': 'application/json',
+ 'X-Auth-Token': 'aToken',
+ 'User-Agent': 'python-keystoneclient',}
+ self.TEST_ROLES = {
+ "roles": {
+ "values": [
+ {
+ "name": "admin",
+ "id": 1
+ },
+ {
+ "name": "member",
+ "id": 2
+ }
+ ]
+ }
+ }
+
+ def test_create(self):
+ req_body = {"role": {"name": "sysadmin",}}
+ resp_body = {"role": {"name": "sysadmin", "id": 3,}}
+ resp = httplib2.Response({
+ "status": 200,
+ "body": json.dumps(resp_body),
+ })
+
+ httplib2.Http.request(urlparse.urljoin(self.TEST_URL, 'v2.0/OS-KSADM/roles'),
+ 'POST',
+ body=json.dumps(req_body),
+ headers=self.TEST_POST_HEADERS) \
+ .AndReturn((resp, resp['body']))
+ self.mox.ReplayAll()
+
+ role = self.client.roles.create(req_body['role']['name'])
+ self.assertTrue(isinstance(role, roles.Role))
+ self.assertEqual(role.id, 3)
+ self.assertEqual(role.name, req_body['role']['name'])
+
+ def test_delete(self):
+ resp = httplib2.Response({
+ "status": 200,
+ "body": ""
+ })
+ httplib2.Http.request(urlparse.urljoin(self.TEST_URL, 'v2.0/OS-KSADM/roles/1'),
+ 'DELETE',
+ headers=self.TEST_REQUEST_HEADERS) \
+ .AndReturn((resp, resp['body']))
+ self.mox.ReplayAll()
+
+ self.client.roles.delete(1)
+
+ def test_get(self):
+ resp = httplib2.Response({
+ "status": 200,
+ "body": json.dumps({'role':self.TEST_ROLES['roles']['values'][0]}),
+ })
+ httplib2.Http.request(urlparse.urljoin(self.TEST_URL, 'v2.0/OS-KSADM/roles/1?fresh=1234'),
+ 'GET',
+ headers=self.TEST_REQUEST_HEADERS) \
+ .AndReturn((resp, resp['body']))
+ self.mox.ReplayAll()
+
+ role = self.client.roles.get(1)
+ self.assertTrue(isinstance(role, roles.Role))
+ self.assertEqual(role.id, 1)
+ self.assertEqual(role.name, 'admin')
+
+
+ def test_list(self):
+ resp = httplib2.Response({
+ "status": 200,
+ "body": json.dumps(self.TEST_ROLES),
+ })
+
+ httplib2.Http.request(urlparse.urljoin(self.TEST_URL, 'v2.0/OS-KSADM/roles?fresh=1234'),
+ 'GET',
+ headers=self.TEST_REQUEST_HEADERS) \
+ .AndReturn((resp, resp['body']))
+ self.mox.ReplayAll()
+
+ role_list = self.client.roles.list()
+ [self.assertTrue(isinstance(r, roles.Role)) for r in role_list]
+
diff --git a/tests/v2_0/test_services.py b/tests/v2_0/test_services.py
new file mode 100644
index 0000000..a1c63e0
--- /dev/null
+++ b/tests/v2_0/test_services.py
@@ -0,0 +1,111 @@
+import urlparse
+import json
+
+import httplib2
+
+from keystoneclient.v2_0 import services
+from tests import utils
+
+
+class ServiceTests(utils.TestCase):
+ def setUp(self):
+ super(ServiceTests, self).setUp()
+ self.TEST_REQUEST_HEADERS = {'X-Auth-Project-Id': '1',
+ 'X-Auth-Token': 'aToken',
+ 'User-Agent': 'python-keystoneclient',}
+ self.TEST_POST_HEADERS = {'X-Auth-Project-Id': '1',
+ 'Content-Type': 'application/json',
+ 'X-Auth-Token': 'aToken',
+ 'User-Agent': 'python-keystoneclient',}
+ self.TEST_SERVICES = {
+ "OS-KSADM:services": {
+ "values": [
+ {
+ "name": "nova",
+ "type": "compute",
+ "description": "Nova-compatible service.",
+ "id": 1
+ },
+ {
+ "name": "keystone",
+ "type": "identity",
+ "description": "Keystone-compatible service.",
+ "id": 2
+ }
+ ]
+ }
+ }
+
+ def test_create(self):
+ req_body = {"OS-KSADM:service": {"name": "swift",
+ "type": "object-store",
+ "description": "Swift-compatible service.",}}
+ resp_body = {"OS-KSADM:service": {"name": "swift",
+ "type": "object-store",
+ "description": "Swift-compatible service.",
+ "id": 3}}
+ resp = httplib2.Response({
+ "status": 200,
+ "body": json.dumps(resp_body),
+ })
+
+ httplib2.Http.request(urlparse.urljoin(self.TEST_URL, 'v2.0/OS-KSADM/services'),
+ 'POST',
+ body=json.dumps(req_body),
+ headers=self.TEST_POST_HEADERS) \
+ .AndReturn((resp, resp['body']))
+ self.mox.ReplayAll()
+
+ service = self.client.services.create(req_body['OS-KSADM:service']['name'],
+ req_body['OS-KSADM:service']['type'],
+ req_body['OS-KSADM:service']['description'])
+ self.assertTrue(isinstance(service, services.Service))
+ self.assertEqual(service.id, 3)
+ self.assertEqual(service.name, req_body['OS-KSADM:service']['name'])
+
+ def test_delete(self):
+ resp = httplib2.Response({
+ "status": 200,
+ "body": ""
+ })
+ httplib2.Http.request(urlparse.urljoin(self.TEST_URL, 'v2.0/OS-KSADM/services/1'),
+ 'DELETE',
+ headers=self.TEST_REQUEST_HEADERS) \
+ .AndReturn((resp, resp['body']))
+ self.mox.ReplayAll()
+
+ self.client.services.delete(1)
+
+ def test_get(self):
+ resp = httplib2.Response({
+ "status": 200,
+ "body": json.dumps({'OS-KSADM:service':self.TEST_SERVICES['OS-KSADM:services']['values'][0]}),
+ })
+ httplib2.Http.request(urlparse.urljoin(self.TEST_URL, 'v2.0/OS-KSADM/services/1?fresh=1234'),
+ 'GET',
+ headers=self.TEST_REQUEST_HEADERS) \
+ .AndReturn((resp, resp['body']))
+ self.mox.ReplayAll()
+
+ service = self.client.services.get(1)
+ self.assertTrue(isinstance(service, services.Service))
+ self.assertEqual(service.id, 1)
+ self.assertEqual(service.name, 'nova')
+ self.assertEqual(service.type, 'compute')
+
+
+ def test_list(self):
+ resp = httplib2.Response({
+ "status": 200,
+ "body": json.dumps(self.TEST_SERVICES),
+ })
+
+ httplib2.Http.request(urlparse.urljoin(self.TEST_URL, 'v2.0/OS-KSADM/services?fresh=1234'),
+ 'GET',
+ headers=self.TEST_REQUEST_HEADERS) \
+ .AndReturn((resp, resp['body']))
+ self.mox.ReplayAll()
+
+ service_list = self.client.services.list()
+ [self.assertTrue(isinstance(r, services.Service)) for r in service_list]
+
diff --git a/tests/v2_0/test_tenants.py b/tests/v2_0/test_tenants.py
new file mode 100644
index 0000000..8d73b66
--- /dev/null
+++ b/tests/v2_0/test_tenants.py
@@ -0,0 +1,150 @@
+import urlparse
+import json
+
+import httplib2
+
+from keystoneclient.v2_0 import tenants
+from tests import utils
+
+
+class TenantTests(utils.TestCase):
+ def setUp(self):
+ super(TenantTests, self).setUp()
+ self.TEST_REQUEST_HEADERS = {'X-Auth-Project-Id': '1',
+ 'X-Auth-Token': 'aToken',
+ 'User-Agent': 'python-keystoneclient',}
+ self.TEST_POST_HEADERS = {'X-Auth-Project-Id': '1',
+ 'Content-Type': 'application/json',
+ 'X-Auth-Token': 'aToken',
+ 'User-Agent': 'python-keystoneclient',}
+ self.TEST_TENANTS = {
+ "tenants": {
+ "values": [
+ {
+ "enabled": True,
+ "description": "A description change!",
+ "name": "invisible_to_admin",
+ "id": 3
+ },
+ {
+ "enabled": True,
+ "description": "None",
+ "name": "demo",
+ "id": 2
+ },
+ {
+ "enabled": True,
+ "description": "None",
+ "name": "admin",
+ "id": 1
+ }
+ ],
+ "links": []
+ }
+ }
+
+ def test_create(self):
+ req_body = {"tenant": {"name": "tenantX",
+ "description": "Like tenant 9, but better.",
+ "enabled": True,}}
+ resp_body = {"tenant": {"name": "tenantX",
+ "enabled": True,
+ "id": 4,
+ "description": "Like tenant 9, but better.",}}
+ resp = httplib2.Response({
+ "status": 200,
+ "body": json.dumps(resp_body),
+ })
+
+ httplib2.Http.request(urlparse.urljoin(self.TEST_URL, 'v2.0/tenants'),
+ 'POST',
+ body=json.dumps(req_body),
+ headers=self.TEST_POST_HEADERS) \
+ .AndReturn((resp, resp['body']))
+ self.mox.ReplayAll()
+
+ tenant = self.client.tenants.create(req_body['tenant']['name'],
+ req_body['tenant']['description'],
+ req_body['tenant']['enabled'])
+ self.assertTrue(isinstance(tenant, tenants.Tenant))
+ self.assertEqual(tenant.id, 4)
+ self.assertEqual(tenant.name, "tenantX")
+ self.assertEqual(tenant.description, "Like tenant 9, but better.")
+
+ def test_delete(self):
+ resp = httplib2.Response({
+ "status": 200,
+ "body": ""
+ })
+ httplib2.Http.request(urlparse.urljoin(self.TEST_URL, 'v2.0/tenants/1'),
+ 'DELETE',
+ headers=self.TEST_REQUEST_HEADERS) \
+ .AndReturn((resp, resp['body']))
+ self.mox.ReplayAll()
+
+ self.client.tenants.delete(1)
+
+ def test_get(self):
+ resp = httplib2.Response({
+ "status": 200,
+ "body": json.dumps({'tenant':self.TEST_TENANTS['tenants']['values'][2]}),
+ })
+ httplib2.Http.request(urlparse.urljoin(self.TEST_URL, 'v2.0/tenants/1?fresh=1234'),
+ 'GET',
+ headers=self.TEST_REQUEST_HEADERS) \
+ .AndReturn((resp, resp['body']))
+ self.mox.ReplayAll()
+
+ t = self.client.tenants.get(1)
+ self.assertTrue(isinstance(t, tenants.Tenant))
+ self.assertEqual(t.id, 1)
+ self.assertEqual(t.name, 'admin')
+
+
+ def test_list(self):
+ resp = httplib2.Response({
+ "status": 200,
+ "body": json.dumps(self.TEST_TENANTS),
+ })
+
+ httplib2.Http.request(urlparse.urljoin(self.TEST_URL, 'v2.0/tenants?fresh=1234'),
+ 'GET',
+ headers=self.TEST_REQUEST_HEADERS) \
+ .AndReturn((resp, resp['body']))
+ self.mox.ReplayAll()
+
+ tenant_list = self.client.tenants.list()
+ [self.assertTrue(isinstance(t, tenants.Tenant)) for t in tenant_list]
+
+
+ def test_update(self):
+ req_body = {"tenant": {"id": 4,
+ "name": "tenantX",
+ "description": "I changed you!",
+ "enabled": False,}}
+ resp_body = {"tenant": {"name": "tenantX",
+ "enabled": False,
+ "id": 4,
+ "description": "I changed you!",}}
+ resp = httplib2.Response({
+ "status": 200,
+ "body": json.dumps(resp_body),
+ })
+
+ httplib2.Http.request(urlparse.urljoin(self.TEST_URL, 'v2.0/tenants/4'),
+ 'PUT',
+ body=json.dumps(req_body),
+ headers=self.TEST_POST_HEADERS) \
+ .AndReturn((resp, resp['body']))
+ self.mox.ReplayAll()
+
+ tenant = self.client.tenants.update(req_body['tenant']['id'],
+ req_body['tenant']['name'],
+ req_body['tenant']['description'],
+ req_body['tenant']['enabled'])
+ print tenant
+ self.assertTrue(isinstance(tenant, tenants.Tenant))
+ self.assertEqual(tenant.id, 4)
+ self.assertEqual(tenant.name, "tenantX")
+ self.assertEqual(tenant.description, "I changed you!")
+ self.assertFalse(tenant.enabled)
diff --git a/tests/v2_0/test_tokens.py b/tests/v2_0/test_tokens.py
new file mode 100644
index 0000000..5f84d67
--- /dev/null
+++ b/tests/v2_0/test_tokens.py
@@ -0,0 +1,47 @@
+import urlparse
+import json
+
+import httplib2
+
+from keystoneclient.v2_0 import tokens
+from tests import utils
+
+
+class TokenTests(utils.TestCase):
+ def setUp(self):
+ super(ServiceTests, self).setUp()
+ self.TEST_REQUEST_HEADERS = {'X-Auth-Project-Id': '1',
+ 'X-Auth-Token': 'aToken',
+ 'User-Agent': 'python-keystoneclient',}
+ self.TEST_POST_HEADERS = {'X-Auth-Project-Id': '1',
+ 'Content-Type': 'application/json',
+ 'X-Auth-Token': 'aToken',
+ 'User-Agent': 'python-keystoneclient',}
+'''
+ def test_create(self):
+ req_body = {"OS-KSADM:service": {"name": "swift",
+ "type": "object-store",
+ "description": "Swift-compatible service.",}}
+ resp_body = {"OS-KSADM:service": {"name": "swift",
+ "type": "object-store",
+ "description": "Swift-compatible service.",
+ "id": 3}}
+ resp = httplib2.Response({
+ "status": 200,
+ "body": json.dumps(resp_body),
+ })
+
+ httplib2.Http.request(urlparse.urljoin(self.TEST_URL, 'v2.0/OS-KSADM/services'),
+ 'POST',
+ body=json.dumps(req_body),
+ headers=self.TEST_POST_HEADERS) \
+ .AndReturn((resp, resp['body']))
+ self.mox.ReplayAll()
+
+ service = self.client.services.create(req_body['OS-KSADM:service']['name'],
+ req_body['OS-KSADM:service']['type'],
+ req_body['OS-KSADM:service']['description'])
+ self.assertTrue(isinstance(service, services.Service))
+ self.assertEqual(service.id, 3)
+ self.assertEqual(service.name, req_body['OS-KSADM:service']['name'])
+'''
diff --git a/tests/v2_0/test_users.py b/tests/v2_0/test_users.py
new file mode 100644
index 0000000..752dd52
--- /dev/null
+++ b/tests/v2_0/test_users.py
@@ -0,0 +1,153 @@
+import urlparse
+import json
+
+import httplib2
+
+from keystoneclient.v2_0 import users
+from tests import utils
+
+
+class UserTests(utils.TestCase):
+ def setUp(self):
+ super(UserTests, self).setUp()
+ self.TEST_REQUEST_HEADERS = {'X-Auth-Project-Id': '1',
+ 'X-Auth-Token': 'aToken',
+ 'User-Agent': 'python-keystoneclient',}
+ self.TEST_POST_HEADERS = {'X-Auth-Project-Id': '1',
+ 'Content-Type': 'application/json',
+ 'X-Auth-Token': 'aToken',
+ 'User-Agent': 'python-keystoneclient',}
+ self.TEST_USERS = {
+ "users": {
+ "values": [
+ {
+ "email": "None",
+ "enabled": True,
+ "id": 1,
+ "name": "admin"
+ },
+ {
+ "email": "None",
+ "enabled": True,
+ "id": 2,
+ "name": "demo"
+ },
+ ]
+ }
+ }
+
+ def test_create(self):
+ req_body = {"user": {"name": "gabriel",
+ "password": "test",
+ "tenantId": 2,
+ "email": "test@example.com",
+ "enabled": True,}}
+ resp_body = {"user": {"name": "gabriel",
+ "enabled": True,
+ "tenantId": 2,
+ "id": 3,
+ "password": "test",
+ "email": "test@example.com"}}
+ resp = httplib2.Response({
+ "status": 200,
+ "body": json.dumps(resp_body),
+ })
+
+ httplib2.Http.request(urlparse.urljoin(self.TEST_URL, 'v2.0/users'),
+ 'POST',
+ body=json.dumps(req_body),
+ headers=self.TEST_POST_HEADERS) \
+ .AndReturn((resp, resp['body']))
+ self.mox.ReplayAll()
+
+ user = self.client.users.create(req_body['user']['name'],
+ req_body['user']['password'],
+ req_body['user']['email'],
+ tenant_id=req_body['user']['tenantId'],
+ enabled=req_body['user']['enabled'])
+ self.assertTrue(isinstance(user, users.User))
+ self.assertEqual(user.id, 3)
+ self.assertEqual(user.name, "gabriel")
+ self.assertEqual(user.email, "test@example.com")
+
+ def test_delete(self):
+ resp = httplib2.Response({
+ "status": 200,
+ "body": ""
+ })
+ httplib2.Http.request(urlparse.urljoin(self.TEST_URL, 'v2.0/users/1'),
+ 'DELETE',
+ headers=self.TEST_REQUEST_HEADERS) \
+ .AndReturn((resp, resp['body']))
+ self.mox.ReplayAll()
+
+ self.client.users.delete(1)
+
+ def test_get(self):
+ resp = httplib2.Response({
+ "status": 200,
+ "body": json.dumps({'user':self.TEST_USERS['users']['values'][0]}),
+ })
+ httplib2.Http.request(urlparse.urljoin(self.TEST_URL, 'v2.0/users/1?fresh=1234'),
+ 'GET',
+ headers=self.TEST_REQUEST_HEADERS) \
+ .AndReturn((resp, resp['body']))
+ self.mox.ReplayAll()
+
+ u = self.client.users.get(1)
+ self.assertTrue(isinstance(u, users.User))
+ self.assertEqual(u.id, 1)
+ self.assertEqual(u.name, 'admin')
+
+ def test_list(self):
+ resp = httplib2.Response({
+ "status": 200,
+ "body": json.dumps(self.TEST_USERS),
+ })
+
+ httplib2.Http.request(urlparse.urljoin(self.TEST_URL, 'v2.0/users?fresh=1234'),
+ 'GET',
+ headers=self.TEST_REQUEST_HEADERS) \
+ .AndReturn((resp, resp['body']))
+ self.mox.ReplayAll()
+
+ user_list = self.client.users.list()
+ [self.assertTrue(isinstance(u, users.User)) for u in user_list]
+
+ def test_update(self):
+ req_1 = {"user": {"password": "swordfish", "id": 2}}
+ req_2 = {"user": {"id": 2, "email": "gabriel@example.com"}}
+ req_3 = {"user": {"tenantId": 1, "id": 2}}
+ req_4 = {"user": {"enabled": False, "id": 2}}
+ # Keystone basically echoes these back... including the password :-/
+ resp_1 = httplib2.Response({"status": 200, "body": json.dumps(req_1),})
+ resp_2 = httplib2.Response({"status": 200, "body": json.dumps(req_2),})
+ resp_3 = httplib2.Response({"status": 200, "body": json.dumps(req_3),})
+ resp_4 = httplib2.Response({"status": 200, "body": json.dumps(req_3),})
+
+ httplib2.Http.request(urlparse.urljoin(self.TEST_URL, 'v2.0/users/2/password'),
+ 'PUT',
+ body=json.dumps(req_1),
+ headers=self.TEST_POST_HEADERS) \
+ .AndReturn((resp_1, resp_1['body']))
+ httplib2.Http.request(urlparse.urljoin(self.TEST_URL, 'v2.0/users/2'),
+ 'PUT',
+ body=json.dumps(req_2),
+ headers=self.TEST_POST_HEADERS) \
+ .AndReturn((resp_2, resp_2['body']))
+ httplib2.Http.request(urlparse.urljoin(self.TEST_URL, 'v2.0/users/2/tenant'),
+ 'PUT',
+ body=json.dumps(req_3),
+ headers=self.TEST_POST_HEADERS) \
+ .AndReturn((resp_3, resp_3['body']))
+ httplib2.Http.request(urlparse.urljoin(self.TEST_URL, 'v2.0/users/2/enabled'),
+ 'PUT',
+ body=json.dumps(req_4),
+ headers=self.TEST_POST_HEADERS) \
+ .AndReturn((resp_4, resp_4['body']))
+ self.mox.ReplayAll()
+
+ user = self.client.users.update_password(2, 'swordfish')
+ user = self.client.users.update_email(2, 'gabriel@example.com')
+ user = self.client.users.update_tenant(2, 1)
+ user = self.client.users.update_enabled(2, False)
diff --git a/tools/generate_authors.sh b/tools/generate_authors.sh
new file mode 100755
index 0000000..c41f079
--- /dev/null
+++ b/tools/generate_authors.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+git shortlog -se | cut -c8-
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..afdb8a6
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,8 @@
+[tox]
+envlist = py25,py26,py27
+
+[testenv]
+deps = nose
+ mock
+ mox
+commands = nosetests