summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorFUJITA Tomonori <fujita.tomonori@lab.ntt.co.jp>2013-03-03 12:05:21 +0900
committerFUJITA Tomonori <fujita.tomonori@lab.ntt.co.jp>2013-03-05 07:37:44 +0900
commit8649e9e15356dc2bd6faa9c7e8a41eb89921280c (patch)
treedb18de798d83a87737768b8a767b9f3bdbdbc304
parentd1a87e87c13568bd30394237e2ee21b21a843b8a (diff)
contrib: import ncclient library (NETCONF clients)
NETCONF clients https://github.com/leopoul/ncclient/ Signed-off-by: FUJITA Tomonori <fujita.tomonori@lab.ntt.co.jp>
-rw-r--r--ryu/contrib/ncclient/__init__.py22
-rw-r--r--ryu/contrib/ncclient/capabilities.py68
-rw-r--r--ryu/contrib/ncclient/debug.py24
-rw-r--r--ryu/contrib/ncclient/manager.py177
-rw-r--r--ryu/contrib/ncclient/operations/__init__.py51
-rw-r--r--ryu/contrib/ncclient/operations/edit.py143
-rw-r--r--ryu/contrib/ncclient/operations/errors.py24
-rw-r--r--ryu/contrib/ncclient/operations/flowmon.py39
-rw-r--r--ryu/contrib/ncclient/operations/lock.py70
-rw-r--r--ryu/contrib/ncclient/operations/retrieve.py127
-rw-r--r--ryu/contrib/ncclient/operations/rpc.py373
-rw-r--r--ryu/contrib/ncclient/operations/session.py44
-rw-r--r--ryu/contrib/ncclient/operations/subscribe.py24
-rw-r--r--ryu/contrib/ncclient/operations/util.py65
-rw-r--r--ryu/contrib/ncclient/transport/__init__.py30
-rw-r--r--ryu/contrib/ncclient/transport/errors.py41
-rw-r--r--ryu/contrib/ncclient/transport/session.py229
-rw-r--r--ryu/contrib/ncclient/transport/ssh.py312
-rw-r--r--ryu/contrib/ncclient/xml_.py108
-rw-r--r--setup.cfg2
-rw-r--r--tools/pip-requires1
21 files changed, 1973 insertions, 1 deletions
diff --git a/ryu/contrib/ncclient/__init__.py b/ryu/contrib/ncclient/__init__.py
new file mode 100644
index 00000000..3d806d69
--- /dev/null
+++ b/ryu/contrib/ncclient/__init__.py
@@ -0,0 +1,22 @@
+# Copyright 2009 Shikhar Bhushan
+#
+# 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 sys
+
+if sys.version_info < (2, 6):
+ raise RuntimeError('You need Python 2.6+ for this module.')
+
+class NCClientError(Exception):
+ "Base type for all NCClient errors"
+ pass
diff --git a/ryu/contrib/ncclient/capabilities.py b/ryu/contrib/ncclient/capabilities.py
new file mode 100644
index 00000000..9a7f2476
--- /dev/null
+++ b/ryu/contrib/ncclient/capabilities.py
@@ -0,0 +1,68 @@
+# Copyright 2009 Shikhar Bhushan
+#
+# 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.
+
+def _abbreviate(uri):
+ if uri.startswith("urn:ietf:params") and ":netconf:" in uri:
+ splitted = uri.split(":")
+ if ":capability:" in uri:
+ if uri.startswith("urn:ietf:params:xml:ns:netconf"):
+ name, version = splitted[7], splitted[8]
+ else:
+ name, version = splitted[5], splitted[6]
+ return [ ":" + name, ":" + name + ":" + version ]
+ elif ":base:" in uri:
+ if uri.startswith("urn:ietf:params:xml:ns:netconf"):
+ return [ ":base", ":base" + ":" + splitted[7] ]
+ else:
+ return [ ":base", ":base" + ":" + splitted[5] ]
+ return []
+
+def schemes(url_uri):
+ "Given a URI that has a *scheme* query string (i.e. `:url` capability URI), will return a list of supported schemes."
+ return url_uri.partition("?scheme=")[2].split(",")
+
+class Capabilities:
+
+ "Represents the set of capabilities available to a NETCONF client or server. It is initialized with a list of capability URI's."
+
+ def __init__(self, capabilities):
+ self._dict = {}
+ for uri in capabilities:
+ self._dict[uri] = _abbreviate(uri)
+
+ def __contains__(self, key):
+ if key in self._dict:
+ return True
+ for abbrs in self._dict.values():
+ if key in abbrs:
+ return True
+ return False
+
+ def __len__(self):
+ return len(self._dict)
+
+ def __iter__(self):
+ return self._dict.iterkeys()
+
+ def __repr__(self):
+ return repr(self._dict.keys())
+
+ def add(self, uri):
+ "Add a capability."
+ self._dict[uri] = _abbreviate(uri)
+
+ def remove(self, uri):
+ "Remove a capability."
+ if key in self._dict:
+ del self._dict[key] \ No newline at end of file
diff --git a/ryu/contrib/ncclient/debug.py b/ryu/contrib/ncclient/debug.py
new file mode 100644
index 00000000..65429cfe
--- /dev/null
+++ b/ryu/contrib/ncclient/debug.py
@@ -0,0 +1,24 @@
+# Copyright 2009 Shikhar Bhushan
+#
+# 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 ncclient.transport import SessionListener
+
+class PrintListener(SessionListener):
+
+ def callback(self, root, raw):
+ print('\n# RECEIVED MESSAGE with root=[tag=%r, attrs=%r] #\n%r\n' %
+ (root[0], root[1], raw))
+
+ def errback(self, err):
+ print('\n# RECEIVED ERROR #\n%r\n' % err)
diff --git a/ryu/contrib/ncclient/manager.py b/ryu/contrib/ncclient/manager.py
new file mode 100644
index 00000000..eb61727b
--- /dev/null
+++ b/ryu/contrib/ncclient/manager.py
@@ -0,0 +1,177 @@
+# Copyright 2009 Shikhar Bhushan
+# Copyright 2011 Leonidas Poulopoulos
+#
+# 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.
+
+"""This module is a thin layer of abstraction around the library. It exposes all core functionality."""
+
+import capabilities
+import operations
+import transport
+
+import logging
+
+logger = logging.getLogger('ncclient.manager')
+
+CAPABILITIES = [
+ "urn:ietf:params:netconf:base:1.0",
+ "urn:ietf:params:netconf:capability:writable-running:1.0",
+ "urn:ietf:params:netconf:capability:candidate:1.0",
+ "urn:ietf:params:netconf:capability:confirmed-commit:1.0",
+ "urn:ietf:params:netconf:capability:rollback-on-error:1.0",
+ "urn:ietf:params:netconf:capability:startup:1.0",
+ "urn:ietf:params:netconf:capability:url:1.0?scheme=http,ftp,file,https,sftp",
+ "urn:ietf:params:netconf:capability:validate:1.0",
+ "urn:ietf:params:netconf:capability:xpath:1.0",
+ "urn:liberouter:params:netconf:capability:power-control:1.0"
+ "urn:ietf:params:netconf:capability:interleave:1.0"
+]
+"""A list of URI's representing the client's capabilities. This is used during the initial capability exchange. Modify this if you need to announce some capability not already included."""
+
+OPERATIONS = {
+ "get": operations.Get,
+ "get_config": operations.GetConfig,
+ "dispatch": operations.Dispatch,
+ "edit_config": operations.EditConfig,
+ "copy_config": operations.CopyConfig,
+ "validate": operations.Validate,
+ "commit": operations.Commit,
+ "discard_changes": operations.DiscardChanges,
+ "delete_config": operations.DeleteConfig,
+ "lock": operations.Lock,
+ "unlock": operations.Unlock,
+ "close_session": operations.CloseSession,
+ "kill_session": operations.KillSession,
+ "poweroff_machine": operations.PoweroffMachine,
+ "reboot_machine": operations.RebootMachine
+}
+"""Dictionary of method names and corresponding :class:`~ncclient.operations.RPC` subclasses. It is used to lookup operations, e.g. `get_config` is mapped to :class:`~ncclient.operations.GetConfig`. It is thus possible to add additional operations to the :class:`Manager` API."""
+
+def connect_ssh(*args, **kwds):
+ """Initialize a :class:`Manager` over the SSH transport. For documentation of arguments see :meth:`ncclient.transport.SSHSession.connect`.
+
+ The underlying :class:`ncclient.transport.SSHSession` is created with :data:`CAPABILITIES`. It is first instructed to :meth:`~ncclient.transport.SSHSession.load_known_hosts` and then all the provided arguments are passed directly to its implementation of :meth:`~ncclient.transport.SSHSession.connect`.
+ """
+ session = transport.SSHSession(capabilities.Capabilities(CAPABILITIES))
+ session.load_known_hosts()
+ session.connect(*args, **kwds)
+ return Manager(session)
+
+connect = connect_ssh
+"Same as :func:`connect_ssh`, since SSH is the default (and currently, the only) transport."
+
+class OpExecutor(type):
+
+ def __new__(cls, name, bases, attrs):
+ def make_wrapper(op_cls):
+ def wrapper(self, *args, **kwds):
+ return self.execute(op_cls, *args, **kwds)
+ wrapper.func_doc = op_cls.request.func_doc
+ return wrapper
+ for op_name, op_cls in OPERATIONS.iteritems():
+ attrs[op_name] = make_wrapper(op_cls)
+ return super(OpExecutor, cls).__new__(cls, name, bases, attrs)
+
+class Manager(object):
+
+ """For details on the expected behavior of the operations and their parameters refer to :rfc:`4741`.
+
+ Manager instances are also context managers so you can use it like this::
+
+ with manager.connect("host") as m:
+ # do your stuff
+
+ ... or like this::
+
+ m = manager.connect("host")
+ try:
+ # do your stuff
+ finally:
+ m.close_session()
+ """
+
+ __metaclass__ = OpExecutor
+
+ def __init__(self, session, timeout=30):
+ self._session = session
+ self._async_mode = False
+ self._timeout = timeout
+ self._raise_mode = operations.RaiseMode.ALL
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args):
+ self.close_session()
+ return False
+
+ def __set_timeout(self, timeout):
+ self._timeout = timeout
+
+ def __set_async_mode(self, mode):
+ self._async_mode = mode
+
+ def __set_raise_mode(self, mode):
+ assert(mode in (operations.RaiseMode.NONE, operations.RaiseMode.ERRORS, operations.RaiseMode.ALL))
+ self._raise_mode = mode
+
+ def execute(self, cls, *args, **kwds):
+ return cls(self._session,
+ async=self._async_mode,
+ timeout=self._timeout,
+ raise_mode=self._raise_mode).request(*args, **kwds)
+
+ def locked(self, target):
+ """Returns a context manager for a lock on a datastore, where *target* is the name of the configuration datastore to lock, e.g.::
+
+ with m.locked("running"):
+ # do your stuff
+
+ ... instead of::
+
+ m.lock("running")
+ try:
+ # do your stuff
+ finally:
+ m.unlock("running")
+ """
+ return operations.LockContext(self._session, target)
+
+ @property
+ def client_capabilities(self):
+ ":class:`~ncclient.capabilities.Capabilities` object representing the client's capabilities."
+ return self._session._client_capabilities
+
+ @property
+ def server_capabilities(self):
+ ":class:`~ncclient.capabilities.Capabilities` object representing the server's capabilities."
+ return self._session._server_capabilities
+
+ @property
+ def session_id(self):
+ "`session-id` assigned by the NETCONF server."
+ return self._session.id
+
+ @property
+ def connected(self):
+ "Whether currently connected to the NETCONF server."
+ return self._session.connected
+
+ async_mode = property(fget=lambda self: self._async_mode, fset=__set_async_mode)
+ "Specify whether operations are executed asynchronously (`True`) or synchronously (`False`) (the default)."
+
+ timeout = property(fget=lambda self: self._timeout, fset=__set_timeout)
+ "Specify the timeout for synchronous RPC requests."
+
+ raise_mode = property(fget=lambda self: self._raise_mode, fset=__set_raise_mode)
+ "Specify which errors are raised as :exc:`~ncclient.operations.RPCError` exceptions. Valid values are the constants defined in :class:`~ncclient.operations.RaiseMode`. The default value is :attr:`~ncclient.operations.RaiseMode.ALL`."
diff --git a/ryu/contrib/ncclient/operations/__init__.py b/ryu/contrib/ncclient/operations/__init__.py
new file mode 100644
index 00000000..1f56b2f0
--- /dev/null
+++ b/ryu/contrib/ncclient/operations/__init__.py
@@ -0,0 +1,51 @@
+# Copyright 2009 Shikhar Bhushan
+#
+# 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 errors import OperationError, TimeoutExpiredError, MissingCapabilityError
+from rpc import RPC, RPCReply, RPCError, RaiseMode
+
+# rfc4741 ops
+from retrieve import Get, GetConfig, GetReply, Dispatch
+from edit import EditConfig, CopyConfig, DeleteConfig, Validate, Commit, DiscardChanges
+from session import CloseSession, KillSession
+from lock import Lock, Unlock, LockContext
+# others...
+from flowmon import PoweroffMachine, RebootMachine
+
+__all__ = [
+ 'RPC',
+ 'RPCReply',
+ 'RPCError',
+ 'RaiseMode',
+ 'Get',
+ 'GetConfig',
+ 'Dispatch',
+ 'GetReply',
+ 'EditConfig',
+ 'CopyConfig',
+ 'Validate',
+ 'Commit',
+ 'DiscardChanges',
+ 'DeleteConfig',
+ 'Lock',
+ 'Unlock',
+ 'PoweroffMachine',
+ 'RebootMachine',
+ 'LockContext',
+ 'CloseSession',
+ 'KillSession',
+ 'OperationError',
+ 'TimeoutExpiredError',
+ 'MissingCapabilityError'
+]
diff --git a/ryu/contrib/ncclient/operations/edit.py b/ryu/contrib/ncclient/operations/edit.py
new file mode 100644
index 00000000..a2dbd942
--- /dev/null
+++ b/ryu/contrib/ncclient/operations/edit.py
@@ -0,0 +1,143 @@
+# Copyright 2009 Shikhar Bhushan
+#
+# 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 ncclient.xml_ import *
+
+from rpc import RPC
+
+import util
+
+import logging
+
+logger = logging.getLogger("ncclient.operations.edit")
+
+"Operations related to changing device configuration"
+
+class EditConfig(RPC):
+ "`edit-config` RPC"
+
+ def request(self, target, config, default_operation=None, test_option=None, error_option=None):
+ """Loads all or part of the specified *config* to the *target* configuration datastore.
+
+ *target* is the name of the configuration datastore being edited
+
+ *config* is the configuration, which must be rooted in the `config` element. It can be specified either as a string or an :class:`~xml.etree.ElementTree.Element`.
+
+ *default_operation* if specified must be one of { `"merge"`, `"replace"`, or `"none"` }
+
+ *test_option* if specified must be one of { `"test_then_set"`, `"set"` }
+
+ *error_option* if specified must be one of { `"stop-on-error"`, `"continue-on-error"`, `"rollback-on-error"` }
+
+ The `"rollback-on-error"` *error_option* depends on the `:rollback-on-error` capability.
+ """
+ node = new_ele("edit-config")
+ node.append(util.datastore_or_url("target", target, self._assert))
+ if error_option is not None:
+ if error_option == "rollback-on-error":
+ self._assert(":rollback-on-error")
+ sub_ele(node, "error-option").text = error_option
+ if test_option is not None:
+ self._assert(':validate')
+ sub_ele(node, "test-option").text = test_option
+ if default_operation is not None:
+ # TODO: check if it is a valid default-operation
+ sub_ele(node, "default-operation").text = default_operation
+ node.append(validated_element(config, ("config", qualify("config"))))
+ return self._request(node)
+
+
+class DeleteConfig(RPC):
+ "`delete-config` RPC"
+
+ def request(self, target):
+ """Delete a configuration datastore.
+
+ *target* specifies the name or URL of configuration datastore to delete
+
+ :seealso: :ref:`srctarget_params`"""
+ node = new_ele("delete-config")
+ node.append(util.datastore_or_url("target", target, self._assert))
+ return self._request(node)
+
+
+class CopyConfig(RPC):
+ "`copy-config` RPC"
+
+ def request(self, source, target):
+ """Create or replace an entire configuration datastore with the contents of another complete
+ configuration datastore.
+
+ *source* is the name of the configuration datastore to use as the source of the copy operation or `config` element containing the configuration subtree to copy
+
+ *target* is the name of the configuration datastore to use as the destination of the copy operation
+
+ :seealso: :ref:`srctarget_params`"""
+ node = new_ele("copy-config")
+ node.append(util.datastore_or_url("target", target, self._assert))
+ node.append(util.datastore_or_url("source", source, self._assert))
+ return self._request(node)
+
+
+class Validate(RPC):
+ "`validate` RPC. Depends on the `:validate` capability."
+
+ DEPENDS = [':validate']
+
+ def request(self, source):
+ """Validate the contents of the specified configuration.
+
+ *source* is the name of the configuration datastore being validated or `config` element containing the configuration subtree to be validated
+
+ :seealso: :ref:`srctarget_params`"""
+ node = new_ele("validate")
+ try:
+ src = validated_element(source, ("config", qualify("config")))
+ except Exception as e:
+ logger.debug(e)
+ src = util.datastore_or_url("source", source, self._assert)
+ (node if src.tag == "source" else sub_ele(node, "source")).append(src)
+ return self._request(node)
+
+
+class Commit(RPC):
+ "`commit` RPC. Depends on the `:candidate` capability, and the `:confirmed-commit`."
+
+ DEPENDS = [':candidate']
+
+ def request(self, confirmed=False, timeout=None):
+ """Commit the candidate configuration as the device's new current configuration. Depends on the `:candidate` capability.
+
+ A confirmed commit (i.e. if *confirmed* is `True`) is reverted if there is no followup commit within the *timeout* interval. If no timeout is specified the confirm timeout defaults to 600 seconds (10 minutes). A confirming commit may have the *confirmed* parameter but this is not required. Depends on the `:confirmed-commit` capability.
+
+ *confirmed* whether this is a confirmed commit
+
+ *timeout* specifies the confirm timeout in seconds"""
+ node = new_ele("commit")
+ if confirmed:
+ self._assert(":confirmed-commit")
+ sub_ele(node, "confirmed")
+ if timeout is not None:
+ sub_ele(node, "confirm-timeout").text = timeout
+ return self._request(node)
+
+
+class DiscardChanges(RPC):
+ "`discard-changes` RPC. Depends on the `:candidate` capability."
+
+ DEPENDS = [":candidate"]
+
+ def request(self):
+ """Revert the candidate configuration to the currently running configuration. Any uncommitted changes are discarded."""
+ return self._request(new_ele("discard-changes")) \ No newline at end of file
diff --git a/ryu/contrib/ncclient/operations/errors.py b/ryu/contrib/ncclient/operations/errors.py
new file mode 100644
index 00000000..623abeda
--- /dev/null
+++ b/ryu/contrib/ncclient/operations/errors.py
@@ -0,0 +1,24 @@
+# Copyright 2009 Shikhar Bhushan
+#
+# 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 ncclient import NCClientError
+
+class OperationError(NCClientError):
+ pass
+
+class TimeoutExpiredError(NCClientError):
+ pass
+
+class MissingCapabilityError(NCClientError):
+ pass
diff --git a/ryu/contrib/ncclient/operations/flowmon.py b/ryu/contrib/ncclient/operations/flowmon.py
new file mode 100644
index 00000000..76759865
--- /dev/null
+++ b/ryu/contrib/ncclient/operations/flowmon.py
@@ -0,0 +1,39 @@
+# Copyright 2h009 Shikhar Bhushan
+#
+# 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.
+
+'Power-control operations'
+
+from ncclient.xml_ import *
+
+from rpc import RPC
+
+PC_URN = "urn:liberouter:params:xml:ns:netconf:power-control:1.0"
+
+class PoweroffMachine(RPC):
+
+ "*poweroff-machine* RPC (flowmon)"
+
+ DEPENDS = ["urn:liberouter:param:netconf:capability:power-control:1.0"]
+
+ def request(self):
+ return self._request(new_ele(qualify("poweroff-machine", PC_URN)))
+
+class RebootMachine(RPC):
+
+ "*reboot-machine* RPC (flowmon)"
+
+ DEPENDS = ["urn:liberouter:params:netconf:capability:power-control:1.0"]
+
+ def request(self):
+ return self._request(new_ele(qualify("reboot-machine", PC_URN)))
diff --git a/ryu/contrib/ncclient/operations/lock.py b/ryu/contrib/ncclient/operations/lock.py
new file mode 100644
index 00000000..13f5bdb0
--- /dev/null
+++ b/ryu/contrib/ncclient/operations/lock.py
@@ -0,0 +1,70 @@
+# Copyright 2h009 Shikhar Bhushan
+#
+# 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.
+
+"Locking-related NETCONF operations"
+
+from ncclient.xml_ import *
+
+from rpc import RaiseMode, RPC
+
+# TODO: parse session-id from a lock-denied error, and raise a tailored exception?
+
+class Lock(RPC):
+
+ "`lock` RPC"
+
+ def request(self, target):
+ """Allows the client to lock the configuration system of a device.
+
+ *target* is the name of the configuration datastore to lock
+ """
+ node = new_ele("lock")
+ sub_ele(sub_ele(node, "target"), target)
+ return self._request(node)
+
+
+class Unlock(RPC):
+
+ "`unlock` RPC"
+
+ def request(self, target):
+ """Release a configuration lock, previously obtained with the lock operation.
+
+ *target* is the name of the configuration datastore to unlock
+ """
+ node = new_ele("unlock")
+ sub_ele(sub_ele(node, "target"), target)
+ return self._request(node)
+
+
+class LockContext:
+
+ """A context manager for the :class:`Lock` / :class:`Unlock` pair of RPC's.
+
+ Any `rpc-error` will be raised as an exception.
+
+ Initialise with (:class:`Session <ncclient.transport.Session>`) instance and lock target.
+ """
+
+ def __init__(self, session, target):
+ self.session = session
+ self.target = target
+
+ def __enter__(self):
+ Lock(self.session, raise_mode=RaiseMode.ERRORS).request(self.target)
+ return self
+
+ def __exit__(self, *args):
+ Unlock(self.session, raise_mode=RaiseMode.ERRORS).request(self.target)
+ return False
diff --git a/ryu/contrib/ncclient/operations/retrieve.py b/ryu/contrib/ncclient/operations/retrieve.py
new file mode 100644
index 00000000..e7fe8dce
--- /dev/null
+++ b/ryu/contrib/ncclient/operations/retrieve.py
@@ -0,0 +1,127 @@
+# Copyright 2009 Shikhar Bhushan
+#
+# 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 rpc import RPC, RPCReply
+
+from ncclient.xml_ import *
+
+import util
+
+class GetReply(RPCReply):
+
+ """Adds attributes for the *data* element to `RPCReply`."""
+
+ def _parsing_hook(self, root):
+ self._data = None
+ if not self._errors:
+ self._data = root.find(qualify("data"))
+
+ @property
+ def data_ele(self):
+ "*data* element as an :class:`~xml.etree.ElementTree.Element`"
+ if not self._parsed:
+ self.parse()
+ return self._data
+
+ @property
+ def data_xml(self):
+ "*data* element as an XML string"
+ if not self._parsed:
+ self.parse()
+ return to_xml(self._data)
+
+ data = data_ele
+ "Same as :attr:`data_ele`"
+
+
+class Get(RPC):
+
+ "The *get* RPC."
+
+ REPLY_CLS = GetReply
+ "See :class:`GetReply`."
+
+ def request(self, filter=None):
+ """Retrieve running configuration and device state information.
+
+ *filter* specifies the portion of the configuration to retrieve (by default entire configuration is retrieved)
+
+ :seealso: :ref:`filter_params`
+ """
+ node = new_ele("get")
+ if filter is not None:
+ node.append(util.build_filter(filter))
+ return self._request(node)
+
+
+class GetConfig(RPC):
+
+ "The *get-config* RPC."
+
+ REPLY_CLS = GetReply
+ "See :class:`GetReply`."
+
+ def request(self, source, filter=None):
+ """Retrieve all or part of a specified configuration.
+
+ *source* name of the configuration datastore being queried
+
+ *filter* specifies the portion of the configuration to retrieve (by default entire configuration is retrieved)
+
+ :seealso: :ref:`filter_params`"""
+ node = new_ele("get-config")
+ node.append(util.datastore_or_url("source", source, self._assert))
+ if filter is not None:
+ node.append(util.build_filter(filter))
+ return self._request(node)
+
+class Dispatch(RPC):
+
+ "Generic retrieving wrapper"
+
+ REPLY_CLS = GetReply
+ "See :class:`GetReply`."
+
+ def request(self, rpc_command, source=None, filter=None):
+ """
+ *rpc_command* specifies rpc command to be dispatched either in plain text or in xml element format (depending on command)
+
+ *source* name of the configuration datastore being queried
+
+ *filter* specifies the portion of the configuration to retrieve (by default entire configuration is retrieved)
+
+ :seealso: :ref:`filter_params`
+
+ Examples of usage::
+
+ dispatch('clear-arp-table')
+
+ or dispatch element like ::
+
+ xsd_fetch = new_ele('get-xnm-information')
+ sub_ele(xsd_fetch, 'type').text="xml-schema"
+ sub_ele(xsd_fetch, 'namespace').text="junos-configuration"
+ dispatch(xsd_fetch)
+ """
+
+ if ET.iselement(rpc_command):
+ node = rpc_command
+ else:
+ node = new_ele(rpc_command)
+ if source is not None:
+ node.append(util.datastore_or_url("source", source, self._assert))
+ if filter is not None:
+ node.append(util.build_filter(filter))
+ return self._request(node)
+
diff --git a/ryu/contrib/ncclient/operations/rpc.py b/ryu/contrib/ncclient/operations/rpc.py
new file mode 100644
index 00000000..a476e184
--- /dev/null
+++ b/ryu/contrib/ncclient/operations/rpc.py
@@ -0,0 +1,373 @@
+# Copyright 2009 Shikhar Bhushan
+#
+# 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 threading import Event, Lock
+from uuid import uuid1
+
+from ncclient.xml_ import *
+from ncclient.transport import SessionListener
+
+from errors import OperationError, TimeoutExpiredError, MissingCapabilityError
+
+import logging
+logger = logging.getLogger("ncclient.operations.rpc")
+
+
+class RPCError(OperationError):
+
+ "Represents an `rpc-error`. It is a type of :exc:`OperationError` and can be raised as such."
+
+ tag_to_attr = {
+ qualify("error-type"): "_type",
+ qualify("error-tag"): "_tag",
+ qualify("error-severity"): "_severity",
+ qualify("error-info"): "_info",
+ qualify("error-path"): "_path",
+ qualify("error-message"): "_message"
+ }
+
+ def __init__(self, raw):
+ self._raw = raw
+ for attr in RPCError.tag_to_attr.values():
+ setattr(self, attr, None)
+ for subele in raw:
+ attr = RPCError.tag_to_attr.get(subele.tag, None)
+ if attr is not None:
+ setattr(self, attr, subele.text if attr != "_info" else to_xml(subele) )
+ if self.message is not None:
+ OperationError.__init__(self, self.message)
+ else:
+ OperationError.__init__(self, self.to_dict())
+
+ def to_dict(self):
+ return dict([ (attr[1:], getattr(self, attr)) for attr in RPCError.tag_to_attr.values() ])
+
+ @property
+ def xml(self):
+ "The `rpc-error` element as returned in XML."
+ return self._raw
+
+ @property
+ def type(self):
+ "The contents of the `error-type` element."
+ return self._type
+
+ @property
+ def tag(self):
+ "The contents of the `error-tag` element."
+ return self._tag
+
+ @property
+ def severity(self):
+ "The contents of the `error-severity` element."
+ return self._severity
+
+ @property
+ def path(self):
+ "The contents of the `error-path` element if present or `None`."
+ return self._path
+
+ @property
+ def message(self):
+ "The contents of the `error-message` element if present or `None`."
+ return self._message
+
+ @property
+ def info(self):
+ "XML string or `None`; representing the `error-info` element."
+ return self._info
+
+
+class RPCReply:
+
+ """Represents an *rpc-reply*. Only concerns itself with whether the operation was successful.
+
+ .. note::
+ If the reply has not yet been parsed there is an implicit, one-time parsing overhead to
+ accessing some of the attributes defined by this class.
+ """
+
+ ERROR_CLS = RPCError
+ "Subclasses can specify a different error class, but it should be a subclass of `RPCError`."
+
+ def __init__(self, raw):
+ self._raw = raw
+ self._parsed = False
+ self._root = None
+ self._errors = []
+
+ def __repr__(self):
+ return self._raw
+
+ def parse(self):
+ "Parses the *rpc-reply*."
+ if self._parsed: return
+ root = self._root = to_ele(self._raw) # The <rpc-reply> element
+ # Per RFC 4741 an <ok/> tag is sent when there are no errors or warnings
+ ok = root.find(qualify("ok"))
+ if ok is None:
+ # Create RPCError objects from <rpc-error> elements
+ error = root.find(qualify("rpc-error"))
+ if error is not None:
+ for err in root.getiterator(error.tag):
+ # Process a particular <rpc-error>
+ self._errors.append(self.ERROR_CLS(err))
+ self._parsing_hook(root)
+ self._parsed = True
+
+ def _parsing_hook(self, root):
+ "No-op by default. Gets passed the *root* element for the reply."
+ pass
+
+ @property
+ def xml(self):
+ "*rpc-reply* element as returned."
+ return self._raw
+
+ @property
+ def ok(self):
+ "Boolean value indicating if there were no errors."
+ return not self.errors # empty list => false
+
+ @property
+ def error(self):
+ "Returns the first :class:`RPCError` and `None` if there were no errors."
+ self.parse()
+ if self._errors:
+ return self._errors[0]
+ else:
+ return None
+
+ @property
+ def errors(self):
+ "List of `RPCError` objects. Will be empty if there were no *rpc-error* elements in reply."
+ self.parse()
+ return self._errors
+
+
+class RPCReplyListener(SessionListener): # internal use
+
+ creation_lock = Lock()
+
+ # one instance per session -- maybe there is a better way??
+ def __new__(cls, session):
+ with RPCReplyListener.creation_lock:
+ instance = session.get_listener_instance(cls)
+ if instance is None:
+ instance = object.__new__(cls)
+ instance._lock = Lock()
+ instance._id2rpc = {}
+ #instance._pipelined = session.can_pipeline
+ session.add_listener(instance)
+ return instance
+
+ def register(self, id, rpc):
+ with self._lock:
+ self._id2rpc[id] = rpc
+
+ def callback(self, root, raw):
+ tag, attrs = root
+ if tag != qualify("rpc-reply"):
+ return
+ for key in attrs: # in the <rpc-reply> attributes
+ if key == "message-id": # if we found msgid attr
+ id = attrs[key] # get the msgid
+ with self._lock:
+ try:
+ rpc = self._id2rpc[id] # the corresponding rpc
+ logger.debug("Delivering to %r" % rpc)
+ rpc.deliver_reply(raw)
+ except KeyError:
+ raise OperationError("Unknown 'message-id': %s", id)
+ # no catching other exceptions, fail loudly if must
+ else:
+ # if no error delivering, can del the reference to the RPC
+ del self._id2rpc[id]
+ break
+ else:
+ raise OperationError("Could not find 'message-id' attribute in <rpc-reply>")
+
+ def errback(self, err):
+ try:
+ for rpc in self._id2rpc.values():
+ rpc.deliver_error(err)
+ finally:
+ self._id2rpc.clear()
+
+
+class RaiseMode(object):
+
+ NONE = 0
+ "Don't attempt to raise any type of `rpc-error` as :exc:`RPCError`."
+
+ ERRORS = 1
+ "Raise only when the `error-type` indicates it is an honest-to-god error."
+
+ ALL = 2
+ "Don't look at the `error-type`, always raise."
+
+
+class RPC(object):
+
+ """Base class for all operations, directly corresponding to *rpc* requests. Handles making the request, and taking delivery of the reply."""
+
+ DEPENDS = []
+ """Subclasses can specify their dependencies on capabilities as a list of URI's or abbreviated names, e.g. ':writable-running'. These are verified at the time of instantiation. If the capability is not available, :exc:`MissingCapabilityError` is raised."""
+
+ REPLY_CLS = RPCReply
+ "By default :class:`RPCReply`. Subclasses can specify a :class:`RPCReply` subclass."
+
+ def __init__(self, session, async=False, timeout=30, raise_mode=RaiseMode.NONE):
+ """
+ *session* is the :class:`~ncclient.transport.Session` instance
+
+ *async* specifies whether the request is to be made asynchronously, see :attr:`is_async`
+
+ *timeout* is the timeout for a synchronous request, see :attr:`timeout`
+
+ *raise_mode* specifies the exception raising mode, see :attr:`raise_mode`
+ """
+ self._session = session
+ try:
+ for cap in self.DEPENDS:
+ self._assert(cap)
+ except AttributeError:
+ pass
+ self._async = async
+ self._timeout = timeout
+ self._raise_mode = raise_mode
+ self._id = uuid1().urn # Keeps things simple instead of having a class attr with running ID that has to be locked
+ self._listener = RPCReplyListener(session)
+ self._listener.register(self._id, self)
+ self._reply = None
+ self._error = None
+ self._event = Event()
+
+ def _wrap(self, subele):
+ # internal use
+ ele = new_ele("rpc", {"message-id": self._id})
+ ele.append(subele)
+ return to_xml(ele)
+
+ def _request(self, op):
+ """Implementations of :meth:`request` call this method to send the request and process the reply.
+
+ In synchronous mode, blocks until the reply is received and returns :class:`RPCReply`. Depending on the :attr:`raise_mode` a `rpc-error` element in the reply may lead to an :exc:`RPCError` exception.
+
+ In asynchronous mode, returns immediately, returning `self`. The :attr:`event` attribute will be set when the reply has been received (see :attr:`reply`) or an error occured (see :attr:`error`).
+
+ *op* is the operation to be requested as an :class:`~xml.etree.ElementTree.Element`
+ """
+ logger.info('Requesting %r' % self.__class__.__name__)
+ req = self._wrap(op)
+ self._session.send(req)
+ if self._async:
+ logger.debug('Async request, returning %r', self)
+ return self
+ else:
+ logger.debug('Sync request, will wait for timeout=%r' % self._timeout)
+ self._event.wait(self._timeout)
+ if self._event.isSet():
+ if self._error:
+ # Error that prevented reply delivery
+ raise self._error
+ self._reply.parse()
+ if self._reply.error is not None:
+ # <rpc-error>'s [ RPCError ]
+ if self._raise_mode == RaiseMode.ALL:
+ raise self._reply.error
+ elif (self._raise_mode == RaiseMode.ERRORS and self._reply.error.type == "error"):
+ raise self._reply.error
+ return self._reply
+ else:
+ raise TimeoutExpiredError
+
+ def request(self):
+ """Subclasses must implement this method. Typically only the request needs to be built as an
+ :class:`~xml.etree.ElementTree.Element` and everything else can be handed off to
+ :meth:`_request`."""
+ pass
+
+ def _assert(self, capability):
+ """Subclasses can use this method to verify that a capability is available with the NETCONF
+ server, before making a request that requires it. A :exc:`MissingCapabilityError` will be
+ raised if the capability is not available."""
+ if capability not in self._session.server_capabilities:
+ raise MissingCapabilityError('Server does not support [%s]' % capability)
+
+ def deliver_reply(self, raw):
+ # internal use
+ self._reply = self.REPLY_CLS(raw)
+ self._event.set()
+
+ def deliver_error(self, err):
+ # internal use
+ self._error = err
+ self._event.set()
+
+ @property
+ def reply(self):
+ ":class:`RPCReply` element if reply has been received or `None`"
+ return self._reply
+
+ @property
+ def error(self):
+ """:exc:`Exception` type if an error occured or `None`.
+
+ .. note::
+ This represents an error which prevented a reply from being received. An *rpc-error*
+ does not fall in that category -- see `RPCReply` for that.
+ """
+ return self._error
+
+ @property
+ def id(self):
+ "The *message-id* for this RPC."
+ return self._id
+
+ @property
+ def session(self):
+ "The `~ncclient.transport.Session` object associated with this RPC."
+ return self._session
+
+ @property
+ def event(self):
+ """:class:`~threading.Event` that is set when reply has been received or when an error preventing
+ delivery of the reply occurs.
+ """
+ return self._event
+
+ def __set_async(self, async=True):
+ self._async = async
+ if async and not session.can_pipeline:
+ raise UserWarning('Asynchronous mode not supported for this device/session')
+
+ def __set_raise_mode(self, mode):
+ assert(choice in ("all", "errors", "none"))
+ self._raise_mode = mode
+
+ def __set_timeout(self, timeout):
+ self._timeout = timeout
+
+ raise_mode = property(fget=lambda self: self._raise_mode, fset=__set_raise_mode)
+ """Depending on this exception raising mode, an `rpc-error` in the reply may be raised as an :exc:`RPCError` exception. Valid values are the constants defined in :class:`RaiseMode`. """
+
+ is_async = property(fget=lambda self: self._async, fset=__set_async)
+ """Specifies whether this RPC will be / was requested asynchronously. By default RPC's are synchronous."""
+
+ timeout = property(fget=lambda self: self._timeout, fset=__set_timeout)
+ """Timeout in seconds for synchronous waiting defining how long the RPC request will block on a reply before raising :exc:`TimeoutExpiredError`.
+
+ Irrelevant for asynchronous usage.
+ """
diff --git a/ryu/contrib/ncclient/operations/session.py b/ryu/contrib/ncclient/operations/session.py
new file mode 100644
index 00000000..0afa3072
--- /dev/null
+++ b/ryu/contrib/ncclient/operations/session.py
@@ -0,0 +1,44 @@
+# Copyright 2009 Shikhar Bhushan
+#
+# 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.
+
+"Session-related NETCONF operations"
+
+from ncclient.xml_ import *
+
+from rpc import RPC
+
+class CloseSession(RPC):
+
+ "`close-session` RPC. The connection to NETCONF server is also closed."
+
+ def request(self):
+ "Request graceful termination of the NETCONF session, and also close the transport."
+ try:
+ return self._request(new_ele("close-session"))
+ finally:
+ self.session.close()
+
+
+class KillSession(RPC):
+
+ "`kill-session` RPC."
+
+ def request(self, session_id):
+ """Force the termination of a NETCONF session (not the current one!)
+
+ *session_id* is the session identifier of the NETCONF session to be terminated as a string
+ """
+ node = new_ele("kill-session")
+ sub_ele(node, "session-id").text = session_id
+ return self._request(node)
diff --git a/ryu/contrib/ncclient/operations/subscribe.py b/ryu/contrib/ncclient/operations/subscribe.py
new file mode 100644
index 00000000..44635d34
--- /dev/null
+++ b/ryu/contrib/ncclient/operations/subscribe.py
@@ -0,0 +1,24 @@
+# Copyright 2009 Shikhar Bhushan
+#
+# 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.
+
+# TODO
+
+class Notification:
+ pass
+
+class CreateSubscription:
+ pass
+
+class NotificationListener:
+ pass
diff --git a/ryu/contrib/ncclient/operations/util.py b/ryu/contrib/ncclient/operations/util.py
new file mode 100644
index 00000000..e11ae598
--- /dev/null
+++ b/ryu/contrib/ncclient/operations/util.py
@@ -0,0 +1,65 @@
+# Copyright 2009 Shikhar Bhushan
+#
+# 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.
+
+'Boilerplate ugliness'
+
+from ncclient.xml_ import *
+
+from errors import OperationError, MissingCapabilityError
+
+def one_of(*args):
+ "Verifies that only one of the arguments is not None"
+ for i, arg in enumerate(args):
+ if arg is not None:
+ for argh in args[i+1:]:
+ if argh is not None:
+ raise OperationError("Too many parameters")
+ else:
+ return
+ raise OperationError("Insufficient parameters")
+
+def datastore_or_url(wha, loc, capcheck=None):
+ node = new_ele(wha)
+ if "://" in loc: # e.g. http://, file://, ftp://
+ if capcheck is not None:
+ capcheck(":url") # url schema check at some point!
+ sub_ele(node, "url").text = loc
+ else:
+ #if loc == 'candidate':
+ # capcheck(':candidate')
+ #elif loc == 'startup':
+ # capcheck(':startup')
+ #elif loc == 'running' and wha == 'target':
+ # capcheck(':writable-running')
+ sub_ele(node, loc)
+ return node
+
+def build_filter(spec, capcheck=None):
+ type = None
+ if isinstance(spec, tuple):
+ type, criteria = spec
+ rep = new_ele("filter", type=type)
+ if type == "xpath":
+ rep.attrib["select"] = criteria
+ elif type == "subtree":
+ rep.append(to_ele(criteria))
+ else:
+ raise OperationError("Invalid filter type")
+ else:
+ rep = validated_element(spec, ("filter", qualify("filter")),
+ attrs=("type",))
+ # TODO set type var here, check if select attr present in case of xpath..
+ if type == "xpath" and capcheck is not None:
+ capcheck(":xpath")
+ return rep
diff --git a/ryu/contrib/ncclient/transport/__init__.py b/ryu/contrib/ncclient/transport/__init__.py
new file mode 100644
index 00000000..51c4a155
--- /dev/null
+++ b/ryu/contrib/ncclient/transport/__init__.py
@@ -0,0 +1,30 @@
+# Copyright 2009 Shikhar Bhushan
+#
+# 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.
+
+"Transport layer"
+
+from session import Session, SessionListener
+from ssh import SSHSession
+from errors import *
+
+__all__ = [
+ 'Session',
+ 'SessionListener',
+ 'SSHSession',
+ 'TransportError',
+ 'AuthenticationError',
+ 'SessionCloseError',
+ 'SSHError',
+ 'SSHUnknownHostError'
+] \ No newline at end of file
diff --git a/ryu/contrib/ncclient/transport/errors.py b/ryu/contrib/ncclient/transport/errors.py
new file mode 100644
index 00000000..ec95c682
--- /dev/null
+++ b/ryu/contrib/ncclient/transport/errors.py
@@ -0,0 +1,41 @@
+# Copyright 2009 Shikhar Bhushan
+#
+# 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 ncclient import NCClientError
+
+class TransportError(NCClientError):
+ pass
+
+class AuthenticationError(TransportError):
+ pass
+
+class SessionCloseError(TransportError):
+
+ def __init__(self, in_buf, out_buf=None):
+ msg = 'Unexpected session close'
+ if in_buf:
+ msg += '\nIN_BUFFER: `%s`' % in_buf
+ if out_buf:
+ msg += ' OUT_BUFFER: `%s`' % out_buf
+ SSHError.__init__(self, msg)
+
+class SSHError(TransportError):
+ pass
+
+class SSHUnknownHostError(SSHError):
+
+ def __init__(self, host, fingerprint):
+ SSHError.__init__(self, 'Unknown host key [%s] for [%s]' % (fingerprint, host))
+ self.host = host
+ self.fingerprint = fingerprint
diff --git a/ryu/contrib/ncclient/transport/session.py b/ryu/contrib/ncclient/transport/session.py
new file mode 100644
index 00000000..46f7a4c6
--- /dev/null
+++ b/ryu/contrib/ncclient/transport/session.py
@@ -0,0 +1,229 @@
+# Copyright 2009 Shikhar Bhushan
+#
+# 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 Queue import Queue
+from threading import Thread, Lock, Event
+
+from ncclient.xml_ import *
+from ncclient.capabilities import Capabilities
+
+from errors import TransportError
+
+import logging
+logger = logging.getLogger('ncclient.transport.session')
+
+class Session(Thread):
+
+ "Base class for use by transport protocol implementations."
+
+ def __init__(self, capabilities):
+ Thread.__init__(self)
+ self.setDaemon(True)
+ self._listeners = set()
+ self._lock = Lock()
+ self.setName('session')
+ self._q = Queue()
+ self._client_capabilities = capabilities
+ self._server_capabilities = None # yet
+ self._id = None # session-id
+ self._connected = False # to be set/cleared by subclass implementation
+ logger.debug('%r created: client_capabilities=%r' %
+ (self, self._client_capabilities))
+
+ def _dispatch_message(self, raw):
+ try:
+ root = parse_root(raw)
+ except Exception as e:
+ logger.error('error parsing dispatch message: %s' % e)
+ return
+ with self._lock:
+ listeners = list(self._listeners)
+ for l in listeners:
+ logger.debug('dispatching message to %r: %s' % (l, raw))
+ l.callback(root, raw) # no try-except; fail loudly if you must!
+
+ def _dispatch_error(self, err):
+ with self._lock:
+ listeners = list(self._listeners)
+ for l in listeners:
+ logger.debug('dispatching error to %r' % l)
+ try: # here we can be more considerate with catching exceptions
+ l.errback(err)
+ except Exception as e:
+ logger.warning('error dispatching to %r: %r' % (l, e))
+
+ def _post_connect(self):
+ "Greeting stuff"
+ init_event = Event()
+ error = [None] # so that err_cb can bind error[0]. just how it is.
+ # callbacks
+ def ok_cb(id, capabilities):
+ self._id = id
+ self._server_capabilities = capabilities
+ init_event.set()
+ def err_cb(err):
+ error[0] = err
+ init_event.set()
+ listener = HelloHandler(ok_cb, err_cb)
+ self.add_listener(listener)
+ self.send(HelloHandler.build(self._client_capabilities))
+ logger.debug('starting main loop')
+ self.start()
+ # we expect server's hello message
+ init_event.wait()
+ # received hello message or an error happened
+ self.remove_listener(listener)
+ if error[0]:
+ raise error[0]
+ #if ':base:1.0' not in self.server_capabilities:
+ # raise MissingCapabilityError(':base:1.0')
+ logger.info('initialized: session-id=%s | server_capabilities=%s' %
+ (self._id, self._server_capabilities))
+
+ def add_listener(self, listener):
+ """Register a listener that will be notified of incoming messages and
+ errors.
+
+ :type listener: :class:`SessionListener`
+ """
+ logger.debug('installing listener %r' % listener)
+ if not isinstance(listener, SessionListener):
+ raise SessionError("Listener must be a SessionListener type")
+ with self._lock:
+ self._listeners.add(listener)
+
+ def remove_listener(self, listener):
+ """Unregister some listener; ignore if the listener was never
+ registered.
+
+ :type listener: :class:`SessionListener`
+ """
+ logger.debug('discarding listener %r' % listener)
+ with self._lock:
+ self._listeners.discard(listener)
+
+ def get_listener_instance(self, cls):
+ """If a listener of the specified type is registered, returns the
+ instance.
+
+ :type cls: :class:`SessionListener`
+ """
+ with self._lock:
+ for listener in self._listeners:
+ if isinstance(listener, cls):
+ return listener
+
+ def connect(self, *args, **kwds): # subclass implements
+ raise NotImplementedError
+
+ def run(self): # subclass implements
+ raise NotImplementedError
+
+ def send(self, message):
+ """Send the supplied *message* (xml string) to NETCONF server."""
+ if not self.connected:
+ raise TransportError('Not connected to NETCONF server')
+ logger.debug('queueing %s' % message)
+ self._q.put(message)
+
+ ### Properties
+
+ @property
+ def connected(self):
+ "Connection status of the session."
+ return self._connected
+
+ @property
+ def client_capabilities(self):
+ "Client's :class:`Capabilities`"
+ return self._client_capabilities
+
+ @property
+ def server_capabilities(self):
+ "Server's :class:`Capabilities`"
+ return self._server_capabilities
+
+ @property
+ def id(self):
+ """A string representing the `session-id`. If the session has not been initialized it will be `None`"""
+ return self._id
+
+
+class SessionListener(object):
+
+ """Base class for :class:`Session` listeners, which are notified when a new
+ NETCONF message is received or an error occurs.
+
+ .. note::
+ Avoid time-intensive tasks in a callback's context.
+ """
+
+ def callback(self, root, raw):
+ """Called when a new XML document is received. The *root* argument allows the callback to determine whether it wants to further process the document.
+
+ Here, *root* is a tuple of *(tag, attributes)* where *tag* is the qualified name of the root element and *attributes* is a dictionary of its attributes (also qualified names).
+
+ *raw* will contain the XML document as a string.
+ """
+ raise NotImplementedError
+
+ def errback(self, ex):
+ """Called when an error occurs.
+
+ :type ex: :exc:`Exception`
+ """
+ raise NotImplementedError
+
+
+class HelloHandler(SessionListener):
+
+ def __init__(self, init_cb, error_cb):
+ self._init_cb = init_cb
+ self._error_cb = error_cb
+
+ def callback(self, root, raw):
+ tag, attrs = root
+ if (tag == qualify("hello")) or (tag == "hello"):
+ try:
+ id, capabilities = HelloHandler.parse(raw)
+ except Exception as e:
+ self._error_cb(e)
+ else:
+ self._init_cb(id, capabilities)
+
+ def errback(self, err):
+ self._error_cb(err)
+
+ @staticmethod
+ def build(capabilities):
+ "Given a list of capability URI's returns <hello> message XML string"
+ hello = new_ele("hello")
+ caps = sub_ele(hello, "capabilities")
+ def fun(uri): sub_ele(caps, "capability").text = uri
+ map(fun, capabilities)
+ return to_xml(hello)
+
+ @staticmethod
+ def parse(raw):
+ "Returns tuple of (session-id (str), capabilities (Capabilities)"
+ sid, capabilities = 0, []
+ root = to_ele(raw)
+ for child in root.getchildren():
+ if child.tag == qualify("session-id") or child.tag == "session-id":
+ sid = child.text
+ elif child.tag == qualify("capabilities") or child.tag == "capabilities" :
+ for cap in child.getchildren():
+ if cap.tag == qualify("capability") or cap.tag == "capability":
+ capabilities.append(cap.text)
+ return sid, Capabilities(capabilities)
diff --git a/ryu/contrib/ncclient/transport/ssh.py b/ryu/contrib/ncclient/transport/ssh.py
new file mode 100644
index 00000000..14082da9
--- /dev/null
+++ b/ryu/contrib/ncclient/transport/ssh.py
@@ -0,0 +1,312 @@
+# Copyright 2009 Shikhar Bhushan
+#
+# 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
+import socket
+import getpass
+from binascii import hexlify
+from cStringIO import StringIO
+from select import select
+
+import paramiko
+
+from errors import AuthenticationError, SessionCloseError, SSHError, SSHUnknownHostError
+from session import Session
+
+import logging
+logger = logging.getLogger("ncclient.transport.ssh")
+
+BUF_SIZE = 4096
+MSG_DELIM = "]]>]]>"
+TICK = 0.1
+
+def default_unknown_host_cb(host, fingerprint):
+ """An unknown host callback returns `True` if it finds the key acceptable, and `False` if not.
+
+ This default callback always returns `False`, which would lead to :meth:`connect` raising a :exc:`SSHUnknownHost` exception.
+
+ Supply another valid callback if you need to verify the host key programatically.
+
+ *host* is the hostname that needs to be verified
+
+ *fingerprint* is a hex string representing the host key fingerprint, colon-delimited e.g. `"4b:69:6c:72:6f:79:20:77:61:73:20:68:65:72:65:21"`
+ """
+ return False
+
+def _colonify(fp):
+ finga = fp[:2]
+ for idx in range(2, len(fp), 2):
+ finga += ":" + fp[idx:idx+2]
+ return finga
+
+class SSHSession(Session):
+
+ "Implements a :rfc:`4742` NETCONF session over SSH."
+
+ def __init__(self, capabilities):
+ Session.__init__(self, capabilities)
+ self._host_keys = paramiko.HostKeys()
+ self._transport = None
+ self._connected = False
+ self._channel = None
+ self._buffer = StringIO() # for incoming data
+ # parsing-related, see _parse()
+ self._parsing_state = 0
+ self._parsing_pos = 0
+
+ def _parse(self):
+ "Messages ae delimited by MSG_DELIM. The buffer could have grown by a maximum of BUF_SIZE bytes everytime this method is called. Retains state across method calls and if a byte has been read it will not be considered again."
+ delim = MSG_DELIM
+ n = len(delim) - 1
+ expect = self._parsing_state
+ buf = self._buffer
+ buf.seek(self._parsing_pos)
+ while True:
+ x = buf.read(1)
+ if not x: # done reading
+ break
+ elif x == delim[expect]: # what we expected
+ expect += 1 # expect the next delim char
+ else:
+ expect = 0
+ continue
+ # loop till last delim char expected, break if other char encountered
+ for i in range(expect, n):
+ x = buf.read(1)
+ if not x: # done reading
+ break
+ if x == delim[expect]: # what we expected
+ expect += 1 # expect the next delim char
+ else:
+ expect = 0 # reset
+ break
+ else: # if we didn't break out of the loop, full delim was parsed
+ msg_till = buf.tell() - n
+ buf.seek(0)
+ logger.debug('parsed new message')
+ self._dispatch_message(buf.read(msg_till).strip())
+ buf.seek(n+1, os.SEEK_CUR)
+ rest = buf.read()
+ buf = StringIO()
+ buf.write(rest)
+ buf.seek(0)
+ expect = 0
+ self._buffer = buf
+ self._parsing_state = expect
+ self._parsing_pos = self._buffer.tell()
+
+ def load_known_hosts(self, filename=None):
+ """Load host keys from an openssh :file:`known_hosts`-style file. Can be called multiple times.
+
+ If *filename* is not specified, looks in the default locations i.e. :file:`~/.ssh/known_hosts` and :file:`~/ssh/known_hosts` for Windows.
+ """
+ if filename is None:
+ filename = os.path.expanduser('~/.ssh/known_hosts')
+ try:
+ self._host_keys.load(filename)
+ except IOError:
+ # for windows
+ filename = os.path.expanduser('~/ssh/known_hosts')
+ try:
+ self._host_keys.load(filename)
+ except IOError:
+ pass
+ else:
+ self._host_keys.load(filename)
+
+ def close(self):
+ if self._transport.is_active():
+ self._transport.close()
+ self._connected = False
+
+ # REMEMBER to update transport.rst if sig. changes, since it is hardcoded there
+ def connect(self, host, port=830, timeout=None, unknown_host_cb=default_unknown_host_cb,
+ username=None, password=None, key_filename=None, allow_agent=True, look_for_keys=True):
+ """Connect via SSH and initialize the NETCONF session. First attempts the publickey authentication method and then password authentication.
+
+ To disable attempting publickey authentication altogether, call with *allow_agent* and *look_for_keys* as `False`.
+
+ *host* is the hostname or IP address to connect to
+
+ *port* is by default 830, but some devices use the default SSH port of 22 so this may need to be specified
+
+ *timeout* is an optional timeout for socket connect
+
+ *unknown_host_cb* is called when the server host key is not recognized. It takes two arguments, the hostname and the fingerprint (see the signature of :func:`default_unknown_host_cb`)
+
+ *username* is the username to use for SSH authentication
+
+ *password* is the password used if using password authentication, or the passphrase to use for unlocking keys that require it
+
+ *key_filename* is a filename where a the private key to be used can be found
+
+ *allow_agent* enables querying SSH agent (if found) for keys
+
+ *look_for_keys* enables looking in the usual locations for ssh keys (e.g. :file:`~/.ssh/id_*`)
+ """
+ if username is None:
+ username = getpass.getuser()
+
+ sock = None
+ for res in socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM):
+ af, socktype, proto, canonname, sa = res
+ try:
+ sock = socket.socket(af, socktype, proto)
+ sock.settimeout(timeout)
+ except socket.error:
+ continue
+ try:
+ sock.connect(sa)
+ except socket.error:
+ sock.close()
+ continue
+ break
+ else:
+ raise SSHError("Could not open socket to %s:%s" % (host, port))
+
+ t = self._transport = paramiko.Transport(sock)
+ t.set_log_channel(logger.name)
+
+ try:
+ t.start_client()
+ except paramiko.SSHException:
+ raise SSHError('Negotiation failed')
+
+ # host key verification
+ server_key = t.get_remote_server_key()
+ known_host = self._host_keys.check(host, server_key)
+
+ fingerprint = _colonify(hexlify(server_key.get_fingerprint()))
+
+ if not known_host and not unknown_host_cb(host, fingerprint):
+ raise SSHUnknownHostError(host, fingerprint)
+
+ if key_filename is None:
+ key_filenames = []
+ elif isinstance(key_filename, basestring):
+ key_filenames = [ key_filename ]
+ else:
+ key_filenames = key_filename
+
+ self._auth(username, password, key_filenames, allow_agent, look_for_keys)
+
+ self._connected = True # there was no error authenticating
+
+ c = self._channel = self._transport.open_session()
+ c.set_name("netconf")
+ c.invoke_subsystem("netconf")
+
+ self._post_connect()
+
+ # on the lines of paramiko.SSHClient._auth()
+ def _auth(self, username, password, key_filenames, allow_agent,
+ look_for_keys):
+ saved_exception = None
+
+ for key_filename in key_filenames:
+ for cls in (paramiko.RSAKey, paramiko.DSSKey):
+ try:
+ key = cls.from_private_key_file(key_filename, password)
+ logger.debug("Trying key %s from %s" %
+ (hexlify(key.get_fingerprint()), key_filename))
+ self._transport.auth_publickey(username, key)
+ return
+ except Exception as e:
+ saved_exception = e
+ logger.debug(e)
+
+ if allow_agent:
+ for key in paramiko.Agent().get_keys():
+ try:
+ logger.debug("Trying SSH agent key %s" %
+ hexlify(key.get_fingerprint()))
+ self._transport.auth_publickey(username, key)
+ return
+ except Exception as e:
+ saved_exception = e
+ logger.debug(e)
+
+ keyfiles = []
+ if look_for_keys:
+ rsa_key = os.path.expanduser("~/.ssh/id_rsa")
+ dsa_key = os.path.expanduser("~/.ssh/id_dsa")
+ if os.path.isfile(rsa_key):
+ keyfiles.append((paramiko.RSAKey, rsa_key))
+ if os.path.isfile(dsa_key):
+ keyfiles.append((paramiko.DSSKey, dsa_key))
+ # look in ~/ssh/ for windows users:
+ rsa_key = os.path.expanduser("~/ssh/id_rsa")
+ dsa_key = os.path.expanduser("~/ssh/id_dsa")
+ if os.path.isfile(rsa_key):
+ keyfiles.append((paramiko.RSAKey, rsa_key))
+ if os.path.isfile(dsa_key):
+ keyfiles.append((paramiko.DSSKey, dsa_key))
+
+ for cls, filename in keyfiles:
+ try:
+ key = cls.from_private_key_file(filename, password)
+ logger.debug("Trying discovered key %s in %s" %
+ (hexlify(key.get_fingerprint()), filename))
+ self._transport.auth_publickey(username, key)
+ return
+ except Exception as e:
+ saved_exception = e
+ logger.debug(e)
+
+ if password is not None:
+ try:
+ self._transport.auth_password(username, password)
+ return
+ except Exception as e:
+ saved_exception = e
+ logger.debug(e)
+
+ if saved_exception is not None:
+ # need pep-3134 to do this right
+ raise AuthenticationError(repr(saved_exception))
+
+ raise AuthenticationError("No authentication methods available")
+
+ def run(self):
+ chan = self._channel
+ q = self._q
+ try:
+ while True:
+ # select on a paramiko ssh channel object does not ever return it in the writable list, so channels don't exactly emulate the socket api
+ r, w, e = select([chan], [], [], TICK)
+ # will wakeup evey TICK seconds to check if something to send, more if something to read (due to select returning chan in readable list)
+ if r:
+ data = chan.recv(BUF_SIZE)
+ if data:
+ self._buffer.write(data)
+ self._parse()
+ else:
+ raise SessionCloseError(self._buffer.getvalue())
+ if not q.empty() and chan.send_ready():
+ logger.debug("Sending message")
+ data = q.get() + MSG_DELIM
+ while data:
+ n = chan.send(data)
+ if n <= 0:
+ raise SessionCloseError(self._buffer.getvalue(), data)
+ data = data[n:]
+ except Exception as e:
+ logger.debug("Broke out of main loop, error=%r", e)
+ self.close()
+ self._dispatch_error(e)
+
+ @property
+ def transport(self):
+ "Underlying `paramiko.Transport <http://www.lag.net/paramiko/docs/paramiko.Transport-class.html>`_ object. This makes it possible to call methods like :meth:`~paramiko.Transport.set_keepalive` on it."
+ return self._transport
diff --git a/ryu/contrib/ncclient/xml_.py b/ryu/contrib/ncclient/xml_.py
new file mode 100644
index 00000000..9e94ef0a
--- /dev/null
+++ b/ryu/contrib/ncclient/xml_.py
@@ -0,0 +1,108 @@
+# Copyright 2009 Shikhar Bhushan
+# Copyright 2011 Leonidas Poulopoulos
+#
+# 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.
+
+"Methods for creating, parsing, and dealing with XML and ElementTree objects."
+
+from cStringIO import StringIO
+from xml.etree import cElementTree as ET
+
+# In case issues come up with XML generation/parsing
+# make sure you have the ElementTree v1.2.7+ lib
+
+from ncclient import NCClientError
+
+class XMLError(NCClientError): pass
+
+### Namespace-related
+
+#: Base NETCONF namespace
+BASE_NS_1_0 = "urn:ietf:params:xml:ns:netconf:base:1.0"
+#: Namespace for Tail-f core data model
+TAILF_AAA_1_1 = "http://tail-f.com/ns/aaa/1.1"
+#: Namespace for Tail-f execd data model
+TAILF_EXECD_1_1 = "http://tail-f.com/ns/execd/1.1"
+#: Namespace for Cisco data model
+CISCO_CPI_1_0 = "http://www.cisco.com/cpi_10/schema"
+#: Namespace for Flowmon data model
+FLOWMON_1_0 = "http://www.liberouter.org/ns/netopeer/flowmon/1.0"
+#: Namespace for Juniper 9.6R4. Tested with Junos 9.6R4+
+JUNIPER_1_1 = "http://xml.juniper.net/xnm/1.1/xnm"
+#
+try:
+ register_namespace = ET.register_namespace
+except AttributeError:
+ def register_namespace(prefix, uri):
+ from xml.etree import ElementTree
+ # cElementTree uses ElementTree's _namespace_map, so that's ok
+ ElementTree._namespace_map[uri] = prefix
+register_namespace.func_doc = "ElementTree's namespace map determines the prefixes for namespace URI's when serializing to XML. This method allows modifying this map to specify a prefix for a namespace URI."
+
+for (ns, pre) in {
+ BASE_NS_1_0: 'nc',
+ TAILF_AAA_1_1: 'aaa',
+ TAILF_EXECD_1_1: 'execd',
+ CISCO_CPI_1_0: 'cpi',
+ FLOWMON_1_0: 'fm',
+ JUNIPER_1_1: 'junos',
+}.items():
+ register_namespace(pre, ns)
+
+qualify = lambda tag, ns=BASE_NS_1_0: tag if ns is None else "{%s}%s" % (ns, tag)
+"""Qualify a *tag* name with a *namespace*, in :mod:`~xml.etree.ElementTree` fashion i.e. *{namespace}tagname*."""
+
+def to_xml(ele, encoding="UTF-8"):
+ "Convert and return the XML for an *ele* (:class:`~xml.etree.ElementTree.Element`) with specified *encoding*."
+ xml = ET.tostring(ele, encoding)
+ return xml if xml.startswith('<?xml') else '<?xml version="1.0" encoding="%s"?>%s' % (encoding, xml)
+
+def to_ele(x):
+ "Convert and return the :class:`~xml.etree.ElementTree.Element` for the XML document *x*. If *x* is already an :class:`~xml.etree.ElementTree.Element` simply returns that."
+ return x if ET.iselement(x) else ET.fromstring(x)
+
+def parse_root(raw):
+ "Efficiently parses the root element of a *raw* XML document, returning a tuple of its qualified name and attribute dictionary."
+ fp = StringIO(raw)
+ for event, element in ET.iterparse(fp, events=('start',)):
+ return (element.tag, element.attrib)
+
+def validated_element(x, tags=None, attrs=None):
+ """Checks if the root element of an XML document or Element meets the supplied criteria.
+
+ *tags* if specified is either a single allowable tag name or sequence of allowable alternatives
+
+ *attrs* if specified is a sequence of required attributes, each of which may be a sequence of several allowable alternatives
+
+ Raises :exc:`XMLError` if the requirements are not met.
+ """
+ ele = to_ele(x)
+ if tags:
+ if isinstance(tags, basestring):
+ tags = [tags]
+ if ele.tag not in tags:
+ raise XMLError("Element [%s] does not meet requirement" % ele.tag)
+ if attrs:
+ for req in attrs:
+ if isinstance(req, basestring): req = [req]
+ for alt in req:
+ if alt in ele.attrib:
+ break
+ else:
+ raise XMLError("Element [%s] does not have required attributes" % ele.tag)
+ return ele
+
+new_ele = lambda tag, attrs={}, **extra: ET.Element(qualify(tag), attrs, **extra)
+
+sub_ele = lambda parent, tag, attrs={}, **extra: ET.SubElement(parent, qualify(tag), attrs, **extra)
+
diff --git a/setup.cfg b/setup.cfg
index 2452758f..44f55c74 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -6,7 +6,7 @@ source-dir = doc/source
[bdist_rpm]
Release = 1
Group = Applications/Accessories
-Requires = python-gevent >= 0.13, python-routes, python-webob
+Requires = python-gevent >= 0.13, python-routes, python-webob, python-paramiko
doc_files = LICENSE
MANIFEST.in
README.rst
diff --git a/tools/pip-requires b/tools/pip-requires
index 7be30721..2a9b4ce4 100644
--- a/tools/pip-requires
+++ b/tools/pip-requires
@@ -1,3 +1,4 @@
gevent>=0.13
routes
webob>=1.0.8
+paramiko