Replacing pywsman with a simple wsman client
Change-Id: I3d87528f1d5286a53e6aba221766aae72513abdb
This commit is contained in:
parent
7be96d5f82
commit
d833babfe8
|
@ -0,0 +1,35 @@
|
||||||
|
#
|
||||||
|
# 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 BaseClientException(Exception):
|
||||||
|
|
||||||
|
msg_fmt = 'An unknown exception occurred'
|
||||||
|
|
||||||
|
def __init__(self, message=None, **kwargs):
|
||||||
|
message = self.msg_fmt % kwargs
|
||||||
|
super(BaseClientException, self).__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
class WSManRequestFailure(BaseClientException):
|
||||||
|
msg_fmt = ('WSMan request failed.')
|
||||||
|
|
||||||
|
|
||||||
|
class WSManInvalidResponse(BaseClientException):
|
||||||
|
msg_fmt = ('Invalid response received. Status code: "%(status_code)s", '
|
||||||
|
'reason: "%(reason)s"')
|
||||||
|
|
||||||
|
|
||||||
|
class WSManInvalidFilterDialect(BaseClientException):
|
||||||
|
msg_fmt = ('Invalid filter dialect "%(invalid_filter)s". '
|
||||||
|
'Supported options are %(supported)s')
|
|
@ -0,0 +1,23 @@
|
||||||
|
#
|
||||||
|
# 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 logging
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseTest(unittest.TestCase):
|
||||||
|
pass
|
|
@ -0,0 +1,278 @@
|
||||||
|
#
|
||||||
|
# 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 collections
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import lxml.etree
|
||||||
|
import lxml.objectify
|
||||||
|
import mock
|
||||||
|
import requests_mock
|
||||||
|
|
||||||
|
from dracclient import exceptions
|
||||||
|
from dracclient.tests import base
|
||||||
|
from dracclient.tests import utils as test_utils
|
||||||
|
import dracclient.wsman
|
||||||
|
|
||||||
|
|
||||||
|
class ClientTestCase(base.BaseTest):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super(ClientTestCase, self).setUp()
|
||||||
|
self.client = dracclient.wsman.Client(**test_utils.FAKE_ENDPOINT)
|
||||||
|
|
||||||
|
@requests_mock.Mocker()
|
||||||
|
def test_enumerate(self, mock_requests):
|
||||||
|
expected_resp = '<result>yay!</result>'
|
||||||
|
mock_requests.post('https://1.2.3.4:443/wsman', text=expected_resp)
|
||||||
|
|
||||||
|
resp = self.client.enumerate('resource', auto_pull=False)
|
||||||
|
self.assertEqual('yay!', resp.text)
|
||||||
|
|
||||||
|
def test_enumerate_with_request_failure(self):
|
||||||
|
self.client = dracclient.wsman.Client('malformed://^@*', 'user',
|
||||||
|
'pass')
|
||||||
|
|
||||||
|
self.assertRaises(exceptions.WSManRequestFailure,
|
||||||
|
self.client.enumerate, 'resource')
|
||||||
|
|
||||||
|
@requests_mock.Mocker()
|
||||||
|
def test_enumerate_with_invalid_status_code(self, mock_requests):
|
||||||
|
mock_requests.post('https://1.2.3.4:443/wsman', status_code=500,
|
||||||
|
reason='dumb request')
|
||||||
|
|
||||||
|
self.assertRaises(exceptions.WSManInvalidResponse,
|
||||||
|
self.client.enumerate, 'resource')
|
||||||
|
|
||||||
|
@requests_mock.Mocker()
|
||||||
|
def test_enumerate_with_auto_pull(self, mock_requests):
|
||||||
|
mock_requests.post(
|
||||||
|
'https://1.2.3.4:443/wsman',
|
||||||
|
[{'text': test_utils.WSManEnumerations['context'][0]},
|
||||||
|
{'text': test_utils.WSManEnumerations['context'][1]},
|
||||||
|
{'text': test_utils.WSManEnumerations['context'][2]},
|
||||||
|
{'text': test_utils.WSManEnumerations['context'][3]}])
|
||||||
|
|
||||||
|
resp_xml = self.client.enumerate('FooResource')
|
||||||
|
|
||||||
|
foo_resource_uri = 'http://FooResource'
|
||||||
|
bar_resource_uri = 'http://BarResource'
|
||||||
|
self.assertEqual(
|
||||||
|
3, len(resp_xml.findall('.//{%s}FooResource' % foo_resource_uri)))
|
||||||
|
self.assertEqual(
|
||||||
|
1, len(resp_xml.findall('.//{%s}BazResource' % bar_resource_uri)))
|
||||||
|
self.assertEqual(
|
||||||
|
0, len(resp_xml.findall(
|
||||||
|
'.//{%s}EnumerationContext' % dracclient.wsman.NS_WSMAN_ENUM)))
|
||||||
|
|
||||||
|
@requests_mock.Mocker()
|
||||||
|
@mock.patch.object(dracclient.wsman.Client, 'pull', autospec=True)
|
||||||
|
def test_enumerate_with_auto_pull_without_optimization(self, mock_requests,
|
||||||
|
mock_pull):
|
||||||
|
mock_requests.post('https://1.2.3.4:443/wsman',
|
||||||
|
text=test_utils.WSManEnumerations['context'][0])
|
||||||
|
mock_pull.return_value = lxml.etree.fromstring(
|
||||||
|
test_utils.WSManEnumerations['context'][3])
|
||||||
|
|
||||||
|
self.client.enumerate('FooResource', optimization=False, max_elems=42)
|
||||||
|
|
||||||
|
mock_pull.assert_called_once_with(self.client, 'FooResource',
|
||||||
|
'enum-context-uuid', 42)
|
||||||
|
|
||||||
|
@requests_mock.Mocker()
|
||||||
|
def test_pull(self, mock_requests):
|
||||||
|
expected_resp = '<result>yay!</result>'
|
||||||
|
mock_requests.post('https://1.2.3.4:443/wsman', text=expected_resp)
|
||||||
|
|
||||||
|
resp = self.client.pull('resource', 'context-uuid')
|
||||||
|
|
||||||
|
self.assertEqual('yay!', resp.text)
|
||||||
|
|
||||||
|
@requests_mock.Mocker()
|
||||||
|
def test_invoke(self, mock_requests):
|
||||||
|
expected_resp = '<result>yay!</result>'
|
||||||
|
mock_requests.post('https://1.2.3.4:443/wsman', text=expected_resp)
|
||||||
|
|
||||||
|
resp = self.client.invoke('http://resource', 'method',
|
||||||
|
{'selector': 'foo'}, {'property': 'bar'})
|
||||||
|
|
||||||
|
self.assertEqual('yay!', resp.text)
|
||||||
|
|
||||||
|
|
||||||
|
class PayloadTestCase(base.BaseTest):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super(PayloadTestCase, self).setUp()
|
||||||
|
dracclient.wsman.NS_MAP = collections.OrderedDict([
|
||||||
|
('s', dracclient.wsman.NS_SOAP_ENV),
|
||||||
|
('wsa', dracclient.wsman.NS_WS_ADDR),
|
||||||
|
('wsman', dracclient.wsman.NS_WSMAN)])
|
||||||
|
|
||||||
|
@mock.patch.object(uuid, 'uuid4', autospec=True)
|
||||||
|
def test_build_enum(self, mock_uuid):
|
||||||
|
expected_payload = """<?xml version="1.0" ?>
|
||||||
|
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
|
||||||
|
xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing"
|
||||||
|
xmlns:wsman="http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd">
|
||||||
|
<s:Header>
|
||||||
|
<wsa:To s:mustUnderstand="true">http://host:443/wsman</wsa:To>
|
||||||
|
<wsman:ResourceURI s:mustUnderstand="true">http://resource_uri</wsman:ResourceURI>
|
||||||
|
<wsa:MessageID s:mustUnderstand="true">uuid:1234-12</wsa:MessageID>
|
||||||
|
<wsa:ReplyTo>
|
||||||
|
<wsa:Address>http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous</wsa:Address>
|
||||||
|
</wsa:ReplyTo>
|
||||||
|
<wsa:Action s:mustUnderstand="true">http://schemas.xmlsoap.org/ws/2004/09/enumeration/Enumerate</wsa:Action>
|
||||||
|
</s:Header>
|
||||||
|
<s:Body>
|
||||||
|
<wsen:Enumerate xmlns:wsen="http://schemas.xmlsoap.org/ws/2004/09/enumeration">
|
||||||
|
<wsman:OptimizeEnumeration/>
|
||||||
|
<wsman:MaxElements>100</wsman:MaxElements>
|
||||||
|
</wsen:Enumerate>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>
|
||||||
|
""" # noqa
|
||||||
|
expected_payload_obj = lxml.objectify.fromstring(expected_payload)
|
||||||
|
|
||||||
|
mock_uuid.return_value = '1234-12'
|
||||||
|
payload = dracclient.wsman._EnumeratePayload(
|
||||||
|
'http://host:443/wsman', 'http://resource_uri').build()
|
||||||
|
payload_obj = lxml.objectify.fromstring(payload)
|
||||||
|
|
||||||
|
self.assertEqual(lxml.etree.tostring(expected_payload_obj),
|
||||||
|
lxml.etree.tostring(payload_obj))
|
||||||
|
|
||||||
|
def test_enumerate_without_optimization(self):
|
||||||
|
payload = dracclient.wsman._EnumeratePayload(
|
||||||
|
'http://host:443/wsman', 'http://resource_uri', optimization=False,
|
||||||
|
max_elems=42).build()
|
||||||
|
payload_xml = lxml.etree.fromstring(payload)
|
||||||
|
|
||||||
|
optimize_enum_elems = payload_xml.findall(
|
||||||
|
'.//{%s}OptimizeEnumeration' % dracclient.wsman.NS_WSMAN)
|
||||||
|
max_elem_elems = payload_xml.findall(
|
||||||
|
'.//{%s}MaxElements' % dracclient.wsman.NS_WSMAN)
|
||||||
|
self.assertEqual([], optimize_enum_elems)
|
||||||
|
self.assertEqual([], max_elem_elems)
|
||||||
|
|
||||||
|
@mock.patch.object(uuid, 'uuid4', autospec=True)
|
||||||
|
def test_build_enum_with_filter(self, mock_uuid):
|
||||||
|
expected_payload = """<?xml version="1.0" ?>
|
||||||
|
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
|
||||||
|
xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing"
|
||||||
|
xmlns:wsman="http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd">
|
||||||
|
<s:Header>
|
||||||
|
<wsa:To s:mustUnderstand="true">http://host:443/wsman</wsa:To>
|
||||||
|
<wsman:ResourceURI s:mustUnderstand="true">http://resource_uri</wsman:ResourceURI>
|
||||||
|
<wsa:MessageID s:mustUnderstand="true">uuid:1234-12</wsa:MessageID>
|
||||||
|
<wsa:ReplyTo>
|
||||||
|
<wsa:Address>http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous</wsa:Address>
|
||||||
|
</wsa:ReplyTo>
|
||||||
|
<wsa:Action s:mustUnderstand="true">http://schemas.xmlsoap.org/ws/2004/09/enumeration/Enumerate</wsa:Action>
|
||||||
|
</s:Header>
|
||||||
|
<s:Body>
|
||||||
|
<wsen:Enumerate xmlns:wsen="http://schemas.xmlsoap.org/ws/2004/09/enumeration">
|
||||||
|
<wsman:Filter Dialect="http://schemas.dmtf.org/wbem/cql/1/dsp0202.pdf">DROP TABLE users</wsman:Filter>
|
||||||
|
<wsman:OptimizeEnumeration/>
|
||||||
|
<wsman:MaxElements>100</wsman:MaxElements>
|
||||||
|
</wsen:Enumerate>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>
|
||||||
|
""" # noqa
|
||||||
|
expected_payload_obj = lxml.objectify.fromstring(expected_payload)
|
||||||
|
|
||||||
|
mock_uuid.return_value = '1234-12'
|
||||||
|
payload = dracclient.wsman._EnumeratePayload(
|
||||||
|
'http://host:443/wsman', 'http://resource_uri',
|
||||||
|
filter_query='DROP TABLE users', filter_dialect='cql').build()
|
||||||
|
payload_obj = lxml.objectify.fromstring(payload)
|
||||||
|
|
||||||
|
self.assertEqual(lxml.etree.tostring(expected_payload_obj),
|
||||||
|
lxml.etree.tostring(payload_obj))
|
||||||
|
|
||||||
|
def test_build_enum_with_invalid_filter_dialect(self):
|
||||||
|
invalid_dialect = 'foo'
|
||||||
|
self.assertRaises(exceptions.WSManInvalidFilterDialect,
|
||||||
|
dracclient.wsman._EnumeratePayload,
|
||||||
|
'http://host:443/wsman', 'http://resource_uri',
|
||||||
|
filter_query='DROP TABLE users',
|
||||||
|
filter_dialect=invalid_dialect)
|
||||||
|
|
||||||
|
@mock.patch.object(uuid, 'uuid4', autospec=True)
|
||||||
|
def test_build_pull(self, mock_uuid):
|
||||||
|
expected_payload = """<?xml version="1.0" ?>
|
||||||
|
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
|
||||||
|
xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing"
|
||||||
|
xmlns:wsman="http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd">
|
||||||
|
<s:Header>
|
||||||
|
<wsa:To s:mustUnderstand="true">http://host:443/wsman</wsa:To>
|
||||||
|
<wsman:ResourceURI s:mustUnderstand="true">http://resource_uri</wsman:ResourceURI>
|
||||||
|
<wsa:MessageID s:mustUnderstand="true">uuid:1234-12</wsa:MessageID>
|
||||||
|
<wsa:ReplyTo>
|
||||||
|
<wsa:Address>http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous</wsa:Address>
|
||||||
|
</wsa:ReplyTo>
|
||||||
|
<wsa:Action s:mustUnderstand="true">http://schemas.xmlsoap.org/ws/2004/09/enumeration/Pull</wsa:Action>
|
||||||
|
</s:Header>
|
||||||
|
<s:Body>
|
||||||
|
<wsen:Pull xmlns:wsen="http://schemas.xmlsoap.org/ws/2004/09/enumeration">
|
||||||
|
<wsen:EnumerationContext>context-uuid</wsen:EnumerationContext>
|
||||||
|
<wsman:MaxElements>100</wsman:MaxElements>
|
||||||
|
</wsen:Pull>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>
|
||||||
|
""" # noqa
|
||||||
|
expected_payload_obj = lxml.objectify.fromstring(expected_payload)
|
||||||
|
|
||||||
|
mock_uuid.return_value = '1234-12'
|
||||||
|
payload = dracclient.wsman._PullPayload('http://host:443/wsman',
|
||||||
|
'http://resource_uri',
|
||||||
|
'context-uuid').build()
|
||||||
|
payload_obj = lxml.objectify.fromstring(payload)
|
||||||
|
|
||||||
|
self.assertEqual(lxml.etree.tostring(expected_payload_obj),
|
||||||
|
lxml.etree.tostring(payload_obj))
|
||||||
|
|
||||||
|
@mock.patch.object(uuid, 'uuid4', autospec=True)
|
||||||
|
def test_build_invoke(self, mock_uuid):
|
||||||
|
expected_payload = """<?xml version="1.0" ?>
|
||||||
|
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
|
||||||
|
xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing"
|
||||||
|
xmlns:wsman="http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd">
|
||||||
|
<s:Header>
|
||||||
|
<wsa:To s:mustUnderstand="true">http://host:443/wsman</wsa:To>
|
||||||
|
<wsman:ResourceURI s:mustUnderstand="true">http://resource_uri</wsman:ResourceURI>
|
||||||
|
<wsa:MessageID s:mustUnderstand="true">uuid:1234-12</wsa:MessageID>
|
||||||
|
<wsa:ReplyTo>
|
||||||
|
<wsa:Address>http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous</wsa:Address>
|
||||||
|
</wsa:ReplyTo>
|
||||||
|
<wsa:Action s:mustUnderstand="true">http://resource_uri/method</wsa:Action>
|
||||||
|
<wsman:SelectorSet>
|
||||||
|
<wsman:Selector Name="selector">foo</wsman:Selector>
|
||||||
|
</wsman:SelectorSet>
|
||||||
|
</s:Header>
|
||||||
|
<s:Body>
|
||||||
|
<ns0:method_INPUT xmlns:ns0="http://resource_uri">
|
||||||
|
<ns0:property>bar</ns0:property>
|
||||||
|
</ns0:method_INPUT>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>
|
||||||
|
""" # noqa
|
||||||
|
expected_payload_obj = lxml.objectify.fromstring(expected_payload)
|
||||||
|
|
||||||
|
mock_uuid.return_value = '1234-12'
|
||||||
|
payload = dracclient.wsman._InvokePayload(
|
||||||
|
'http://host:443/wsman', 'http://resource_uri', 'method',
|
||||||
|
{'selector': 'foo'}, {'property': 'bar'}).build()
|
||||||
|
payload_obj = lxml.objectify.fromstring(payload)
|
||||||
|
|
||||||
|
self.assertEqual(lxml.etree.tostring(expected_payload_obj),
|
||||||
|
lxml.etree.tostring(payload_obj))
|
|
@ -0,0 +1,42 @@
|
||||||
|
#
|
||||||
|
# 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 os
|
||||||
|
|
||||||
|
FAKE_ENDPOINT = {
|
||||||
|
'host': '1.2.3.4',
|
||||||
|
'port': '443',
|
||||||
|
'path': '/wsman',
|
||||||
|
'protocol': 'https',
|
||||||
|
'username': 'admin',
|
||||||
|
'password': 's3cr3t'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_wsman_xml(name):
|
||||||
|
"""Helper function to load a WSMan XML response from a file."""
|
||||||
|
|
||||||
|
with open(os.path.join(os.path.dirname(__file__), 'wsman_mocks',
|
||||||
|
'%s.xml' % name), 'r') as f:
|
||||||
|
xml_body = f.read()
|
||||||
|
|
||||||
|
return xml_body
|
||||||
|
|
||||||
|
WSManEnumerations = {
|
||||||
|
'context': [
|
||||||
|
load_wsman_xml('wsman-enum_context-1'),
|
||||||
|
load_wsman_xml('wsman-enum_context-2'),
|
||||||
|
load_wsman_xml('wsman-enum_context-3'),
|
||||||
|
load_wsman_xml('wsman-enum_context-4'),
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
|
||||||
|
xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing"
|
||||||
|
xmlns:wsen="http://schemas.xmlsoap.org/ws/2004/09/enumeration">
|
||||||
|
<s:Header>
|
||||||
|
<wsa:To>http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous</wsa:To>
|
||||||
|
<wsa:Action>http://schemas.xmlsoap.org/ws/2004/09/enumeration/EnumerateResponse</wsa:Action>
|
||||||
|
<wsa:RelatesTo>uuid:89afbea0-2005-1005-8002-fd0aa2bdb228</wsa:RelatesTo>
|
||||||
|
<wsa:MessageID>uuid:babd467b-2009-1009-8096-fcc71555dbe0</wsa:MessageID>
|
||||||
|
</s:Header>
|
||||||
|
<s:Body>
|
||||||
|
<wsen:EnumerateResponse>
|
||||||
|
<wsen:EnumerationContext>enum-context-uuid</wsen:EnumerationContext>
|
||||||
|
</wsen:EnumerateResponse>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>
|
|
@ -0,0 +1,22 @@
|
||||||
|
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
|
||||||
|
xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing"
|
||||||
|
xmlns:wsen="http://schemas.xmlsoap.org/ws/2004/09/enumeration"
|
||||||
|
xmlns:n1="http://FooResource"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||||
|
<s:Header>
|
||||||
|
<wsa:To>http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous</wsa:To>
|
||||||
|
<wsa:Action>http://schemas.xmlsoap.org/ws/2004/09/enumeration/PullResponse</wsa:Action>
|
||||||
|
<wsa:RelatesTo>uuid:89ec4d7c-2005-1005-8003-fd0aa2bdb228</wsa:RelatesTo>
|
||||||
|
<wsa:MessageID>uuid:bac57212-2009-1009-8097-fcc71555dbe0</wsa:MessageID>
|
||||||
|
</s:Header>
|
||||||
|
<s:Body>
|
||||||
|
<wsen:PullResponse>
|
||||||
|
<wsen:EnumerationContext>enum-context-uuid</wsen:EnumerationContext>
|
||||||
|
<wsen:Items>
|
||||||
|
<n1:FooResource>
|
||||||
|
<n1:InstanceID>1</n1:InstanceID>
|
||||||
|
</n1:FooResource>
|
||||||
|
</wsen:Items>
|
||||||
|
</wsen:PullResponse>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>
|
|
@ -0,0 +1,24 @@
|
||||||
|
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
|
||||||
|
xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing"
|
||||||
|
xmlns:wsen="http://schemas.xmlsoap.org/ws/2004/09/enumeration"
|
||||||
|
xmlns:n1="http://FooResource">
|
||||||
|
<s:Header>
|
||||||
|
<wsa:To>http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous</wsa:To>
|
||||||
|
<wsa:Action>http://schemas.xmlsoap.org/ws/2004/09/enumeration/PullResponse</wsa:Action>
|
||||||
|
<wsa:RelatesTo>uuid:89f3b75a-2005-1005-8004-fd0aa2bdb228</wsa:RelatesTo>
|
||||||
|
<wsa:MessageID>uuid:baccc4b1-2009-1009-8098-fcc71555dbe0</wsa:MessageID>
|
||||||
|
</s:Header>
|
||||||
|
<s:Body>
|
||||||
|
<wsen:PullResponse>
|
||||||
|
<wsen:EnumerationContext>enum-context-uuid</wsen:EnumerationContext>
|
||||||
|
<wsen:Items>
|
||||||
|
<n1:FooResource>
|
||||||
|
<n1:InstanceID>2</n1:InstanceID>
|
||||||
|
</n1:FooResource>
|
||||||
|
<n1:FooResource>
|
||||||
|
<n1:InstanceID>3</n1:InstanceID>
|
||||||
|
</n1:FooResource>
|
||||||
|
</wsen:Items>
|
||||||
|
</wsen:PullResponse>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>
|
|
@ -0,0 +1,21 @@
|
||||||
|
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
|
||||||
|
xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing"
|
||||||
|
xmlns:wsen="http://schemas.xmlsoap.org/ws/2004/09/enumeration"
|
||||||
|
xmlns:n1="http://BarResource">
|
||||||
|
<s:Header>
|
||||||
|
<wsa:To>http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous</wsa:To>
|
||||||
|
<wsa:Action>http://schemas.xmlsoap.org/ws/2004/09/enumeration/PullResponse</wsa:Action>
|
||||||
|
<wsa:RelatesTo>uuid:8b0bcd65-2005-1005-8026-fd0aa2bdb228</wsa:RelatesTo>
|
||||||
|
<wsa:MessageID>uuid:bbe513cd-2009-1009-80ba-fcc71555dbe0</wsa:MessageID>
|
||||||
|
</s:Header>
|
||||||
|
<s:Body>
|
||||||
|
<wsen:PullResponse>
|
||||||
|
<wsen:Items>
|
||||||
|
<n1:BazResource>
|
||||||
|
<n1:InstanceID>4</n1:InstanceID>
|
||||||
|
</n1:BazResource>
|
||||||
|
</wsen:Items>
|
||||||
|
<wsen:EndOfSequence/>
|
||||||
|
</wsen:PullResponse>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>
|
|
@ -0,0 +1,374 @@
|
||||||
|
#
|
||||||
|
# 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 logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from lxml import etree as ElementTree
|
||||||
|
import requests
|
||||||
|
import requests.exceptions
|
||||||
|
|
||||||
|
from dracclient import exceptions
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
NS_SOAP_ENV = 'http://www.w3.org/2003/05/soap-envelope'
|
||||||
|
NS_WS_ADDR = 'http://schemas.xmlsoap.org/ws/2004/08/addressing'
|
||||||
|
NS_WS_ADDR_ANONYM_ROLE = ('http://schemas.xmlsoap.org/ws/2004/08/addressing/'
|
||||||
|
'role/anonymous')
|
||||||
|
NS_WSMAN = 'http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd'
|
||||||
|
NS_WSMAN_ENUM = 'http://schemas.xmlsoap.org/ws/2004/09/enumeration'
|
||||||
|
|
||||||
|
NS_MAP = {'s': NS_SOAP_ENV,
|
||||||
|
'wsa': NS_WS_ADDR,
|
||||||
|
'wsman': NS_WSMAN}
|
||||||
|
|
||||||
|
FILTER_DIALECT_MAP = {'cql': 'http://schemas.dmtf.org/wbem/cql/1/dsp0202.pdf',
|
||||||
|
'wql': 'http://schemas.microsoft.com/wbem/wsman/1/WQL'}
|
||||||
|
|
||||||
|
|
||||||
|
class Client(object):
|
||||||
|
"""Simple client for talking over WSMan protocol."""
|
||||||
|
|
||||||
|
def __init__(self, host, username, password, port=443, path='/wsman',
|
||||||
|
protocol='https'):
|
||||||
|
self.host = host
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.port = port
|
||||||
|
self.path = path
|
||||||
|
self.protocol = protocol
|
||||||
|
self.endpoint = ('%(protocol)s://%(host)s:%(port)s%(path)s' % {
|
||||||
|
'protocol': self.protocol,
|
||||||
|
'host': self.host,
|
||||||
|
'port': self.port,
|
||||||
|
'path': self.path})
|
||||||
|
|
||||||
|
def _do_request(self, payload):
|
||||||
|
payload = payload.build()
|
||||||
|
LOG.debug('Sending request to %(endpoint)s: %(payload)s',
|
||||||
|
{'endpoint': self.endpoint, 'payload': payload})
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
self.endpoint,
|
||||||
|
auth=requests.auth.HTTPBasicAuth(self.username, self.password),
|
||||||
|
data=payload,
|
||||||
|
# TODO(ifarkas): enable cert verification
|
||||||
|
verify=False)
|
||||||
|
except requests.exceptions.RequestException:
|
||||||
|
LOG.exception('Request failed')
|
||||||
|
raise exceptions.WSManRequestFailure()
|
||||||
|
|
||||||
|
LOG.debug('Received response from %(endpoint)s: %(payload)s',
|
||||||
|
{'endpoint': self.endpoint, 'payload': resp.content})
|
||||||
|
if not resp.ok:
|
||||||
|
raise exceptions.WSManInvalidResponse(
|
||||||
|
status_code=resp.status_code,
|
||||||
|
reason=resp.reason)
|
||||||
|
else:
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def enumerate(self, resource_uri, optimization=True, max_elems=100,
|
||||||
|
auto_pull=True, filter_query=None, filter_dialect='cql'):
|
||||||
|
"""Executes enumerate operation over WSMan.
|
||||||
|
|
||||||
|
:param resource_uri: URI of resource to enumerate.
|
||||||
|
:param optimization: flag to enable enumeration optimization. If
|
||||||
|
disabled, the enumeration returns only an
|
||||||
|
enumeration context.
|
||||||
|
:param max_elems: maximum number of elements returned by the operation.
|
||||||
|
:param auto_pull: flag to enable automatic pull on the enumeration
|
||||||
|
context, merging the items returned.
|
||||||
|
:param filter_query: filter query string.
|
||||||
|
:param filter_dialect: filter dialect. Valid options are: 'cql' and
|
||||||
|
'wql'.
|
||||||
|
:returns: an lxml.etree.Element object of the response received.
|
||||||
|
:raises: WSManRequestFailure on request failures
|
||||||
|
:raises: WSManInvalidResponse when receiving invalid response
|
||||||
|
"""
|
||||||
|
|
||||||
|
payload = _EnumeratePayload(self.endpoint, resource_uri,
|
||||||
|
optimization, max_elems,
|
||||||
|
filter_query, filter_dialect)
|
||||||
|
|
||||||
|
resp = self._do_request(payload)
|
||||||
|
resp_xml = ElementTree.fromstring(resp.content)
|
||||||
|
|
||||||
|
if auto_pull:
|
||||||
|
find_items_query = './/{%s}Items' % NS_WSMAN_ENUM
|
||||||
|
full_resp_xml = resp_xml
|
||||||
|
|
||||||
|
context = self._enum_context(full_resp_xml)
|
||||||
|
while context is not None:
|
||||||
|
resp_xml = self.pull(resource_uri, context, max_elems)
|
||||||
|
context = self._enum_context(resp_xml)
|
||||||
|
|
||||||
|
items_xml = full_resp_xml.find(find_items_query)
|
||||||
|
if items_xml is not None:
|
||||||
|
# merge enumeration items
|
||||||
|
for item in resp_xml.find(find_items_query):
|
||||||
|
items_xml.append(item)
|
||||||
|
else:
|
||||||
|
full_resp_xml = resp_xml
|
||||||
|
|
||||||
|
# remove enumeration context because items are already merged
|
||||||
|
enum_context_elem = full_resp_xml.find('.//{%s}EnumerationContext'
|
||||||
|
% NS_WSMAN_ENUM)
|
||||||
|
if enum_context_elem is not None:
|
||||||
|
enum_context_elem.getparent().remove(enum_context_elem)
|
||||||
|
|
||||||
|
return full_resp_xml
|
||||||
|
else:
|
||||||
|
return resp_xml
|
||||||
|
|
||||||
|
def pull(self, resource_uri, context, max_elems=100):
|
||||||
|
"""Executes pull operation over WSMan.
|
||||||
|
|
||||||
|
:param resource_uri: URI of resource to pull
|
||||||
|
:param context: enumeration context
|
||||||
|
:param max_elems: maximum number of elements returned by the operation
|
||||||
|
:returns: an lxml.etree.Element object of the response received
|
||||||
|
:raises: WSManRequestFailure on request failures
|
||||||
|
:raises: WSManInvalidResponse when receiving invalid response
|
||||||
|
"""
|
||||||
|
|
||||||
|
payload = _PullPayload(self.endpoint, resource_uri, context,
|
||||||
|
max_elems)
|
||||||
|
resp = self._do_request(payload)
|
||||||
|
resp_xml = ElementTree.fromstring(resp.content)
|
||||||
|
|
||||||
|
return resp_xml
|
||||||
|
|
||||||
|
def invoke(self, resource_uri, method, selectors, properties):
|
||||||
|
"""Executes invoke operation over WSMan.
|
||||||
|
|
||||||
|
:param resource_uri: URI of resource to invoke
|
||||||
|
:param method: name of the method to invoke
|
||||||
|
:param selector: dict of selectors
|
||||||
|
:param properties: dict of properties
|
||||||
|
:returns: an lxml.etree.Element object of the response received.
|
||||||
|
:raises: WSManRequestFailure on request failures
|
||||||
|
:raises: WSManInvalidResponse when receiving invalid response
|
||||||
|
"""
|
||||||
|
|
||||||
|
payload = _InvokePayload(self.endpoint, resource_uri, method,
|
||||||
|
selectors, properties)
|
||||||
|
resp = self._do_request(payload)
|
||||||
|
resp_xml = ElementTree.fromstring(resp.content)
|
||||||
|
|
||||||
|
return resp_xml
|
||||||
|
|
||||||
|
def _enum_context(self, resp):
|
||||||
|
context_elem = resp.find('.//{%s}EnumerationContext' % NS_WSMAN_ENUM)
|
||||||
|
if context_elem is not None:
|
||||||
|
return context_elem.text
|
||||||
|
|
||||||
|
|
||||||
|
class _Payload(object):
|
||||||
|
"""Payload generation for WSMan requests."""
|
||||||
|
|
||||||
|
def build(self):
|
||||||
|
request = self._create_envelope()
|
||||||
|
self._add_header(request)
|
||||||
|
self._add_body(request)
|
||||||
|
|
||||||
|
return ElementTree.tostring(request)
|
||||||
|
|
||||||
|
def _create_envelope(self):
|
||||||
|
return ElementTree.Element('{%s}Envelope' % NS_SOAP_ENV, nsmap=NS_MAP)
|
||||||
|
|
||||||
|
def _add_header(self, envelope):
|
||||||
|
header = ElementTree.SubElement(envelope, '{%s}Header' % NS_SOAP_ENV)
|
||||||
|
|
||||||
|
qn_must_understand = ElementTree.QName(NS_SOAP_ENV, 'mustUnderstand')
|
||||||
|
|
||||||
|
to_elem = ElementTree.SubElement(header, '{%s}To' % NS_WS_ADDR)
|
||||||
|
to_elem.set(qn_must_understand, 'true')
|
||||||
|
to_elem.text = self.endpoint
|
||||||
|
|
||||||
|
resource_elem = ElementTree.SubElement(header,
|
||||||
|
'{%s}ResourceURI' % NS_WSMAN)
|
||||||
|
resource_elem.set(qn_must_understand, 'true')
|
||||||
|
resource_elem.text = self.resource_uri
|
||||||
|
|
||||||
|
msg_id_elem = ElementTree.SubElement(header,
|
||||||
|
'{%s}MessageID' % NS_WS_ADDR)
|
||||||
|
msg_id_elem.set(qn_must_understand, 'true')
|
||||||
|
msg_id_elem.text = 'uuid:%s' % uuid.uuid4()
|
||||||
|
|
||||||
|
reply_to_elem = ElementTree.SubElement(header,
|
||||||
|
'{%s}ReplyTo' % NS_WS_ADDR)
|
||||||
|
reply_to_addr_elem = ElementTree.SubElement(reply_to_elem,
|
||||||
|
'{%s}Address' % NS_WS_ADDR)
|
||||||
|
reply_to_addr_elem.text = NS_WS_ADDR_ANONYM_ROLE
|
||||||
|
|
||||||
|
return header
|
||||||
|
|
||||||
|
def _add_body(self, envelope):
|
||||||
|
return ElementTree.SubElement(envelope, '{%s}Body' % NS_SOAP_ENV)
|
||||||
|
|
||||||
|
|
||||||
|
class _EnumeratePayload(_Payload):
|
||||||
|
"""Payload generation for WSMan enumerate operation."""
|
||||||
|
|
||||||
|
def __init__(self, endpoint, resource_uri, optimization=True,
|
||||||
|
max_elems=100, filter_query=None, filter_dialect=None):
|
||||||
|
self.endpoint = endpoint
|
||||||
|
self.resource_uri = resource_uri
|
||||||
|
self.filter_dialect = None
|
||||||
|
self.filter_query = None
|
||||||
|
self.optimization = optimization
|
||||||
|
self.max_elems = max_elems
|
||||||
|
|
||||||
|
if filter_query is not None:
|
||||||
|
try:
|
||||||
|
self.filter_dialect = FILTER_DIALECT_MAP[filter_dialect]
|
||||||
|
except KeyError:
|
||||||
|
valid_opts = ', '.join(FILTER_DIALECT_MAP)
|
||||||
|
raise exceptions.WSManInvalidFilterDialect(
|
||||||
|
invalid_filter=filter_dialect, supported=valid_opts)
|
||||||
|
|
||||||
|
self.filter_query = filter_query
|
||||||
|
|
||||||
|
def _add_header(self, envelope):
|
||||||
|
header = super(_EnumeratePayload, self)._add_header(envelope)
|
||||||
|
|
||||||
|
action_elem = ElementTree.SubElement(header, '{%s}Action' % NS_WS_ADDR)
|
||||||
|
action_elem.set('{%s}mustUnderstand' % NS_SOAP_ENV, 'true')
|
||||||
|
action_elem.text = NS_WSMAN_ENUM + '/Enumerate'
|
||||||
|
|
||||||
|
return header
|
||||||
|
|
||||||
|
def _add_body(self, envelope):
|
||||||
|
body = super(_EnumeratePayload, self)._add_body(envelope)
|
||||||
|
|
||||||
|
enum_elem = ElementTree.SubElement(body,
|
||||||
|
'{%s}Enumerate' % NS_WSMAN_ENUM,
|
||||||
|
nsmap={'wsen': NS_WSMAN_ENUM})
|
||||||
|
|
||||||
|
if self.filter_query is not None:
|
||||||
|
self._add_filter(enum_elem)
|
||||||
|
|
||||||
|
if self.optimization:
|
||||||
|
self._add_enum_optimization(enum_elem)
|
||||||
|
|
||||||
|
return body
|
||||||
|
|
||||||
|
def _add_enum_optimization(self, enum_elem):
|
||||||
|
ElementTree.SubElement(enum_elem,
|
||||||
|
'{%s}OptimizeEnumeration' % NS_WSMAN)
|
||||||
|
|
||||||
|
max_elem_elem = ElementTree.SubElement(enum_elem,
|
||||||
|
'{%s}MaxElements' % NS_WSMAN)
|
||||||
|
max_elem_elem.text = str(self.max_elems)
|
||||||
|
|
||||||
|
def _add_filter(self, enum_elem):
|
||||||
|
filter_elem = ElementTree.SubElement(enum_elem,
|
||||||
|
'{%s}Filter' % NS_WSMAN)
|
||||||
|
filter_elem.set('Dialect', self.filter_dialect)
|
||||||
|
filter_elem.text = self.filter_query
|
||||||
|
|
||||||
|
|
||||||
|
class _PullPayload(_Payload):
|
||||||
|
"""Payload generation for WSMan pull operation."""
|
||||||
|
|
||||||
|
def __init__(self, endpoint, resource_uri, context, max_elems=100):
|
||||||
|
self.endpoint = endpoint
|
||||||
|
self.resource_uri = resource_uri
|
||||||
|
self.context = context
|
||||||
|
self.max_elems = max_elems
|
||||||
|
|
||||||
|
def _add_header(self, envelope):
|
||||||
|
header = super(_PullPayload, self)._add_header(envelope)
|
||||||
|
|
||||||
|
action_elem = ElementTree.SubElement(header, '{%s}Action' % NS_WS_ADDR)
|
||||||
|
action_elem.set('{%s}mustUnderstand' % NS_SOAP_ENV, 'true')
|
||||||
|
action_elem.text = NS_WSMAN_ENUM + '/Pull'
|
||||||
|
|
||||||
|
return header
|
||||||
|
|
||||||
|
def _add_body(self, envelope):
|
||||||
|
body = super(_PullPayload, self)._add_body(envelope)
|
||||||
|
|
||||||
|
pull_elem = ElementTree.SubElement(body,
|
||||||
|
'{%s}Pull' % NS_WSMAN_ENUM,
|
||||||
|
nsmap={'wsen': NS_WSMAN_ENUM})
|
||||||
|
|
||||||
|
enum_context_elem = ElementTree.SubElement(
|
||||||
|
pull_elem, '{%s}EnumerationContext' % NS_WSMAN_ENUM)
|
||||||
|
enum_context_elem.text = self.context
|
||||||
|
|
||||||
|
self._add_enum_optimization(pull_elem)
|
||||||
|
|
||||||
|
return body
|
||||||
|
|
||||||
|
def _add_enum_optimization(self, pull_elem):
|
||||||
|
max_elem_elem = ElementTree.SubElement(pull_elem,
|
||||||
|
'{%s}MaxElements' % NS_WSMAN)
|
||||||
|
max_elem_elem.text = str(self.max_elems)
|
||||||
|
|
||||||
|
|
||||||
|
class _InvokePayload(_Payload):
|
||||||
|
"""Payload generation for WSMan invoke operation."""
|
||||||
|
|
||||||
|
def __init__(self, endpoint, resource_uri, method, selectors=None,
|
||||||
|
properties=None):
|
||||||
|
self.endpoint = endpoint
|
||||||
|
self.resource_uri = resource_uri
|
||||||
|
self.method = method
|
||||||
|
self.selectors = selectors
|
||||||
|
self.properties = properties
|
||||||
|
|
||||||
|
def _add_header(self, envelope):
|
||||||
|
header = super(_InvokePayload, self)._add_header(envelope)
|
||||||
|
|
||||||
|
action_elem = ElementTree.SubElement(header, '{%s}Action' % NS_WS_ADDR)
|
||||||
|
action_elem.set('{%s}mustUnderstand' % NS_SOAP_ENV, 'true')
|
||||||
|
action_elem.text = ('%(resource_uri)s/%(method)s' %
|
||||||
|
{'resource_uri': self.resource_uri,
|
||||||
|
'method': self.method})
|
||||||
|
|
||||||
|
self._add_selectors(header)
|
||||||
|
|
||||||
|
return header
|
||||||
|
|
||||||
|
def _add_body(self, envelope):
|
||||||
|
body = super(_InvokePayload, self)._add_body(envelope)
|
||||||
|
self._add_properties(body)
|
||||||
|
|
||||||
|
return body
|
||||||
|
|
||||||
|
def _add_selectors(self, header):
|
||||||
|
selector_set_elem = ElementTree.SubElement(
|
||||||
|
header, '{%s}SelectorSet' % NS_WSMAN)
|
||||||
|
|
||||||
|
for (name, value) in self.selectors.items():
|
||||||
|
selector_elem = ElementTree.SubElement(selector_set_elem,
|
||||||
|
'{%s}Selector' % NS_WSMAN)
|
||||||
|
selector_elem.set('Name', name)
|
||||||
|
selector_elem.text = value
|
||||||
|
|
||||||
|
def _add_properties(self, body):
|
||||||
|
method_elem = ElementTree.SubElement(
|
||||||
|
body,
|
||||||
|
('{%(resource_uri)s}%(method)s_INPUT' %
|
||||||
|
{'resource_uri': self.resource_uri,
|
||||||
|
'method': self.method}))
|
||||||
|
|
||||||
|
for (name, value) in self.properties.items():
|
||||||
|
property_elem = ElementTree.SubElement(
|
||||||
|
method_elem,
|
||||||
|
('{%(resource_uri)s}%(name)s' %
|
||||||
|
{'resource_uri': self.resource_uri,
|
||||||
|
'name': name}))
|
||||||
|
property_elem.text = value
|
|
@ -2,4 +2,5 @@
|
||||||
# of appearance. Changing the order has an impact on the overall integration
|
# of appearance. Changing the order has an impact on the overall integration
|
||||||
# process, which may cause wedges in the gate later.
|
# process, which may cause wedges in the gate later.
|
||||||
|
|
||||||
pywsman>=2.3
|
lxml>=2.3
|
||||||
|
requests>=2.5.2
|
||||||
|
|
|
@ -5,4 +5,5 @@
|
||||||
coverage>=3.6
|
coverage>=3.6
|
||||||
doc8
|
doc8
|
||||||
hacking>=0.10.0,<0.11
|
hacking>=0.10.0,<0.11
|
||||||
mock>=1.2
|
mock>=1.2
|
||||||
|
requests-mock>=0.6
|
||||||
|
|
Loading…
Reference in New Issue