First working 0.1 version

- Uses tortilla lib to wrap the REST API (dep)
- Uses python requests to manage login/logout (dep)
- Provides 2 functional working examples with Redfish simulator and
  ProLiant server or Moonshot Server
- Remove OpenStack deps as this code has to be usable outside of
  OpenStack
- Provides a configuration file to handle credentials and connection
  URL
- Provides a mapping class to handle multiple versions of Redfish
  (in this version, 0.95.0 for ProLiant and 1.0.0 for mockup)
- Provides a first action reset_server to ... reset system
  The action is commented into simple-proliant.py to not do
  unexpected reset.
- Provides a first retrieving function get_bios_version to get the
  BIOS version of a system.
- Add basic logging capability
- Clean up to meet pep8 and doc strings (in progress).
This commit is contained in:
Bruno Cornec 2015-09-09 21:43:13 +02:00
parent 359b5db8db
commit f844afc49b
22 changed files with 983 additions and 621 deletions

6
.gitignore vendored
View File

@ -54,3 +54,9 @@ docs/_build/
# PyBuilder # PyBuilder
target/ target/
# Pydev
.project
.pydevproject
.settings/
.metadata

View File

@ -23,6 +23,7 @@ for build and test automation::
doc/ # documentation doc/ # documentation
doc/source # the doc source files live here doc/source # the doc source files live here
doc/build/html # output of building any docs will go here doc/build/html # output of building any docs will go here
dmtf # Reference documents and mockup provided by the DMTF
examples/ # any sample code using this library, eg. for education examples/ # any sample code using this library, eg. for education
# should be put here # should be put here
redfish/ # the redfish library redfish/ # the redfish library

View File

@ -3,7 +3,6 @@ DMTF Redfish specification
This directory contains the current references from the DMTF on the Redfish This directory contains the current references from the DMTF on the Redfish
specification (1.0.0 at the time of the writing) specification (1.0.0 at the time of the writing)
In order to ease test, the DMTF has published a mockup environment to simulate In order to ease test, the DMTF has published a mockup environment to simulate
a Redfish based system so it is possible to write programs without real Redfish a Redfish based system so it is possible to write programs without real Redfish
compliant hardware platform. compliant hardware platform.

View File

@ -0,0 +1,13 @@
FROM ubuntu:15.04
MAINTAINER bruno.cornec@hp.com
ENV DEBIAN_FRONTEND noninterative
ENV http_proxy http://web-proxy.fra.hp.com:8080
ENV https_proxy http://web-proxy.fra.hp.com:8080
# Install deps for Redfish mockup
RUN apt-get update
RUN apt-get -y install python-mock python-pip git openssh-client libpython2.7-dev python-oslotest
RUN apt-get -y ansible
RUN useradd -m bruno
RUN chown -R bruno /usr/local
RUN su - bruno -c "git clone https://github.com/bcornec/python-redfish.git ; pip install -r python-redfish/requirements.txt ; cd python-redfish ; python setup.py install -O1"
CMD /bin/bash

9
examples/redfish.conf Normal file
View File

@ -0,0 +1,9 @@
{
"Nodes": {
"default": {
"url": "",
"login": "",
"password": ""
}
}
}

View File

@ -0,0 +1,45 @@
# coding=utf-8
""" Simple example to use python-redfish on HP Proliant servers """
import os
import sys
import json
import redfish
# Get $HOME environment.
HOME = os.getenv('HOME')
if HOME == '':
print("$HOME environment variable not set, please check your system")
sys.exit(1)
try:
with open(HOME + "/.redfish.conf") as json_data:
config = json.load(json_data)
json_data.close()
except IOError as e:
print("Please create a json configuration file")
print(e)
sys.exit(1)
URL = config["Nodes"]["default"]["url"]
USER_NAME = config["Nodes"]["default"]["login"]
PASSWORD = config["Nodes"]["default"]["password"]
''' remote_mgmt is a redfish.RedfishConnection object '''
remote_mgmt = redfish.connect(URL, USER_NAME, PASSWORD, verify_cert=False)
print ("Redfish API version : %s \n" % remote_mgmt.get_api_version())
# Uncomment following line to reset the blade !!!
#remote_mgmt.Systems.systems_list[0].reset_system()
# TODO : create an attribute to link the managed system directly
# and avoid systems_list[0]
# --> will be something like :
# remote_mgmt.Systems.systems_list[0] = remote_mgmt.Systems.managed_system
print("Bios version : {}\n".format(remote_mgmt.Systems.systems_list[0].get_bios_version()))
remote_mgmt.logout()

View File

@ -0,0 +1,38 @@
# coding=utf-8
""" Simple example to use python-redfish with DMTF simulator """
import os
import sys
import json
import redfish
# Get $HOME environment.
HOME = os.getenv('HOME')
if HOME == '':
print("$HOME environment variable not set, please check your system")
sys.exit(1)
try:
with open(HOME + "/.redfish.conf") as json_data:
config = json.load(json_data)
json_data.close()
except IOError as e:
print("Please create a json configuration file")
print(e)
sys.exit(1)
URL = config["Nodes"]["default"]["url"]
USER_NAME = config["Nodes"]["default"]["login"]
PASSWORD = config["Nodes"]["default"]["password"]
''' remoteMgmt is a redfish.RedfishConnection object '''
remote_mgmt = redfish.connect(URL, USER_NAME, PASSWORD,
simulator=True, enforceSSL=False)
print ("Redfish API version : {} \n".format(remote_mgmt.get_api_version()))
print ("UUID : {} \n".format(remote_mgmt.Root.get_api_UUID()))
print ("Bios version : {}\n".format(remote_mgmt.Systems.systems_list[0].get_bios_version()))
#print remoteMgmt.get_api_link_to_server()

View File

@ -1,6 +0,0 @@
from redfish import connection
host = '127.0.0.1'
user_name = 'Admin'
password = 'password'
server = connection.RedfishConnection(host, user_name, password)

View File

@ -2,8 +2,8 @@
#import logging #import logging
import sys import sys
from oslo_config import cfg #from oslo_config import cfg
from oslo_log import log as logging #from oslo_log import log as logging
import redfish import redfish
@ -12,9 +12,9 @@ import redfish
#log_root.addHandler(logging.StreamHandler(sys.stdout)) #log_root.addHandler(logging.StreamHandler(sys.stdout))
#log_root.setLevel(logging.DEBUG) #log_root.setLevel(logging.DEBUG)
CONF = cfg.CONF #CONF = cfg.CONF
logging.set_defaults(['redfish=DEBUG']) #logging.set_defaults(['redfish=DEBUG'])
logging.register_options(CONF) #logging.register_options(CONF)
#logging.setup(CONF, "redfish") #logging.setup(CONF, "redfish")
# Connect to a redfish API endpoint # Connect to a redfish API endpoint

View File

@ -28,6 +28,7 @@ system such as defined by http://www.redfishcertification.org
%install %install
%{__python} setup.py install -O1 --skip-build --root %{buildroot} %{__python} setup.py install -O1 --skip-build --root %{buildroot}
# TODO: Add examples
%files %files
%doc README.rst examples/*.py %doc README.rst examples/*.py
%dir %{python_sitelib}/redfish %dir %{python_sitelib}/redfish

View File

@ -12,11 +12,11 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
import pbr.version #import pbr.version
import redfish.server from redfish.main import *
import redfish.types #import redfish.types
__version__ = pbr.version.VersionInfo( #__version__ = pbr.version.VersionInfo(
'redfish').version_string() # 'redfish').version_string()

37
redfish/config.py Normal file
View File

@ -0,0 +1,37 @@
# coding=utf-8
import logging
from logging.handlers import RotatingFileHandler
# Global variable definition
TORTILLADEBUG = True
logger = None
def initialize_logger(redfish_logfile):
"""Return api version.
:param redfish_logfile: redfish log
:type str
:returns: True
"""
global logger
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter(
'%(asctime)s :: %(levelname)s :: %(message)s'
)
file_handler = RotatingFileHandler(redfish_logfile, 'a', 1000000, 1)
# First logger to file
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
# Second logger to console
steam_handler = logging.StreamHandler()
steam_handler.setLevel(logging.DEBUG)
logger.addHandler(steam_handler)
return True

View File

@ -1,31 +1,16 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# 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.
class RedfishException(Exception): class RedfishException(Exception):
"""Base class for redfish exceptions""" """Base class for redfish exceptions"""
def __init__(self, message=None, **kwargs): def __init__(self, message=None, **kwargs):
self.kwargs = kwargs self.kwargs = kwargs
self.message = message
if not message:
try:
message = self.message % kwargs
except Excetion as e:
LOG.exception('Error in string format operation')
message = self.message
super(RedfishException, self).__init__(message)
class ObjectLoadException(RedfishException):
class AuthenticationFailureException(RedfishException):
pass
class LogoutFailureException(RedfishException):
pass pass

399
redfish/main.py Normal file
View File

@ -0,0 +1,399 @@
# Copyright 2014 Hewlett-Packard Development Company, L.P.
#
# 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.
"""
STARTING ASSUMPTIONS
On URIs:
The Redfish RESTful API is a "hypermedia API" by design. This is to avoid
building in restrictive assumptions to the data model that will make it
difficult to adapt to future hardware implementations. A hypermedia API avoids
these assumptions by making the data model discoverable via links between
resources.
A URI should be treated by the client as opaque, and thus should not be
attempted to be understood or deconstructed by the client. Only specific top
level URIs (any URI in this sample code) may be assumed, and even these may be
absent based upon the implementation (e.g. there might be no /redfish/v1/Systems
collection on something that doesn't have compute nodes.)
The other URIs must be discovered dynamically by following href links. This is
because the API will eventually be implemented on a system that breaks any
existing data model "shape" assumptions we may make now. In particular,
clients should not make assumptions about the URIs for the resource members of
a collection. For instance, the URI of a collection member will NOT always be
/redfish/v1/.../collection/1, or 2. On systems with multiple compute nodes per
manager, a System collection member might be /redfish/v1/Systems/C1N1.
This sounds very complicated, but in reality (as these examples demonstrate),
if you are looking for specific items, the traversal logic isn't too
complicated.
On Resource Model Traversal:
Although the resources in the data model are linked together, because of cross
link references between resources, a client may not assume the resource model
is a tree. It is a graph instead, so any crawl of the data model should keep
track of visited resources to avoid an infinite traversal loop.
A reference to another resource is any property called "href" no matter where
it occurs in a resource.
An external reference to a resource outside the data model is referred to by a
property called "extref". Any resource referred to by extref should not be
assumed to follow the conventions of the API.
On Resource Versions:
Each resource has a "Type" property with a value of the format Tyepname.x.y.z
where
* x = major version - incrementing this is a breaking change to the schema y =
* minor version - incrementing this is a non-breaking additive change to the
* schema z = errata - non-breaking change
Because all resources are versioned and schema also have a version, it is
possible to design rules for "nearest" match (e.g. if you are interacting with
multiple services using a common batch of schema files). The mechanism is not
prescribed, but a client should be prepared to encounter both older and newer
versions of resource types.
On HTTP POST to create:
WHen POSTing to create a resource (e.g. create an account or session) the
guarantee is that a successful response includes a "Location" HTTP header
indicating the resource URI of the newly created resource. The POST may also
include a representation of the newly created object in a JSON response body
but may not. Do not assume the response body, but test it. It may also be an
ExtendedError object.
HTTP REDIRECT:
All clients must correctly handle HTTP redirect. We (or Redfish) may
eventually need to use redirection as a way to alias portions of the data
model.
FUTURE: Asynchronous tasks
In the future some operations may start asynchonous tasks. In this case, the
client should recognized and handle HTTP 202 if needed and the 'Location'
header will point to a resource with task information and status.
JSON-SCHEMA:
The json-schema available at /redfish/v1/Schemas governs the content of the
resources, but keep in mind:
* not every property in the schema is implemented in every implementation.
* some properties are schemed to allow both null and anotehr type like string
* or integer.
Robust client code should check both the existence and type of interesting
properties and fail gracefully if expectations are not met.
GENERAL ADVICE:
Clients should always be prepared for:
* unimplemented properties (e.g. a property doesn't apply in a particular case)
* null values in some cases if the value of a property is not currently known
* due to system conditions HTTP status codes other than 200 OK. Can your code
* handle an HTTP 500 Internal Server Error with no other info? URIs are case
* insensitive HTTP header names are case insensitive JSON Properties and Enum
* values are case sensitive A client should be tolerant of any set of HTTP
* headers the service returns
"""
# coding=utf-8
import sys
import json
from urlparse import urlparse
import requests
import config
import types
import mapping
import exception
# Global variable definition
redfish_logfile = "/var/log/python-redfish/python-redfish.log"
# ===============================================================================
# TODO : create method to set logging level and TORTILLADEBUG.
# ===============================================================================
def set_log_file(logfile):
global redfish_logfile
redfish_logfile = logfile
return True
""" Function to wrap RedfishConnection """
def connect(
url,
user,
password,
simulator=False,
enforceSSL=True,
verify_cert=True
):
global redfish_logfile
config.initialize_logger(redfish_logfile)
return RedfishConnection(
url,
user,
password,
simulator=simulator,
enforceSSL=enforceSSL,
verify_cert=verify_cert
)
class RedfishConnection(object):
"""Implements basic connection handling for Redfish APIs."""
def __init__(self,
url,
user,
password,
simulator=False,
enforceSSL=True,
verify_cert=True
):
"""Initialize a connection to a Redfish service."""
super(RedfishConnection, self).__init__()
config.logger.info("Initialize python-redfish")
self.connection_parameters = ConnectionParameters()
self.connection_parameters.rooturl = url
self.connection_parameters.user_name = user
self.connection_parameters.password = password
self.connection_parameters.enforceSSL = enforceSSL
self.connection_parameters.verify_cert = verify_cert
# Use DMTF mockup or not
self.__simulator = simulator
# Session attributes
self.connection_parameters.auth_token = None
self.connection_parameters.user_uri = None
rooturl = urlparse(self.connection_parameters.rooturl)
# Enforce ssl
if self.connection_parameters.enforceSSL is True:
config.logger.debug("Enforcing SSL")
rooturl = rooturl._replace(scheme="https")
self.connection_parameters.rooturl = rooturl.geturl()
# Verify cert
if self.connection_parameters.verify_cert is False:
config.logger.info("Certificat is not checked, " +
"this is insecure and can allow" +
" a man in the middle attack")
config.logger.debug("Root url : %s", self.connection_parameters.rooturl)
self.Root = types.Root(self.connection_parameters.rooturl,
self.connection_parameters
)
#self.api_url = tortilla.wrap(self.connection_parameters.rooturl,
# debug=TORTILLADEBUG)
#self.root = self.api_url.get(verify=self.connection_parameters.verify_cert)
config.logger.info("API Version : %s", self.get_api_version())
mapping.redfish_version = self.get_api_version()
# Instanciate a global mapping object to handle Redfish version variation
mapping.redfish_mapper = mapping.RedfishVersionMapping(self.get_api_version())
# Now we need to login otherwise we are not allowed to extract data
if self.__simulator is False:
try:
config.logger.info("Login to %s", rooturl.netloc)
self.login()
config.logger.info("Login successful")
except "Error getting token":
config.logger.error("Login fail, fail to get auth token")
raise exception.AuthenticationFailureException("Fail to get an auth token.")
# Struture change with mockup 1.0.0, there is no links
# section anymore.
# ===================================================================
# TODO : Add a switch to allow the both structure
# ===================================================================
# Types
self.SessionService = types.SessionService(
self.Root.get_link_url(
mapping.redfish_mapper.map_sessionservice()),
self.connection_parameters
)
self.Managers = types.ManagersCollection(self.Root.get_link_url("Managers"),
self.connection_parameters
)
self.Systems = types.SystemsCollection(self.Root.get_link_url("Systems"),
self.connection_parameters
)
#for system in self.Systems.systems_list:
#config.logger.debug(system.data.links.ManagedBy)
# self.Chassis
# self.EventService
# self.AccountService
# self.Tasks
# ========================================================================
# systemCollectionLink = getattr(self.root.Links.Systems,"@odata.id")
# self.systemCollection = self.apiUrl.redfish.v1.Systems.get()
#
# print self.systemCollection.Name
#
# ========================================================================
def get_api_version(self):
"""Return api version.
:returns: string -- version
:raises: AttributeError
"""
return (self.Root.get_api_version())
def login(self):
# Craft full url
url = self.Root.get_link_url(
mapping.redfish_mapper.map_sessionservice()
)
# Craft request body and header
requestBody = {"UserName": self.connection_parameters.user_name , "Password": self.connection_parameters.password}
header = {'Content-type': 'application/json'}
# =======================================================================
# Tortilla seems not able to provide the header of a post request answer.
# However this is required by redfish standard to get X-Auth-Token.
# So jump to "requests" library to get the required token.
# TODO : Patch tortilla to handle this case.
# =======================================================================
# sessionsUrl = tortilla.wrap("https://10.3.222.104/rest/v1/Sessions", debug=TORTILLADEBUG)
# sessions = sessionsUrl.post(verify=self.verify_cert, data=requestBody)
auth = requests.post(url,
data=json.dumps(requestBody),
headers=header,
verify=self.connection_parameters.verify_cert
)
# =======================================================================
# TODO : Manage exception with a class.
# =======================================================================
if auth.status_code != 201:
pass
#sysraise "Error getting token", auth.status_code
self.connection_parameters.auth_token = auth.headers.get("x-auth-token")
self.connection_parameters.user_uri = auth.headers.get("location")
config.logger.debug("x-auth-token : %s", self.connection_parameters.auth_token)
config.logger.debug("user session : %s", self.connection_parameters.user_uri)
return True
def logout(self):
# Craft full url
url = self.connection_parameters.user_uri
# Craft request header
header = {"Content-type": "application/json",
"x-auth-token": self.connection_parameters.auth_token
}
logout = requests.delete(url, headers=header,
verify=self.connection_parameters.verify_cert
)
if logout.status_code == 200:
config.logger.info("Logout successful")
else:
config.logger.error("Logout failed")
raise exception.LogoutFailureException("Fail to logout properly.")
class ConnectionParameters(object):
"""Store connection parameters."""
def __init__(self):
pass
@property
def rooturl(self):
return self.__rooturl
@rooturl.setter
def rooturl(self, rooturl):
self.__rooturl = rooturl
@property
def user_name(self):
return self.__user_name
@user_name.setter
def user_name(self, user_name):
self.__user_name = user_name
@property
def password(self):
return self.__password
@password.setter
def password(self, password):
self.__password = password
@property
def enforceSSL(self):
return self.__enforceSSL
@enforceSSL.setter
def enforceSSL(self, enforceSSL):
self.__enforceSSL = enforceSSL
@property
def verify_cert(self):
return self.__verify_cert
@verify_cert.setter
def verify_cert(self, verify_cert):
self.__verify_cert = verify_cert
@property
def auth_token(self):
return self.__auth_token
@auth_token.setter
def auth_token(self, auth_token):
self.__auth_token = auth_token
@property
def user_uri(self):
return self.__user_uri
@user_uri.setter
def user_uri(self, user_uri):
self.__user_uri = user_uri

31
redfish/mapping.py Normal file
View File

@ -0,0 +1,31 @@
# coding=utf-8
redfish_mapper = None
redfish_version = None
class RedfishVersionMapping(object):
"""Implements basic url path mapping beetween Redfish versions."""
def __init__(self, version):
self.__version = version
def map_sessionservice(self):
if self.__version == "0.95":
return "Sessions"
return("SessionService")
def map_links(self):
if self.__version == "0.95":
return "links"
return("Links")
def map_links_ref(self):
if self.__version == "0.95":
return "href"
return("@odata.id")
def map_members(self):
if self.__version == "0.95":
return "Member"
return("Members")

197
redfish/old/types.py Normal file
View File

@ -0,0 +1,197 @@
# Copyright 2014 Hewlett-Packard Development Company, L.P.
#
# 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.
"""
Redfish Resource Types
"""
import base64
import gzip
import hashlib
import httplib
import json
import ssl
import StringIO
import sys
import urllib2
from urlparse import urlparse
#from oslo_log import log as logging
from redfish import exception
#LOG = logging.getLogger('redfish')
class Base(object):
def __init__(self, obj, connection=None):
self._conn = connection
"""handle to the redfish connection"""
self._attrs = []
"""list of discovered attributes"""
self._links = []
"""list of linked resources"""
# parse the individual resources, appending them to
# the list of object attributes
for k in obj.keys():
ref = k.lower()
if ref in ["links", "oem", "items"]:
continue
setattr(self, ref, obj[k])
self._attrs.append(ref)
# make sure the required attributes are present
if not getattr(self, 'name', False):
raise ObjectLoadException(
"Failed to load object. Reason: could not determine name.")
if not getattr(self, 'type', False):
raise ObjectLoadException(
"Failed to load object. Reason: could not determine type.")
if getattr(self, 'serviceversion', False):
self.type = self.type.replace('.' + self.serviceversion, '')
else:
# TODO: use a regex here to strip and store the version
# instead of assuming it is 7 chars long
self.type = self.type[:-7]
# Lastly, parse the 'links' resource.
# Note that this may have different nested structure, depending on
# what type of resource this is, or what vendor it is.
# subclasses may follow this by parsing other resources / collections
self._parse_links(obj)
def _parse_links(self, obj):
"""Map linked resources to getter functions
The root resource returns a dict of links to top-level resources
"""
def getter(connection, href):
def _get():
return connection.rest_get(href, {})
return _get
for k in obj['links']:
ref = "get_" + k.lower()
self._links.append(ref)
href = obj['links'][k]['href']
setattr(self, ref, getter(self._conn, href))
def __repr__(self):
"""Return this object's _attrs as a dict"""
res = {}
for a in self._attrs:
res[a] = getattr(self, a)
return res
def __str__(self):
"""Return the string representation of this object's _attrs"""
return json.dumps(self.__repr__())
class BaseCollection(Base):
"""Base class for collection types"""
def __init__(self, obj, connection=None):
super(BaseCollection, self).__init__(obj, connection=connection)
self._parse_items(obj)
self._attrs.append('items')
def _parse_links(self, obj):
"""links are special on a chassis; dont parse them"""
pass
def _parse_items(self, obj):
"""Map linked items to getter methods
The chassis resource returns a list of items and corresponding
link data in a separate entity.
"""
def getter(connection, href):
def _get():
return connection.rest_get(href, {})
return _get
self.items = []
self._item_getters = []
if 'links' in obj and 'Member' in obj['links']:
# NOTE: this assumes the lists are ordered the same
counter = 0
for item in obj['links']['Member']:
self.items.append(obj['Items'][counter])
self._item_getters.append(
getter(self._conn, item['href']))
counter+=1
elif 'Items' in obj:
# TODO: find an example of this format and make sure it works
for item in obj['Items']:
if 'links' in item and 'self' in item['links']:
href = item['links']['self']['href']
self.items.append(item)
# TODO: implement paging support
# if 'links' in obj and 'NextPage' in obj['links']:
# next_page = THIS_URI + '?page=' + str(obj['links']['NextPage']['page'])
# do something with next_page URI
def __iter__(self):
for getter in self._item_getters:
yield getter()
class Root(Base):
"""Root '/' resource class"""
def _parse_links(self, obj):
"""Map linked resources to getter functions
The root resource returns a dict of links to top-level resources
TODO: continue implementing customizations for top-level resources
"""
mapping = {
'Systems': Systems,
'Chassis': Chassis,
'Managers': Base,
'Schemas': Base,
'Registries': Base,
'Tasks': Base,
'AccountService': Base,
'Sessions': Base,
'EventService': Base,
}
def getter(connection, href, type):
def _get():
return mapping[type](connection.rest_get(href, {}), self._conn)
return _get
for k in obj['links']:
ref = "get_" + k.lower()
self._links.append(ref)
href = obj['links'][k]['href']
setattr(self, ref, getter(self._conn, href, k))
class Chassis(BaseCollection):
"""Chassis resource class"""
def __len__(self):
return len(self.items)
class Systems(Base):
pass

View File

@ -1,412 +0,0 @@
# Copyright 2014 Hewlett-Packard Development Company, L.P.
#
# 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.
"""
STARTING ASSUMPTIONS
On URIs:
The Redfish RESTful API is a "hypermedia API" by design. This is to avoid
building in restrictive assumptions to the data model that will make it
difficult to adapt to future hardware implementations. A hypermedia API avoids
these assumptions by making the data model discoverable via links between
resources.
A URI should be treated by the client as opaque, and thus should not be
attempted to be understood or deconstructed by the client. Only specific top
level URIs (any URI in this sample code) may be assumed, and even these may be
absent based upon the implementation (e.g. there might be no /redfish/v1/Systems
collection on something that doesn't have compute nodes.)
The other URIs must be discovered dynamically by following href links. This is
because the API will eventually be implemented on a system that breaks any
existing data model "shape" assumptions we may make now. In particular,
clients should not make assumptions about the URIs for the resource members of
a collection. For instance, the URI of a collection member will NOT always be
/redfish/v1/.../collection/1, or 2. On systems with multiple compute nodes per
manager, a System collection member might be /redfish/v1/Systems/C1N1.
This sounds very complicated, but in reality (as these examples demonstrate),
if you are looking for specific items, the traversal logic isn't too
complicated.
On Resource Model Traversal:
Although the resources in the data model are linked together, because of cross
link references between resources, a client may not assume the resource model
is a tree. It is a graph instead, so any crawl of the data model should keep
track of visited resources to avoid an infinite traversal loop.
A reference to another resource is any property called "href" no matter where
it occurs in a resource.
An external reference to a resource outside the data model is referred to by a
property called "extref". Any resource referred to by extref should not be
assumed to follow the conventions of the API.
On Resource Versions:
Each resource has a "Type" property with a value of the format Tyepname.x.y.z
where
* x = major version - incrementing this is a breaking change to the schema y =
* minor version - incrementing this is a non-breaking additive change to the
* schema z = errata - non-breaking change
Because all resources are versioned and schema also have a version, it is
possible to design rules for "nearest" match (e.g. if you are interacting with
multiple services using a common batch of schema files). The mechanism is not
prescribed, but a client should be prepared to encounter both older and newer
versions of resource types.
On HTTP POST to create:
WHen POSTing to create a resource (e.g. create an account or session) the
guarantee is that a successful response includes a "Location" HTTP header
indicating the resource URI of the newly created resource. The POST may also
include a representation of the newly created object in a JSON response body
but may not. Do not assume the response body, but test it. It may also be an
ExtendedError object.
HTTP REDIRECT:
All clients must correctly handle HTTP redirect. We (or Redfish) may
eventually need to use redirection as a way to alias portions of the data
model.
FUTURE: Asynchronous tasks
In the future some operations may start asynchonous tasks. In this case, the
client should recognized and handle HTTP 202 if needed and the 'Location'
header will point to a resource with task information and status.
JSON-SCHEMA:
The json-schema available at /redfish/v1/Schemas governs the content of the
resources, but keep in mind:
* not every property in the schema is implemented in every implementation.
* some properties are schemed to allow both null and anotehr type like string
* or integer.
Robust client code should check both the existence and type of interesting
properties and fail gracefully if expectations are not met.
GENERAL ADVICE:
Clients should always be prepared for:
* unimplemented properties (e.g. a property doesn't apply in a particular case)
* null values in some cases if the value of a property is not currently known
* due to system conditions HTTP status codes other than 200 OK. Can your code
* handle an HTTP 500 Internal Server Error with no other info? URIs are case
* insensitive HTTP header names are case insensitive JSON Properties and Enum
* values are case sensitive A client should be tolerant of any set of HTTP
* headers the service returns
"""
import base64
import gzip
import hashlib
import httplib
import json
import ssl
import StringIO
import sys
import urllib2
from urlparse import urlparse
from oslo_log import log as logging
from redfish import exception
from redfish import types
LOG = logging.getLogger('redfish')
def connect(host, user, password):
return RedfishConnection(host, user, password)
class RedfishConnection(object):
"""Implements basic connection handling for Redfish APIs."""
def __init__(self, host, user_name, password,
auth_token=None, enforce_SSL=True):
"""Initialize a connection to a Redfish service."""
super(RedfishConnection, self).__init__()
self.user_name = user_name
self.password = password
self.auth_token = auth_token
self.enforce_SSL = enforce_SSL
# context for the last status and header returned from a call
self.status = None
self.headers = None
# If the http schema wasn't specified, default to HTTPS
if host[0:4] != 'http':
host = 'https://' + host
self.host = host
self._connect()
if not self.auth_token:
# TODO: if a token is returned by this call, cache it. However,
# the sample HTML does not include any token data, so it's unclear
# what we should do here.
LOG.debug('Initiating session with host %s', self.host)
auth_dict = {'Password': self.password, 'UserName': self.user_name}
response = self.rest_post(
'/redfish/v1/Sessions', None, json.dumps(auth_dict))
# TODO: do some schema discovery here and cache the result
# self.schema = ...
LOG.info('Connection established to host %s', self.host)
def _connect(self):
LOG.debug("Establishing connection to host %s", self.host)
url = urlparse(self.host)
if url.scheme == 'https':
# New in Python 2.7.9, SSL enforcement is defaulted on.
# It can be opted-out of, which might be useful for debugging
# some things. The below case is the Opt-Out condition and
# should be used with GREAT caution.
if (sys.version_info.major == 2
and sys.version_info.minor == 7
and sys.version_info.micro >= 9
and self.enforce_SSL == False):
cont = ssl.SSLContext(ssl.PROTOCOL_TLSv1)
cont.verify_mode = ssl.CERT_NONE
self.connection = httplib.HTTPSConnection(
host=url.netloc, strict=True, context=cont)
else:
self.connection = httplib.HTTPSConnection(
host=url.netloc, strict=True)
elif url.scheme == 'http':
self.connection = httplib.HTTPConnection(
host=url.netloc, strict=True)
else:
raise exception.RedfishException(
message='Unknown connection schema')
def _op(self, operation, suburi, request_headers=None, request_body=None):
"""
REST operation generic handler
:param operation: GET, POST, etc
:param suburi: the URI path to the resource
:param request_headers: optional dict of headers
:param request_body: optional JSON body
"""
# ensure trailing slash
if suburi[-1:] != '/':
suburi = suburi + '/'
url = urlparse(self.host + suburi)
if not isinstance(request_headers, dict):
request_headers = dict()
request_headers['Content-Type'] = 'application/json'
# if X-Auth-Token specified, supply it instead of basic auth
if self.auth_token is not None:
request_headers['X-Auth-Token'] = self.auth_token
# else use user_name/password and Basic Auth
elif self.user_name is not None and self.password is not None:
request_headers['Authorization'] = ("BASIC " + base64.b64encode(
self.user_name + ":" + self.password))
# TODO: add support for other types of auth
redir_count = 4
while redir_count:
# NOTE: Do not assume every HTTP operation will return a JSON body.
# For example, ExtendedError structures are only required for
# HTTP 400 errors and are optional elsewhere as they are mostly
# redundant for many of the other HTTP status code. In particular,
# 200 OK responses should not have to return any body.
self.connection.request(operation, url.path,
headers=request_headers, body=json.dumps(request_body))
resp = self.connection.getresponse()
body = resp.read()
# NOTE: this makes sure the headers names are all lower case
# because HTTP says they are case insensitive
headers = dict((x.lower(), y) for x, y in resp.getheaders())
# Follow HTTP redirect
if resp.status == 301 and 'location' in headers:
url = urlparse(headers['location'])
# TODO: cache these redirects
LOG.debug("Following redirect to %s", headers['location'])
redir_count -= 1
else:
break
response = dict()
try:
response = json.loads(body.decode('utf-8'))
except ValueError: # if it doesn't decode as json
# NOTE: resources may return gzipped content, so try to decode
# as gzip (we should check the headers for Content-Encoding=gzip)
try:
gzipper = gzip.GzipFile(fileobj=StringIO.StringIO(body))
uncompressed_string = gzipper.read().decode('UTF-8')
response = json.loads(uncompressed_string)
except:
raise exception.RedfishException(message=
'Failed to parse response as a JSON document, '
'received "%s".' % body)
self.status = resp.status
self.headers = headers
return response
def rest_get(self, suburi, request_headers):
"""REST GET
:param: suburi
:param: request_headers
"""
# NOTE: be prepared for various HTTP responses including 500, 404, etc
return self._op('GET', suburi, request_headers, None)
def rest_patch(self, suburi, request_headers, request_body):
"""REST PATCH
:param: suburi
:param: request_headers
:param: request_body
NOTE: this body is a dict, not a JSONPATCH document.
redfish does not follow IETF JSONPATCH standard
https://tools.ietf.org/html/rfc6902
"""
# NOTE: be prepared for various HTTP responses including 500, 404, 202
return self._op('PATCH', suburi, request_headers, request_body)
def rest_put(self, suburi, request_headers, request_body):
"""REST PUT
:param: suburi
:param: request_headers
:param: request_body
"""
# NOTE: be prepared for various HTTP responses including 500, 404, 202
return self._op('PUT', suburi, request_headers, request_body)
def rest_post(self, suburi, request_headers, request_body):
"""REST POST
:param: suburi
:param: request_headers
:param: request_body
"""
# NOTE: don't assume any newly created resource is included in the
# response. Only the Location header matters.
# the response body may be the new resource, it may be an
# ExtendedError, or it may be empty.
return self._op('POST', suburi, request_headers, request_body)
def rest_delete(self, suburi, request_headers):
"""REST DELETE
:param: suburi
:param: request_headers
"""
# NOTE: be prepared for various HTTP responses including 500, 404
# NOTE: response may be an ExtendedError or may be empty
return self._op('DELETE', suburi, request_headers, None)
def get_root(self):
return types.Root(self.rest_get('/redfish/v1', {}), connection=self)
class Version(object):
def __init__(self, string):
try:
buf = string.split('.')
if len(buf) < 2:
raise AttributeError
except AttributeError:
raise RedfishException(message="Failed to parse version string")
self.major = int(buf[0])
self.minor = int(buf[1])
def __repr__(self):
return str(self.major) + '.' + str(self.minor)
# return the type of an object (down to the major version, skipping minor, and errata)
def get_type(obj):
typever = obj['Type']
typesplit = typever.split('.')
return typesplit[0] + '.' + typesplit[1]
# checks HTTP response headers for specified operation (e.g. 'GET' or 'PATCH')
def operation_allowed(headers_dict, operation):
if 'allow' in headers_dict:
if headers_dict['allow'].find(operation) != -1:
return True
return False
# Message registry support
# XXX not supported yet
message_registries = {}
# Build a list of decoded messages from the extended_error using the message
# registries An ExtendedError JSON object is a response from the with its own
# schema. This function knows how to parse the ExtendedError object and, using
# any loaded message registries, render an array of plain language strings that
# represent the response.
def render_extended_error_message_list(extended_error):
messages = []
if isinstance(extended_error, dict):
if 'Type' in extended_error and extended_error['Type'].startswith('ExtendedError.'):
for msg in extended_error['Messages']:
MessageID = msg['MessageID']
x = MessageID.split('.')
registry = x[0]
msgkey = x[len(x) - 1]
# if the correct message registry is loaded, do string resolution
if registry in message_registries:
if registry in message_registries and msgkey in message_registries[registry]['Messages']:
msg_dict = message_registries[registry]['Messages'][msgkey]
msg_str = MessageID + ': ' + msg_dict['Message']
for argn in range(0, msg_dict['NumberOfArgs']):
subst = '%' + str(argn+1)
msg_str = msg_str.replace(subst, str(msg['MessageArgs'][argn]))
if 'Resolution' in msg_dict and msg_dict['Resolution'] != 'None':
msg_str += ' ' + msg_dict['Resolution']
messages.append(msg_str)
else: # no message registry, simply return the msg object in string form
messages.append('No Message Registry Info: '+ str(msg))
return messages
# Print a list of decoded messages from the extended_error using the message registries
def print_extended_error(extended_error):
messages = render_extended_error_message_list(extended_error)
msgcnt = 0
for msg in messages:
print('\t' + msg)
msgcnt += 1
if msgcnt == 0: # add a spacer
print

View File

@ -1,197 +1,215 @@
# Copyright 2014 Hewlett-Packard Development Company, L.P. # coding=utf-8
#
# 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 pprint
from urlparse import urljoin
import requests
import tortilla
import config
import mapping
""" # Global variable
Redfish Resource Types
"""
import base64
import gzip
import hashlib
import httplib
import json
import ssl
import StringIO
import sys
import urllib2
from urlparse import urlparse
from oslo_log import log as logging
from redfish import exception
LOG = logging.getLogger('redfish')
class Base(object): class Base(object):
def __init__(self, obj, connection=None): """Abstract class to manage types (Chassis, Servers etc...)."""
self._conn = connection
"""handle to the redfish connection"""
self._attrs = [] def __init__(self, url, connection_parameters):
"""list of discovered attributes""" global TORTILLADEBUG
self.connection_parameters = connection_parameters # Uggly hack to check
self.url = url
self.api_url = tortilla.wrap(url, debug=config.TORTILLADEBUG)
self._links = [] try:
"""list of linked resources""" if connection_parameters.auth_token == None:
self.data = self.api_url.get(verify=connection_parameters.verify_cert)
# parse the individual resources, appending them to
# the list of object attributes
for k in obj.keys():
ref = k.lower()
if ref in ["links", "oem", "items"]:
continue
setattr(self, ref, obj[k])
self._attrs.append(ref)
# make sure the required attributes are present
if not getattr(self, 'name', False):
raise ObjectLoadException(
"Failed to load object. Reason: could not determine name.")
if not getattr(self, 'type', False):
raise ObjectLoadException(
"Failed to load object. Reason: could not determine type.")
if getattr(self, 'serviceversion', False):
self.type = self.type.replace('.' + self.serviceversion, '')
else: else:
# TODO: use a regex here to strip and store the version self.data = self.api_url.get(verify=connection_parameters.verify_cert,
# instead of assuming it is 7 chars long headers={'x-auth-token': connection_parameters.auth_token}
self.type = self.type[:-7] )
except requests.ConnectionError as e:
print e
# Log and transmit the exception.
config.logger.error("Connection error : %s", e)
raise e
print self.data
# Lastly, parse the 'links' resource. def get_link_url(self, link_type):
# Note that this may have different nested structure, depending on """Need to be explained.
# what type of resource this is, or what vendor it is.
# subclasses may follow this by parsing other resources / collections
self._parse_links(obj)
def _parse_links(self, obj): :param redfish_logfile: redfish log
"""Map linked resources to getter functions :type str
:returns: True
The root resource returns a dict of links to top-level resources
""" """
def getter(connection, href): self.links=[]
def _get():
return connection.rest_get(href, {})
return _get
for k in obj['links']: # Manage standard < 1.0
ref = "get_" + k.lower() if float(mapping.redfish_version) < 1.00:
self._links.append(ref) links = getattr(self.data, mapping.redfish_mapper.map_links())
href = obj['links'][k]['href'] if link_type in links:
setattr(self, ref, getter(self._conn, href)) return urljoin(self.url, links[link_type][mapping.redfish_mapper.map_links_ref()])
else:
links = getattr(self.data, link_type)
link = getattr(links, mapping.redfish_mapper.map_links_ref())
return urljoin(self.url, link)
def __repr__(self): @property
"""Return this object's _attrs as a dict""" def url(self):
res = {} return self.__url
for a in self._attrs:
res[a] = getattr(self, a)
return res
def __str__(self): @url.setter
"""Return the string representation of this object's _attrs""" def url(self, url):
return json.dumps(self.__repr__()) self.__url = url
class BaseCollection(Base): class BaseCollection(Base):
"""Base class for collection types""" """Abstract class to manage collection (Chassis, Servers etc...)."""
def __init__(self, obj, connection=None):
super(BaseCollection, self).__init__(obj, connection=connection)
self._parse_items(obj)
self._attrs.append('items')
def _parse_links(self, obj): def __init__(self, url, connection_parameters):
"""links are special on a chassis; dont parse them""" super(BaseCollection, self).__init__(url, connection_parameters)
pass
def _parse_items(self, obj): self.links=[]
"""Map linked items to getter methods
The chassis resource returns a list of items and corresponding
link data in a separate entity.
"""
def getter(connection, href):
def _get():
return connection.rest_get(href, {})
return _get
self.items = [] #linksmembers = self.data.Links.Members
self._item_getters = [] #linksmembers = self.data.links.Member
if float(mapping.redfish_version) < 1.00:
linksmembers = getattr(self.data, mapping.redfish_mapper.map_links())
linksmembers = getattr(linksmembers, mapping.redfish_mapper.map_members())
else:
linksmembers = getattr(self.data, mapping.redfish_mapper.map_members())
for link in linksmembers:
#self.links.append(getattr(link,"@odata.id"))
#self.links.append(getattr(link,"href"))
self.links.append(urljoin(self.url, getattr(link, mapping.redfish_mapper.map_links_ref())))
if 'links' in obj and 'Member' in obj['links']:
# NOTE: this assumes the lists are ordered the same
counter = 0
for item in obj['links']['Member']:
self.items.append(obj['Items'][counter])
self._item_getters.append(
getter(self._conn, item['href']))
counter+=1
elif 'Items' in obj:
# TODO: find an example of this format and make sure it works
for item in obj['Items']:
if 'links' in item and 'self' in item['links']:
href = item['links']['self']['href']
self.items.append(item)
# TODO: implement paging support print self.links
# if 'links' in obj and 'NextPage' in obj['links']:
# next_page = THIS_URI + '?page=' + str(obj['links']['NextPage']['page'])
# do something with next_page URI
def __iter__(self):
for getter in self._item_getters:
yield getter()
class Root(Base): class Root(Base):
"""Root '/' resource class""" """Class to manage redfish Root data."""
def _parse_links(self, obj):
"""Map linked resources to getter functions
The root resource returns a dict of links to top-level resources
TODO: continue implementing customizations for top-level resources def get_api_version(self):
"""Return api version.
:returns: string -- version
:raises: AttributeError
""" """
mapping = { try:
'Systems': Systems, version = self.data.RedfishVersion
'Chassis': Chassis, except AttributeError:
'Managers': Base, version = self.data.ServiceVersion
'Schemas': Base,
'Registries': Base,
'Tasks': Base,
'AccountService': Base,
'Sessions': Base,
'EventService': Base,
}
def getter(connection, href, type): version = version.replace('.', '')
def _get(): version = version[0] + '.' + version[1:]
return mapping[type](connection.rest_get(href, {}), self._conn) return(version)
return _get
for k in obj['links']: def get_api_UUID(self):
ref = "get_" + k.lower() return self.data.UUID
self._links.append(ref)
href = obj['links'][k]['href']
setattr(self, ref, getter(self._conn, href, k))
class Chassis(BaseCollection): def get_api_link_to_server(self):
"""Chassis resource class""" """Return api link to server.
def __len__(self):
return len(self.items) :returns: string -- path
"""
return getattr(self.root.Links.Systems, "@odata.id")
class SessionService(Base):
"""Class to manage redfish SessionService data."""
pass
class Managers(Base):
def __init__(self, url, connection_parameters):
super(Managers, self).__init__(url, connection_parameters)
try:
# self.ethernet_interfaces_collection = EthernetInterfacesCollection(
# self.get_link_url("EthernetInterfaces"),
# connection_parameters
# )
# Works on proliant, need to treat 095 vs 0.96 differences
self.ethernet_interfaces_collection = EthernetInterfacesCollection(
self.get_link_url("EthernetNICs"),
connection_parameters
)
except:
pass
class ManagersCollection(BaseCollection):
"""Class to manage redfish ManagersCollection data."""
def __init__(self, url, connection_parameters):
super(ManagersCollection, self).__init__(url, connection_parameters)
self.managers_list = []
for link in self.links:
self.managers_list.append(Managers(link, connection_parameters))
class Systems(Base): class Systems(Base):
# TODO : Need to discuss with Bruno the required method.
# Also to check with the ironic driver requirement.
def __init__(self, url, connection_parameters):
super(Systems, self).__init__(url, connection_parameters)
def reset_system(self):
# Craft the request
action = dict()
action['Action'] = 'Reset'
action['ResetType'] = 'ForceRestart'
# perform the POST action
print self.api_url
response = self.api_url.post(verify=self.connection_parameters.verify_cert,
headers={'x-auth-token': self.connection_parameters.auth_token},
data=action
)
#TODO : treat response.
def get_bios_version(self):
try:
# Returned by proliant
return self.data.Bios.Current.VersionString
except:
# Returned by mockup.
# Hopefully this kind of discrepencies will be fixed with Redfish 1.0 (August)
return self.data.BiosVersion
class SystemsCollection(BaseCollection):
"""Class to manage redfish ManagersCollection data."""
def __init__(self, url, connection_parameters):
super(SystemsCollection, self).__init__(url, connection_parameters)
self.systems_list = []
for link in self.links:
self.systems_list.append(Systems(link, connection_parameters))
class EthernetInterfacesCollection(BaseCollection):
def __init__(self, url, connection_parameters):
super(EthernetInterfacesCollection, self).__init__(url, connection_parameters)
self.ethernet_interfaces_list = []
# Url returned by the mock up is wrong /redfish/v1/Managers/EthernetInterfaces/1 returns a 404.
# The correct one should be /redfish/v1/Managers/1/EthernetInterfaces/1
# Check more than 1 hour for this bug.... grrr....
for link in self.links:
self.ethernet_interfaces_list.append(EthernetInterfaces(link, connection_parameters))
class EthernetInterfaces(Base):
pass pass

View File

@ -3,5 +3,6 @@
# process, which may cause wedges in the gate later. # process, which may cause wedges in the gate later.
pbr>=0.6,!=0.7,<1.0 pbr>=0.6,!=0.7,<1.0
oslo.log>=1.0,<2.0 #oslo.log>=1.0,<2.0
Babel>=1.3 Babel>=1.3
tortilla>=0.4.1

View File

@ -3,8 +3,8 @@ name = python-redfish
summary = Reference implementation of Redfish standard client. summary = Reference implementation of Redfish standard client.
description-file = description-file =
README.rst README.rst
author = OpenStack author = Redfish dev team
author-email = openstack-dev@lists.openstack.org author-email = python-redfish@lists.mondorescue.org
home-page = http://www.openstack.org/ home-page = http://www.openstack.org/
classifier = classifier =
Environment :: OpenStack Environment :: OpenStack

View File

@ -8,8 +8,8 @@ coverage>=3.6
discover discover
python-subunit>=0.0.18 python-subunit>=0.0.18
sphinx>=1.1.2,!=1.2.0,!=1.3b1,<1.3 sphinx>=1.1.2,!=1.2.0,!=1.3b1,<1.3
oslosphinx>=2.2.0 # Apache-2.0 #oslosphinx>=2.2.0 # Apache-2.0
oslotest>=1.2.0 # Apache-2.0 #oslotest>=1.2.0 # Apache-2.0
testrepository>=0.0.18 testrepository>=0.0.18
testscenarios>=0.4 testscenarios>=0.4
testtools>=0.9.36,!=1.2.0 testtools>=0.9.36,!=1.2.0