diff options
author | Gabriel Hurley <gabriel@strikeawe.com> | 2011-10-25 16:50:08 -0700 |
---|---|---|
committer | Gabriel Hurley <gabriel@strikeawe.com> | 2011-10-25 16:50:08 -0700 |
commit | 17f6b83ee6157371104b065d7fb9cb6e5b03c386 (patch) | |
tree | 61e4b41e2edf001f6023b305f37d3c61131e9708 | |
download | python-keystoneclient-17f6b83ee6157371104b065d7fb9cb6e5b03c386.tar.gz |
Initial commit.
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 @@ -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> @@ -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 + + """ @@ -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- @@ -0,0 +1,8 @@ +[tox] +envlist = py25,py26,py27 + +[testenv] +deps = nose + mock + mox +commands = nosetests |