Repository: velp/nocexec

Issue:
Add base support for CIsco IOS

Discussion:

Patch:
diff --git a/README.md b/README.md
index 53dccfd..af40ce1 100644
--- a/README.md
+++ b/README.md
@@ -10,10 +10,10 @@ Supported protocols for connecting to devices:
  * Telnet
  * [NetConf](https://tools.ietf.org/html/rfc6241)
 
-The following vendors of network equipment are supported (now in development, see the issues):
- * Cisco (IOS)
- * Juniper (JunOS)
- * Extreme (XOS)
+The following vendors of network equipment are supported:
+ * Cisco: IOS (SSH, Telnet)
+ * Juniper: JunOS (NetConf only)
+ * Extreme networks: ExtremeXOS (SSH, Telnet)
 
 ## Installation
 NOCExec can be installed from PyPi:
@@ -21,7 +21,7 @@ NOCExec can be installed from PyPi:
 pip install nocexec
 ```
 
-## Usage
+## Examples
 Using a basic SSH client
 
 ```python
@@ -43,13 +43,59 @@ Using a basic NetConf client
     ...     if cli.compare() is not None:
     ...         cli.commit()
     ...
-    '<rpc-reply message-id="urn:uuid:a89b27fb-209c-4e61-9a38-993b51b54eae">\n
-    <system-uptime-information>\n
-    <current-time>\n
+    '<rpc-reply message-id="urn:uuid:a89b27fb-209c-4e61-9a38-993b51b54eae">\n<system-uptime-information>\n<current-time>\n
     .....
-    </current-time>\n
-    </system-uptime-information>\n
-    </rpc-reply>\n'
+    </current-time>\n</system-uptime-information>\n</rpc-reply>\n'
+```
+
+Using driver for Juniper device with JunOS operating system:
+
+```python
+    >>> from nocexec.drivers.juniper import JunOS
+    >>> with JunOS("192.168.0.1", "user", "password") as cli:
+    ...     cli.edit("set interfaces ae0.0 description Test")
+    ...     cli.save()
+    ... 
+    <ncclient.xml_.NCElement object at 0x7fd9581d6c90>
+    True
+    >>> with JunOS("192.168.0.1", "user", "password") as cli:
+    ...     cli.view("show system uptime", tostring=True)
+    ... 
+    '<rpc-reply message-id="urn:uuid:ea3192f8-3e94-4565-86ce-c81f07269845">\n  <system-uptime-information>\n    <current-time>\n      <date-time seconds="1493113938">2017-04-25 12:52:18 MSK</date-time>\n    </current-time>\n    <system-booted-time>\n      <date-time seconds="1490603699">2017-03-27 11:34:59 MSK</date-time>\n      <time-length seconds="2510239">4w1d 01:17</time-length>\n    </system-booted-time>\n    <protocols-started-time>\n      <date-time seconds="1490749306">2017-03-29 04:01:46 MSK</date-time>\n      <time-length seconds="2364632">3w6d 08:50</time-length>\n    </protocols-started-time>\n    <last-configured-time>\n      <date-time seconds="1493113807">2017-04-25 12:50:07 MSK</date-time>\n      <time-length seconds="131">00:02:11</time-length>\n      <user>vponomarev</user>\n    </last-configured-time>\n    <uptime-information>\n      <date-time seconds="1493113938">\n12:52PM\n</date-time>\n      <up-time seconds="2510269">\n29 days,  1:17\n</up-time>\n      <active-user-count format="2 users">\n2\n</active-user-count>\n      <load-average-1>\n0.02\n</load-average-1>\n      <load-average-5>\n0.07\n</load-average-5>\n      <load-average-15>\n0.07\n</load-average-15>\n    </uptime-information>\n  </system-uptime-information>\n</rpc-reply>\n'
+```
+
+Using driver for Cisco device with IOS operating system:
+
+```python
+    >>> from nocexec.drivers.cisco import IOS
+    >>> with IOS("192.168.0.1", "user", "password") as cli:
+    ...    cli.edit("interface FastEthernet 0/31")
+    ...    cli.edit("description Test")
+    ...    cli.save()
+    ... 
+    ['interface FastEthernet 0/31']
+    ['description Test']
+    True
+    >>> with IOS("192.168.0.1", "user", "password") as cli:
+    ...     cli.view("sh run interface Fa0/31")
+    ... 
+    ['sh run interface Fa0/31', 'Building configuration...', '', 'Current configuration : 126 bytes', '!', 'interface FastEthernet0/31', ' description Test', ' switchport access vlan 9', ' switchport mode access', ' spanning-tree portfast', 'end', '']
+```
+
+Using driver for Extreme networks device with XOS operating system:
+
+```python
+    >>> from nocexec.drivers.extreme import XOS
+    >>> with XOS("192.168.0.1", "user", "password") as cli:
+    ...     cli.edit("create vlan Test tag 1000")
+    ...     cli.save()
+    ... 
+    [' create vlan Test tag 1000', '', '* ']
+    True
+    >>> with XOS("192.168.0.1", "user", "password") as cli:
+    ...     cli.view("show switch")
+    ... 
+    [' show switch', '', '', 'SysName:          swm', 'SysLocation:      ', 'SysContact:       ', 'System MAC:       00:04:96:52:D9:8D', 'System Type:      X670-48x', '', 'SysHealth check:  Enabled (Normal)', 'Recovery Mode:    All', 'System Watchdog:  Enabled', '', 'Current Time:     Tue Apr 25 14:02:30 2017', 'Timezone:         [Auto DST Disabled] GMT Offset: 240 minutes, name is not set.', 'Boot Time:        Mon Mar 20 20:27:10 2017', 'Boot Count:       23', 'Next Reboot:      None scheduled', 'System UpTime:    35 days 17 hours 35 minutes 20 seconds ', '', 'Current State:    OPERATIONAL             ', 'Image Selected:   primary                 ', 'Image Booted:     primary                 ', 'Primary ver:      15.3.2.11               ', 'Secondary ver:    12.6.2.10   ', '', 'Config Selected:  primary.cfg                                          ', 'Config Booted:    primary.cfg                                          ', '', 'primary.cfg       Created by ExtremeXOS version 15.3.2.11', '                  254518 bytes saved on Tue Apr 25 14:01:57 2017']
 ```
 
 ## Tests
diff --git a/nocexec/__init__.py b/nocexec/__init__.py
index 02c0369..c360b5e 100644
--- a/nocexec/__init__.py
+++ b/nocexec/__init__.py
@@ -54,8 +54,21 @@ from nocexec.exception import SSHClientError, TelnetClientError, \
 
 LOG = logging.getLogger('nocexec')
 
+__all__ = ['TelnetClient', 'SSHClient', 'NetConfClient']
 
-class TelnetClient(object):  # pylint: disable=too-few-public-methods
+
+class ContextClient(object):  # pylint: disable=too-few-public-methods
+    """Context manager class for all clients and drivers"""
+
+    def __enter__(self):
+        self.connect()  # pylint: disable=no-member
+        return self
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        self.disconnect()  # pylint: disable=no-member
+
+
+class TelnetClient(ContextClient):  # pylint: disable=too-few-public-methods
     """
     A client class for connecting to devices using the telnet protocol and
     executing commands.
@@ -63,7 +76,7 @@ class TelnetClient(object):  # pylint: disable=too-few-public-methods
     pass
 
 
-class SSHClient(object):
+class SSHClient(ContextClient):
     """
     A client class for connecting to devices using the SSH protocol and
     executing commands.
@@ -71,10 +84,12 @@ class SSHClient(object):
         :param device: domain or ip address of device (default: "")
         :param login: username for authorization on device (default: "")
         :param password: password for authorization on device (default: "")
+        :param port: SSH port for connection
         :param timeout: timeout waiting for connection (default: 5)
         :type device: string
         :type login: string
         :type password: string
+        :type port: int
         :type timeout: int
 
     :Example:
@@ -91,22 +106,17 @@ class SSHClient(object):
         raises exceptions inherited from SSHClientError exception
     """
 
-    def __init__(self, device="", login="", password="", timeout=5):
+    # pylint: disable=too-many-arguments
+    def __init__(self, device="", login="", password="", port=22, timeout=5):
         self.ssh_options = {"UserKnownHostsFile": "/dev/null",
                             "StrictHostKeyChecking": "no",
                             "PubkeyAuthentication": "no"}
         self.device = device
+        self.port = port
         self.login = login
         self.password = password
         self.timeout = timeout
-        self.cli = None
-
-    def __enter__(self):
-        self.connect()
-        return self
-
-    def __exit__(self, exc_type, exc_value, traceback):
-        self.disconnect()
+        self.connection = None
 
     def disconnect(self):
         """
@@ -115,7 +125,7 @@ class SSHClient(object):
         .. note::
             not raises exceptions.
         """
-        self.cli.close()
+        self.connection.close()
 
     def connect(self):
         """
@@ -126,20 +136,21 @@ class SSHClient(object):
         """
         opts = " ".join(["-o%s=%s" % (k, v)
                          for k, v in self.ssh_options.items()])
-        conn_cmd = 'ssh {0} {1}@{2}'.format(opts, self.login, self.device)
-        self.cli = pexpect.spawn(conn_cmd, timeout=self.timeout)
+        conn_cmd = 'ssh {0} -p {1} {2}@{3}'.format(
+            opts, self.port, self.login, self.device)
+        self.connection = pexpect.spawn(conn_cmd, timeout=self.timeout)
         LOG.debug("SSH client command: %s", conn_cmd)
-        if self.cli is None:
+        if self.connection is None:
             LOG.error("SSH spawn error to host %s", self.device)
             raise SSHClientError(
                 "SSH spawn error to host {0}".format(self.device))
-        answ = self.cli.expect(['.?assword.*:',
-                                pexpect.EOF, pexpect.TIMEOUT])
+        answ = self.connection.expect(['.?assword.*:',
+                                       pexpect.EOF, pexpect.TIMEOUT])
         # Catch password request
         if answ == 0:
-            self.cli.sendline(self.password)
-            answ = self.cli.expect(['>', '#', 'Permission denied',
-                                    pexpect.EOF, pexpect.TIMEOUT])
+            self.connection.sendline(self.password)
+            answ = self.connection.expect(['>', '#', 'Permission denied',
+                                           pexpect.EOF, pexpect.TIMEOUT])
             # Check promt
             if not answ > 1:
                 LOG.debug("Connect and login successful on %s", self.device)
@@ -162,7 +173,7 @@ class SSHClient(object):
 
     def execute(self, command, wait=None, timeout=10):
         """
-        Execute the command on the device with the expected result and error
+        Running a command on the device with the expected result and error
         handling.
 
             :param command: sent command
@@ -183,8 +194,11 @@ class SSHClient(object):
         if isinstance(wait, list):
             ex = wait + ex
         LOG.debug("execute command '%s'", command)
-        self.cli.sendline(command)
-        answ = self.cli.expect(ex, timeout=timeout)
+        self.connection.sendline(command)
+        answ = self.connection.expect(ex, timeout=timeout)
+        LOG.debug("expect (regex): %s", ex)
+        before = self.connection.before.splitlines()
+        LOG.debug("lines before expect: %s", before)
         # Catch EOF
         if answ == len(ex) - 1:
             LOG.error("execute command '%s' EOF error", command)
@@ -195,7 +209,7 @@ class SSHClient(object):
             LOG.error("execute command '%s' timeout", command)
             raise SSHClientExecuteCmdError(
                 "Execute command '{0}' timeout".format(command))
-        return self.cli.before.splitlines()
+        return before
 
     def send(self, command):
         """
@@ -209,10 +223,11 @@ class SSHClient(object):
             not raises exceptions.
         """
         LOG.debug("send command '%s'", command)
-        self.cli.sendline(command)
+        self.connection.sendline(command)
 
 
-class NetConfClient(object):  # pylint: disable=too-many-instance-attributes
+# pylint: disable=too-many-instance-attributes
+class NetConfClient(ContextClient):
     """
     A client class for connecting to devices using the NetConf protocol
     (RFC6241) and executing commands.
@@ -259,14 +274,15 @@ class NetConfClient(object):  # pylint: disable=too-many-instance-attributes
 
     # pylint: disable=too-many-arguments
     def __init__(self, device="", login="", password="", timeout=5,
-                 exclusive=True, ignore_rpc_errors=None):
+                 device_params=None, exclusive=True, ignore_rpc_errors=None):
         self.device = device
         self.login = login
         self.password = password
         self.timeout = timeout
-        self.cli = None
+        self.connection = None
         self.exclusive = exclusive
         self._configuration_lock = False
+        self.device_params = device_params or {'name': 'junos'}
         if ignore_rpc_errors is None:
             self.ignore_rpc_errors = [
                 # delete not existing configuration
@@ -274,13 +290,6 @@ class NetConfClient(object):  # pylint: disable=too-many-instance-attributes
                 # clear not exist entry
                 "no entry for"]
 
-    def __enter__(self):
-        self.connect()
-        return self
-
-    def __exit__(self, exc_type, exc_value, traceback):
-        self.disconnect()
-
     def _ignored_rpc_error(self, error):
         """
         Check error ignored or not.
@@ -307,7 +316,7 @@ class NetConfClient(object):  # pylint: disable=too-many-instance-attributes
         """
         if self.exclusive:
             self.unlock()
-        self.cli.close_session()
+        self.connection.close_session()
 
     def connect(self):
         """
@@ -318,13 +327,13 @@ class NetConfClient(object):  # pylint: disable=too-many-instance-attributes
             occurs.
         """
         try:
-            self.cli = manager.connect(host=self.device,
-                                       port=22,
-                                       username=self.login,
-                                       password=self.password,
-                                       timeout=self.timeout,
-                                       device_params={'name': 'junos'},
-                                       hostkey_verify=False)
+            self.connection = manager.connect(host=self.device,
+                                              port=22,
+                                              username=self.login,
+                                              password=self.password,
+                                              timeout=self.timeout,
+                                              device_params=self.device_params,
+                                              hostkey_verify=False)
         except AuthenticationError:
             LOG.error("authentication failed on '%s'", self.device)
             raise NetConfClientError(
@@ -333,7 +342,7 @@ class NetConfClient(object):  # pylint: disable=too-many-instance-attributes
             LOG.error("connection to '%s' error: %s", self.device, err)
             raise NetConfClientError(
                 "Connection to '{0}' error: {1}".format(self.device, err))
-        if self.cli is None:
+        if self.connection is None:
             LOG.error("connection to '%s' error: manager return "
                       "None object", self.device)
             raise NetConfClientError("Connection to '{0}' error: manager "
@@ -357,9 +366,9 @@ class NetConfClient(object):  # pylint: disable=too-many-instance-attributes
         """
         try:
             if action == "unlock":
-                lock_res = self.cli.unlock()
+                lock_res = self.connection.unlock()
             else:
-                lock_res = self.cli.lock()
+                lock_res = self.connection.lock()
             if lock_res.xpath('//ok'):
                 self._configuration_lock = not self._configuration_lock
                 LOG.debug("%s configuration on '%s'", action, self.device)
@@ -424,7 +433,7 @@ class NetConfClient(object):  # pylint: disable=too-many-instance-attributes
         """
         LOG.debug("execute edit command '%s'", command)
         try:
-            result = self.cli.load_configuration(
+            result = self.connection.load_configuration(
                 action='set', config=[command])
             if tostring:
                 return result.tostring
@@ -455,8 +464,8 @@ class NetConfClient(object):  # pylint: disable=too-many-instance-attributes
         LOG.debug("execute view command '%s'", command)
         try:
             if tostring:
-                return self.cli.command(command).tostring
-            return self.cli.command(command)
+                return self.connection.command(command).tostring
+            return self.connection.command(command)
         except RPCError as err:
             if self._ignored_rpc_error(err):
                 LOG.info("catch skiped RCP error: %s", str(err))
@@ -474,7 +483,7 @@ class NetConfClient(object):  # pylint: disable=too-many-instance-attributes
         :returns: differences between versions in unix diff format
         :rtype: string or None (if no differences)
         """
-        result = self.cli.compare_configuration(version).xpath(
+        result = self.connection.compare_configuration(version).xpath(
             '//configuration-information/configuration-output')
         if result[0].text and result[0].text.rstrip() != '':
             return result[0].text
@@ -491,7 +500,7 @@ class NetConfClient(object):  # pylint: disable=too-many-instance-attributes
                 False, if errors occur
         """
         try:
-            commit_res = self.cli.commit()
+            commit_res = self.connection.commit()
             if commit_res.xpath('//ok'):
                 return True
         except RPCError as err:
@@ -509,9 +518,16 @@ class NetConfClient(object):  # pylint: disable=too-many-instance-attributes
                 False, if errors occur
         """
         try:
-            self.cli.validate()
+            self.connection.validate()
             return True
         except RPCError as err:
             LOG.error("configuration check on '%s' error: %s",
                       self.device, str(err))
             return False
+
+# pylint: disable=invalid-name
+protocols = {
+    "ssh": SSHClient,
+    "telnet": TelnetClient,
+    "netconf": NetConfClient
+}
diff --git a/nocexec/drivers/__init__.py b/nocexec/drivers/__init__.py
new file mode 100644
index 0000000..de668f7
--- /dev/null
+++ b/nocexec/drivers/__init__.py
@@ -0,0 +1,19 @@
+"""
+Module with drivers for connecting to devices from different manufacturers.
+
+Supported devices:
+  - Cisco devices with IOS devices
+  - Extreme netwroks devices with XOS operating system
+  - Juniper devices with JunOS operating system
+"""
+
+from nocexec.drivers.juniper import JunOS
+from nocexec.drivers.cisco import IOS
+from nocexec.drivers.extreme import XOS
+
+# pylint: disable=invalid-name
+drivers = {
+    "CiscoIOS": IOS,
+    "ExtremeXOS": XOS,
+    "JuniperJunOS": JunOS
+}
diff --git a/nocexec/drivers/base.py b/nocexec/drivers/base.py
new file mode 100644
index 0000000..9f6ab85
--- /dev/null
+++ b/nocexec/drivers/base.py
@@ -0,0 +1,39 @@
+"""
+Parent driver class from which other drivers are inherited
+"""
+
+import nocexec
+
+__all__ = ['NOCExecDriver']
+
+
+# pylint: disable=too-many-instance-attributes
+class NOCExecDriver(nocexec.ContextClient):
+    """
+    Parent driver class from which other drivers are inherited
+    """
+
+    # pylint: disable=too-many-arguments
+    def __init__(self, device, login="", password="", port=22, timeout=5,
+                 protocol="ssh"):
+        self._device = device
+        self._login = login
+        self._password = password
+        self._port = port
+        self._timeout = timeout
+        self._protocol = nocexec.protocols.get(protocol)
+        self._hostname = device
+        self.cli = None
+
+    def init_client(self):
+        """Initialize client for protocol"""
+        self.cli = self._protocol(device=self._device,
+                                  login=self._login,
+                                  password=self._password,
+                                  port=self._port,
+                                  timeout=self._timeout)
+
+    def disconnect(self):
+        """Close the connection if the client is initialized."""
+        if self.cli is not None:
+            self.cli.disconnect()
diff --git a/nocexec/drivers/cisco.py b/nocexec/drivers/cisco.py
new file mode 100644
index 0000000..cfa79fa
--- /dev/null
+++ b/nocexec/drivers/cisco.py
@@ -0,0 +1,216 @@
+"""
+Driver classes for cisco devices.
+
+  - IOS - driver for cisco IOS operating system
+"""
+
+import logging
+from nocexec.drivers.base import NOCExecDriver
+from nocexec.exception import SSHClientError, TelnetClientError, \
+    NOCExecError, SSHClientExecuteCmdError, TelnetClientExecuteCmdError
+
+
+LOG = logging.getLogger('nocexec.drivers.cisco')
+
+__all__ = ['IOSError', 'IOSCommandError', 'IOS']
+
+
+class IOSError(NOCExecError):
+    """
+    The base exception class for Cisco IOS driver
+    """
+    pass
+
+
+class IOSCommandError(IOSError):
+    """
+    The exception class for command errors
+    """
+    pass
+
+
+class IOS(NOCExecDriver):  # pylint: disable=too-many-instance-attributes
+    """
+    A driver class for connecting to Cisco IOS devices using the SSH or Telnet
+    protocol and executing commands.
+
+        :param device: domain or ip address of device (default: "")
+        :param login: username for authorization on device (default: "")
+        :param password: password for authorization on device (default: "")
+        :param port: port number for connection (default: 22)
+        :param timeout: timeout waiting for connection (default: 5)
+        :param protocol: use protocol ('ssh' or 'telnet') for
+                         connection (default: "ssh")
+        :type device: string
+        :type login: string
+        :type password: string
+        :type port: int
+        :type timeout: int
+        :type protocol: string
+
+    :Example:
+
+    >>> from nocexec.drivers.cisco import IOS
+    >>> with IOS("192.168.0.1", "user", "password") as cli:
+    ...     for l in cli.view("show system mtu"):
+    ...         print(l)
+    ['', 'System MTU size is 1500 bytes', 'System Jumbo MTU size is 1500
+    bytes', 'System Alternate MTU size is 1500 bytes', 'Routing MTU size is
+    1500 bytes']
+
+    .. seealso:: :class:`nocexec.drivers.juniper.JunOS` and
+                 :class:`nocexec.drivers.extreme.XOS`
+    .. note::
+        raises exceptions inherited from IOSError exception
+    """
+
+    def __init__(self, *args, **kwargs):
+        super(IOS, self).__init__(*args, **kwargs)
+        self._shell_prompt = "#"
+        self._config_prompt = r"\(config.*?\)#"
+        self._priv_mode = False
+        self._config_mode = False
+
+    def _prepare_shell(self):
+        # disable clipaging, check permission level nad fill hostname
+        shell_ends = ['>', '#']
+        self.cli.connection.sendline("terminal length 0")
+        answ = self.cli.connection.expect(shell_ends)
+        self._hostname = self.cli.connection.before.splitlines()[-1]
+        self._shell_prompt = self._hostname + "#"
+        self._config_prompt = self._hostname + self._config_prompt
+        self._priv_mode = bool(answ)
+
+    def _enable_privileged(self):
+        try:
+            self.cli.execute("enable", wait=[self._hostname + "#"])
+            self._shell_prompt = self._hostname + "#"
+            self._priv_mode = True
+            return True
+        except (SSHClientExecuteCmdError, TelnetClientExecuteCmdError):
+            return False
+
+    def _disable_privileged(self):
+        try:
+            self.cli.execute("disable", wait=[self._hostname + ">"])
+            self._shell_prompt = self._hostname + ">"
+            self._priv_mode = False
+            return True
+        except (SSHClientExecuteCmdError, TelnetClientExecuteCmdError):
+            return False
+
+    def _enter_config(self):
+        if not self._priv_mode and not self._enable_privileged():
+            LOG.error("unregistered mode is used")
+            return False
+        if self._config_mode:
+            return True
+        try:
+            self.cli.execute("configure terminal",
+                             wait=[self._config_prompt])
+            self._config_mode = True
+            return True
+        except (SSHClientExecuteCmdError, TelnetClientExecuteCmdError) as err:
+            LOG.error("enter config mode error: %s", str(err))
+            return False
+
+    def _exit_config(self):
+        if not self._config_mode:
+            return True
+        try:
+            self.cli.execute("end", wait=[self._shell_prompt])
+            self._config_mode = False
+            return True
+        except (SSHClientExecuteCmdError, TelnetClientExecuteCmdError) as err:
+            LOG.error("enter config mode error: %s", str(err))
+            return False
+
+    def connect(self):
+        """
+        Connection to the device via the specified protocol.
+
+        .. note::
+            raises an exception IOSError if a connection error occurs.
+        """
+        super(IOS, self).init_client()
+        try:
+            self.cli.connect()
+        except (SSHClientError, TelnetClientError) as err:
+            raise IOSError(err)
+        self._prepare_shell()
+
+    def edit(self, command):
+        """
+        Running a command on the Cisco IOS device in configuration mode with
+        the expected result and error handling. Before executing the command,
+        the access level is checked and the configuration mode is enabled.
+
+            :param command: sent command
+            :type command: string
+            :returns: list of lines with the result of the command execution
+            :rtype: list of lines
+
+        .. warning:: command is required argument
+        .. note::
+            raises an exception IOSError if connection not established.
+            raises an exception IOSCommandError if an error occurs.
+        """
+        if self.cli is None:
+            raise IOSError("no connection to the device")
+        if not self._enter_config():
+            raise IOSCommandError("can not enter configuration mode")
+        try:
+            result = self.cli.execute(
+                command=command, wait=[self._config_prompt])
+        except (SSHClientExecuteCmdError, TelnetClientExecuteCmdError) as err:
+            raise IOSCommandError(err)
+        return result
+
+    def view(self, command):
+        """
+        Running a command on the Cisco IOS device in view mode with
+        the expected result and error handling. Before executing the command,
+        the configuration mode is disabled.
+
+            :param command: sent command
+            :type command: string
+            :returns: list of lines with the result of the command execution
+            :rtype: list of lines
+
+        .. warning:: command is required argument
+        .. note::
+            raises an exception IOSError if connection not established.
+            raises an exception IOSCommandError if an error occurs.
+        """
+        if self.cli is None:
+            raise IOSError("no connection to the device")
+        if not self._exit_config():
+            raise IOSCommandError("can not exit the configuration mode")
+        try:
+            result = self.cli.execute(
+                command=command, wait=[self._shell_prompt])
+        except (SSHClientExecuteCmdError, TelnetClientExecuteCmdError) as err:
+            raise IOSCommandError(err)
+        return result
+
+    def save(self):
+        """
+        Saving the configuration on the device
+
+            :returns: True if configuration saved, False if not
+            :rtype: bool
+
+        .. note::
+            not raises exceptions.
+        """
+        if self.cli is None:
+            return False
+        if not self._exit_config():
+            return False
+        try:
+            self.cli.execute(command="write memory", wait=[r"\[OK\]"])
+            return True
+        except (SSHClientExecuteCmdError, TelnetClientExecuteCmdError) as err:
+            LOG.error("CiscoIOS save configuration on device '%s' error: %s",
+                      self._device, str(err))
+            return False
diff --git a/nocexec/drivers/extreme.py b/nocexec/drivers/extreme.py
new file mode 100644
index 0000000..b0da521
--- /dev/null
+++ b/nocexec/drivers/extreme.py
@@ -0,0 +1,162 @@
+"""
+Driver classes for Extreme networks devices.
+
+  - XOS - driver for extreme XOS operating system
+"""
+
+
+import logging
+import re
+from nocexec.drivers.base import NOCExecDriver
+from nocexec.exception import SSHClientError, TelnetClientError, \
+    NOCExecError, SSHClientExecuteCmdError, TelnetClientExecuteCmdError
+
+
+LOG = logging.getLogger('nocexec.drivers.extreme')
+
+__all__ = ['XOSError', 'XOSCommandError', 'XOS']
+
+
+class XOSError(NOCExecError):
+    """
+    The base exception class for Extreme XOS driver
+    """
+    pass
+
+
+class XOSCommandError(XOSError):
+    """
+    The exception class for command errors
+    """
+    pass
+
+
+class XOS(NOCExecDriver):  # pylint: disable=too-many-instance-attributes
+    """
+    A driver class for connecting to Cisco IOS devices using the SSH or Telnet
+    protocol and executing commands.
+
+        :param device: domain or ip address of device (default: "")
+        :param login: username for authorization on device (default: "")
+        :param password: password for authorization on device (default: "")
+        :param port: port number for connection (default: 22)
+        :param timeout: timeout waiting for connection (default: 5)
+        :param protocol: use protocol ('ssh' or 'telnet') for
+                         connection (default: "ssh")
+        :type device: string
+        :type login: string
+        :type password: string
+        :type port: int
+        :type timeout: int
+        :type protocol: string
+
+    :Example:
+
+    >>> from nocexec.drivers.extreme import XOS
+    >>> with XOS("192.168.0.1", "user", "password") as cli:
+    ...     for l in cli.view("show system mtu"):
+    ...         print(l)
+    ['', 'SysName:          extreme-switch', ............
+
+    .. seealso:: :class:`nocexec.drivers.juniper.JunOS` and
+                 :class:`nocexec.drivers.cisco.IOS`
+    .. note::
+        raises exceptions inherited from XOSError exception
+    """
+
+    def __init__(self, *args, **kwargs):
+        super(XOS, self).__init__(*args, **kwargs)
+        self._cmd_num = 2
+        self._last_cmd = ""
+
+    def _prepare_shell(self):
+        # disable clipaging, check permission level nad fill hostname
+        self.cli.connection.sendline("disable clipaging")
+        self.cli.connection.expect('.{0} #'.format(self._cmd_num))
+        self._hostname = self.cli.connection.before.splitlines()[-1]
+
+    @staticmethod
+    def _is_error(cmd_result_lines):
+        text = '|'.join(cmd_result_lines)
+        errors = [r'Invalid .* detected.*[.]',
+                  r'Error:.*[.]']
+        for error in errors:
+            if re.findall(error, text):
+                return True
+        return False
+
+    def connect(self):
+        """
+        Connection to the device via the specified protocol.
+
+        .. note::
+            raises an exception XOSError if a connection error occurs.
+        """
+        super(XOS, self).init_client()
+        try:
+            self.cli.connect()
+        except (SSHClientError, TelnetClientError) as err:
+            raise XOSError(err)
+        self._prepare_shell()
+
+    def edit(self, command):
+        """
+        Since there is no configuration mode in the Extreme networks devices,
+        this function is a wrapper over the view() function for driver
+        compatibility.
+        """
+        return self.view(command)
+
+    def view(self, command):
+        """
+        Running a command on the extreme XOS device with the expected result
+        and error handling.
+
+            :param command: sent command
+            :type command: string
+            :returns: list of lines with the result of the command execution
+            :rtype: list of lines
+
+        .. warning:: command is required argument
+        .. note::
+            raises an exception XOSError if connection not established.
+            raises an exception XOSCommandError if an error occurs.
+        """
+        if self.cli is None:
+            raise XOSError("no connection to the device")
+        shell_prompt = self._hostname + ".{0} #"
+        if command != self._last_cmd:
+            self._cmd_num += 1
+            self._last_cmd = command
+        try:
+            result = self.cli.execute(
+                command=command, wait=[shell_prompt.format(self._cmd_num)])
+            if self._is_error(result):
+                raise XOSCommandError('\n'.join(result))
+        except (SSHClientExecuteCmdError, TelnetClientExecuteCmdError) as err:
+            raise XOSCommandError(err)
+        return result
+
+    def save(self):
+        """
+        Saving the primary configuration on the device
+
+            :returns: True if configuration saved, False if not
+            :rtype: bool
+
+        .. note::
+            not raises exceptions.
+        """
+        if self.cli is None:
+            return False
+        try:
+            self.cli.execute(command="save configuration primary",
+                             wait=["Do you want to save configuration to "
+                                   "primary.cfg and overwrite it?"])
+            self.cli.execute(command="Yes", wait=["Configuration saved to "
+                                                  "primary.cfg successfully."])
+            return True
+        except (SSHClientExecuteCmdError, TelnetClientExecuteCmdError) as err:
+            LOG.error("ExtremeXOS save configuration on device '%s' error: %s",
+                      self._device, str(err))
+            return False
diff --git a/nocexec/drivers/juniper.py b/nocexec/drivers/juniper.py
new file mode 100644
index 0000000..144063a
--- /dev/null
+++ b/nocexec/drivers/juniper.py
@@ -0,0 +1,171 @@
+"""
+Driver classes for Juniper devices.
+
+  - JunOS - driver for Juniper JunOS operating system
+"""
+import logging
+from nocexec.drivers.base import NOCExecDriver
+from nocexec.exception import NOCExecError, NetConfClientError, \
+    NetConfClientExecuteCmdError
+
+
+LOG = logging.getLogger('nocexec.drivers.juniper')
+
+__all__ = ['JunOSError', 'JunOSCommandError', 'JunOS']
+
+
+class JunOSError(NOCExecError):
+    """
+    The base exception class for Juniper JunOS driver
+    """
+    pass
+
+
+class JunOSCommandError(JunOSError):
+    """
+    The exception class for command errors
+    """
+    pass
+
+
+class JunOS(NOCExecDriver):
+    """
+    A driver class for connecting to Juniper JunOS devices using the NetConf
+    protocol and executing commands.
+
+        :param device: domain or ip address of device (default: "")
+        :param login: username for authorization on device (default: "")
+        :param password: password for authorization on device (default: "")
+        :param port: port number for connection (default: 22)
+        :param timeout: timeout waiting for connection (default: 5)
+        :type device: string
+        :type login: string
+        :type password: string
+        :type port: int
+        :type timeout: int
+
+    :Example:
+
+    >>> from nocexec.drivers.juniper import JunOS
+    >>> with JunOS("192.168.0.1", "user", "password") as cli:
+    ...     for l in cli.view("show system uptime", tostring=True):
+    ...         print(l)
+    ['Current time: 2017-04-24 14:54:04 MSK', 'System booted: 2017-03-27
+    11:34:59 MSK (4w0d 03:19 ago)'...............
+
+    .. seealso:: :class:`nocexec.drivers.juniper.JunOS` and
+                 :class:`nocexec.drivers.extreme.XOS`
+    .. note::
+        raises exceptions inherited from IOSError exception
+    """
+
+    def __init__(self, *args, **kwargs):  # pylint: disable=too-many-arguments
+        super(JunOS, self).__init__(protocol="netconf", *args, **kwargs)
+
+    def connect(self):
+        """
+        Connection to the device via the specified protocol.
+
+        .. note::
+            raises an exception JunOSError if a connection error occurs.
+        """
+        self.cli = self._protocol(device=self._device,
+                                  login=self._login,
+                                  password=self._password,
+                                  timeout=self._timeout,
+                                  exclusive=False,
+                                  device_params={'name': 'junos'})
+        try:
+            self.cli.connect()
+        except NetConfClientError as err:
+            raise JunOSError(err)
+        # only exclusive mode
+        if not self.cli.lock():
+            raise JunOSError("configuration lock error")
+
+    def disconnect(self):
+        """
+        Unlock configuration and close connection.
+
+        .. note::
+            not raises exceptions.
+        """
+        if self.cli is not None:
+            self.cli.unlock()
+            self.cli.disconnect()
+
+    def edit(self, command, tostring=False):
+        """
+        Running a command on the Juniper JunOS device in configuration mode
+        with the expected result and error handling.
+
+            :param command: sent command
+            :param tostring: enable or disable converting to
+                             string (default: False)
+            :type command: string
+            :type tostring: bool
+            :returns: result of the command execution
+            :rtype: list of lines or etree Element (if tostring == False)
+
+        .. warning:: command is required argument
+        .. note::
+            raises an exception JunOSError if connection not established.
+            raises an exception JunOSCommandError if an error occurs.
+        """
+        if self.cli is None:
+            raise JunOSError("no connection to the device")
+        try:
+            result = self.cli.edit(command=command, tostring=tostring)
+        except NetConfClientExecuteCmdError as err:
+            raise JunOSCommandError(err)
+        return result
+
+    def view(self, command, tostring=False):
+        """
+        Running a command on the Juniper JunOS device in view mode
+        with the expected result and error handling.
+
+            :param command: sent command
+            :param tostring: enable or disable converting to
+                             string (default: False)
+            :type command: string
+            :type tostring: bool
+            :returns: result of the command execution
+            :rtype: list of lines or etree Element (if tostring == False)
+
+        .. warning:: command is required argument
+        .. note::
+            raises an exception JunOSError if connection not established.
+            raises an exception JunOSCommandError if an error occurs.
+        """
+        if self.cli is None:
+            raise JunOSError("no connection to the device")
+        try:
+            result = self.cli.view(command=command, tostring=tostring)
+        except NetConfClientExecuteCmdError as err:
+            raise JunOSCommandError(err)
+        return result
+
+    def save(self):
+        """
+        Saving the configuration on the device. Before saving, the
+        configuration is checked for errors, and if any changes were made. If
+        no changes were made, the save command is not called on the device,
+        but the function returns true.
+
+            :returns: True if configuration saved, False if not
+            :rtype: bool
+
+        .. note::
+            not raises exceptions.
+        """
+        if self.cli.validate():
+            if self.cli.compare() is not None:
+                if not self.cli.commit():
+                    LOG.error("don't commit configuration on '%s'",
+                              self._device)
+                    return False
+            return True
+        else:
+            LOG.error("error in device configuration")
+            return False
diff --git a/nocexec/exception.py b/nocexec/exception.py
index 74499f2..8c84f23 100644
--- a/nocexec/exception.py
+++ b/nocexec/exception.py
@@ -40,6 +40,14 @@ class SSHClientExecuteCmdError(SSHClientError):
     pass
 
 
+class TelnetClientExecuteCmdError(TelnetClientError):
+    """
+    The exception class for errors that occurred during the execution of
+    commands
+    """
+    pass
+
+
 class NetConfClientExecuteCmdError(SSHClientError):
     """
     The exception class for errors that occurred during the execution of
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..224a779
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,2 @@
+[metadata]
+description-file = README.md
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 3270725..04a460b 100644
--- a/setup.py
+++ b/setup.py
@@ -8,7 +8,7 @@ import sys
 import os
 from setuptools import setup
 
-if sys.version_info < (2, 6):
+if sys.version_info < (2, 7):
     raise Exception("NOCExec requires Python 2.7 or higher.")
 
 # Hard linking doesn't work inside VirtualBox shared folders. This means that
@@ -23,7 +23,7 @@ if os.path.abspath(__file__).split(os.path.sep)[1] == 'vagrant':
 
 setup(
     name="NOCExec",
-    version="0.1a",
+    version="0.2a",
     packages=["nocexec"],
     author="Vadim Ponomarev",
     author_email="velizarx@gmail.com",
@@ -41,7 +41,7 @@ setup(
         'Programming Language :: Python :: 2',
         'Programming Language :: Python :: 3',
         'Topic :: System :: Networking',
-        'Topic :: System :: Systems Administratio',
+        'Topic :: System :: Systems Administration',
         'Topic :: Software Development :: Libraries :: Python Modules'
     ],
     install_requires=["pexpect", "ncclient>=0.5"],
diff --git a/tox.ini b/tox.ini
index 34f1ca7..d826b56 100644
--- a/tox.ini
+++ b/tox.ini
@@ -4,7 +4,7 @@
 # and then run "tox" from this directory.
 
 [tox]
-envlist = py27, py33, py34, py35
+envlist = py27, py33, py34, py35, lint
 skip_missing_interpreters = true
 
 [testenv]
@@ -20,9 +20,10 @@ deps =
     py27: mock==1.0
 
 [testenv:lint]
+basepython = python3.5
 commands =
     pep8 tests/ nocexec/
-    pylint tests/ nocexec/ -r n
+    pylint --disable=duplicate-code tests/ nocexec/ -r n
 deps =
     pep8
     pylint


Test patch:
diff --git a/tests/test_nocexec_drivers_base.py b/tests/test_nocexec_drivers_base.py
new file mode 100644
index 0000000..d6e8ad4
--- /dev/null
+++ b/tests/test_nocexec_drivers_base.py
@@ -0,0 +1,42 @@
+"""
+Tests for module nocexec.drivers.base
+"""
+
+import unittest
+try:
+    from unittest import mock
+except ImportError:  # pragma: no cover
+    import mock
+from nocexec import SSHClient
+from nocexec.drivers.base import NOCExecDriver
+
+# pylint: disable=invalid-name,missing-docstring,protected-access
+
+
+class TestNOCExecDriver(unittest.TestCase):
+
+    def setUp(self):
+        self.c = NOCExecDriver(device="d")
+
+    def test_init(self):
+        self.assertEqual(self.c._device, "d")
+        self.assertEqual(self.c._login, "")
+        self.assertEqual(self.c._password, "")
+        self.assertEqual(self.c._port, 22)
+        self.assertEqual(self.c._timeout, 5)
+        self.assertEqual(self.c._protocol, SSHClient)
+        self.assertEqual(self.c._hostname, "d")
+        self.assertEqual(self.c.cli, None)
+
+    def test_init_client(self):
+        self.c._protocol = mock.Mock()
+        self.c.init_client()
+        self.c._protocol.assert_called_with(device='d', login='', password='',
+                                            port=22, timeout=5)
+        self.c.cli = self.c._protocol.return_value
+
+    def test_disconnect(self):
+        self.c.disconnect()  # cli now is None
+        self.c.cli = mock.Mock()
+        self.c.disconnect()
+        self.c.cli.disconnect.assert_called_with()
diff --git a/tests/test_nocexec_drivers_cisco.py b/tests/test_nocexec_drivers_cisco.py
new file mode 100644
index 0000000..776607e
--- /dev/null
+++ b/tests/test_nocexec_drivers_cisco.py
@@ -0,0 +1,149 @@
+"""
+Tests for module nocexec.drivers.cisco
+"""
+
+import unittest
+try:
+    from unittest import mock
+except ImportError:  # pragma: no cover
+    import mock
+from nocexec.drivers.cisco import IOS, IOSError, IOSCommandError
+from nocexec.exception import SSHClientError, SSHClientExecuteCmdError
+
+# pylint: disable=invalid-name,missing-docstring,protected-access
+
+
+class TestIOS(unittest.TestCase):
+
+    def setUp(self):
+        self.c = IOS(device="d")
+        self.c.cli = mock.MagicMock()
+
+    @mock.patch('nocexec.drivers.base.NOCExecDriver.init_client')
+    def test_connect(self, mock_init_client):
+        mock_sendline = self.c.cli.connection.sendline
+        mock_expect = self.c.cli.connection.expect
+        self.c.cli.connection.before.splitlines.return_value = ["", "hostname"]
+        self.c.connect()
+        mock_init_client.assert_called_with()
+        self.c.cli.connect.assert_called_with()
+        mock_sendline.assert_called_with("terminal length 0")
+        mock_expect.assert_called_with(['>', '#'])
+        self.assertEqual(self.c._hostname, "hostname")
+        self.assertEqual(self.c._shell_prompt, "hostname#")
+        self.assertEqual(self.c._config_prompt, r"hostname\(config.*?\)#")
+        self.assertEqual(self.c._priv_mode, bool(mock_expect.return_value))
+
+    # Test connection error
+    @mock.patch('nocexec.drivers.base.NOCExecDriver.init_client')
+    def test_error_connect(self, mock_init_client):
+        self.c.cli.connect.side_effect = SSHClientError("error")
+        with self.assertRaises(IOSError):
+            self.c.connect()
+        mock_init_client.assert_called_with()
+
+    def test_enable_privileged(self):
+        self.assertTrue(self.c._enable_privileged())
+        self.c.cli.execute.assert_called_with("enable", wait=["d#"])
+        self.assertEqual(self.c._shell_prompt, "d#")
+        self.assertEqual(self.c._priv_mode, True)
+        # error
+        self.c.cli.execute.side_effect = SSHClientExecuteCmdError("error")
+        self.assertFalse(self.c._enable_privileged())
+
+    def test_disable_privileged(self):
+        self.assertTrue(self.c._disable_privileged())
+        self.c.cli.execute.assert_called_with("disable", wait=["d>"])
+        self.assertEqual(self.c._shell_prompt, "d>")
+        self.assertEqual(self.c._priv_mode, False)
+        # error
+        self.c.cli.execute.side_effect = SSHClientExecuteCmdError("error")
+        self.assertFalse(self.c._disable_privileged())
+
+    @mock.patch('nocexec.drivers.cisco.IOS._enable_privileged')
+    def test_enter_config(self, mock_priv):
+        self.assertTrue(self.c._enter_config())
+        self.c.cli.execute.assert_called_with(
+            'configure terminal', wait=[r'\(config.*?\)#'])
+        # error execute
+        self.c._priv_mode = True
+        self.c._config_mode = False
+        self.c.cli.execute.side_effect = SSHClientExecuteCmdError("error")
+        self.assertFalse(self.c._enter_config())
+        # already config mode
+        self.c.cli.reset_mock()
+        self.c._config_mode = True
+        self.assertTrue(self.c._enter_config())
+        self.c.cli.execute.assert_not_called()
+        # not privilaged mode
+        self.c._priv_mode = False
+        mock_priv.return_value = False
+        self.assertFalse(self.c._enter_config())
+
+    def test_exit_config(self):
+        self.assertTrue(self.c._exit_config())
+        # exit from config mode
+        self.c._config_mode = True
+        self.assertTrue(self.c._exit_config())
+        self.c.cli.execute.assert_called_with("end", wait=["#"])
+        self.assertFalse(self.c._config_mode)
+        # command error
+        self.c._config_mode = True
+        self.c.cli.execute.side_effect = SSHClientExecuteCmdError("error")
+        self.assertFalse(self.c._exit_config())
+
+    @mock.patch('nocexec.drivers.cisco.IOS._enter_config')
+    def test_edit(self, mock_config):
+        self.c.cli.execute.return_value = ["l1", "l2"]
+        self.assertEqual(self.c.edit("test"), ["l1", "l2"])
+        self.c.cli.execute.assert_called_with(
+            command='test', wait=[r'\(config.*?\)#'])
+        # command error
+        self.c.cli.execute.side_effect = SSHClientExecuteCmdError("error")
+        with self.assertRaises(IOSCommandError):
+            self.assertFalse(self.c.edit("test"))
+        # not configuration mode
+        mock_config.return_value = False
+        with self.assertRaises(IOSCommandError) as err:
+            self.assertFalse(self.c.edit("test"))
+        self.assertEqual("can not enter configuration mode",
+                         str(err.exception))
+        # client not initilized
+        self.c.cli = None
+        with self.assertRaises(IOSError):
+            self.assertFalse(self.c.edit("test"))
+
+    @mock.patch('nocexec.drivers.cisco.IOS._exit_config')
+    def test_view(self, mock_config):
+        self.c.cli.execute.return_value = ["l1", "l2"]
+        self.assertEqual(self.c.view("test"), ["l1", "l2"])
+        self.c.cli.execute.assert_called_with(command='test', wait=['#'])
+        # command error
+        self.c.cli.execute.side_effect = SSHClientExecuteCmdError("error")
+        with self.assertRaises(IOSCommandError):
+            self.assertFalse(self.c.view("test"))
+        # not configuration mode
+        mock_config.return_value = False
+        with self.assertRaises(IOSCommandError) as err:
+            self.assertFalse(self.c.view("test"))
+        self.assertEqual("can not exit the configuration mode",
+                         str(err.exception))
+        # client not initilized
+        self.c.cli = None
+        with self.assertRaises(IOSError):
+            self.assertFalse(self.c.view("test"))
+
+    @mock.patch('nocexec.drivers.cisco.IOS._exit_config')
+    def test_save(self, mock_exit):
+        self.assertTrue(self.c.save())
+        self.c.cli.execute.assert_called_with(command="write memory",
+                                              wait=[r"\[OK\]"])
+        # error command
+        self.c.cli.execute.side_effect = SSHClientExecuteCmdError("error")
+        self.assertFalse(self.c.save())
+        # error exit config mode
+        mock_exit.return_value = False
+        self.assertFalse(self.c.save())
+        # client is None
+        self.c.cli = None
+        self.assertFalse(self.c.save())
diff --git a/tests/test_nocexec_drivers_extreme.py b/tests/test_nocexec_drivers_extreme.py
new file mode 100644
index 0000000..3c0d6d9
--- /dev/null
+++ b/tests/test_nocexec_drivers_extreme.py
@@ -0,0 +1,99 @@
+"""
+Tests for module nocexec.drivers.extreme
+"""
+
+import unittest
+try:
+    from unittest import mock
+except ImportError:  # pragma: no cover
+    import mock
+from nocexec.drivers.extreme import XOS, XOSError, XOSCommandError
+from nocexec.exception import SSHClientError, SSHClientExecuteCmdError
+
+# pylint: disable=invalid-name,missing-docstring,protected-access
+invalid_input = [' asdasdasd', '', '\x07        ^',
+                 '', "%% Invalid input detected at '^' marker.", '']
+error_input = [' create vlan Test tag 11', '',
+               'Error: 802.1Q Tag 11 is assigned to VLAN test.']
+normal = [' show vlan', '', 'test']
+
+
+class TestXOS(unittest.TestCase):
+
+    def setUp(self):
+        self.c = XOS(device="d")
+        self.c.cli = mock.MagicMock()
+
+    @mock.patch('nocexec.drivers.base.NOCExecDriver.init_client')
+    def test_connect(self, mock_init_client):
+        mock_sendline = self.c.cli.connection.sendline
+        mock_expect = self.c.cli.connection.expect
+        self.c.cli.connection.before.splitlines.return_value = ["", "hostname"]
+        self.c.connect()
+        mock_init_client.assert_called_with()
+        self.c.cli.connect.assert_called_with()
+        mock_sendline.assert_called_with("disable clipaging")
+        mock_expect.assert_called_with('.2 #')
+        self.assertEqual(self.c._hostname, "hostname")
+
+    # Test connection error
+    @mock.patch('nocexec.drivers.base.NOCExecDriver.init_client')
+    def test_error_connect(self, mock_init_client):
+        self.c.cli.connect.side_effect = SSHClientError("error")
+        with self.assertRaises(XOSError):
+            self.c.connect()
+        mock_init_client.assert_called_with()
+
+    def test_is_error(self):
+        self.assertTrue(self.c._is_error(invalid_input))
+        self.assertTrue(self.c._is_error(error_input))
+        self.assertFalse(self.c._is_error(normal))
+
+    def _command_function_test(self, funct):
+        self.c.cli.execute.return_value = ["l1", "l2"]
+        # If the command is repeated then the counter self._cmd_num does
+        # not increase
+        for _ in range(3):
+            self.assertEqual(funct("test"), ["l1", "l2"])
+            self.c.cli.execute.assert_called_with(
+                command='test', wait=['d.3 #'])
+        # but if run new command, counter self._cmd_num does increase
+        self.assertEqual(funct("test2"), ["l1", "l2"])
+        self.c.cli.execute.assert_called_with(command='test2', wait=['d.4 #'])
+        # check errors in result
+        for err in [invalid_input, error_input]:
+            self.c.cli.execute.return_value = err
+            with self.assertRaises(XOSCommandError):
+                funct("test2")
+            self.c.cli.execute.assert_called_with(
+                command='test2', wait=['d.4 #'])
+        # execute error
+        self.c.cli.execute.side_effect = SSHClientExecuteCmdError("error")
+        with self.assertRaises(XOSCommandError):
+            self.assertFalse(funct("test"))
+        # client not initilized
+        self.c.cli = None
+        with self.assertRaises(XOSError):
+            self.assertFalse(funct("test"))
+
+    def test_view(self):
+        self._command_function_test(self.c.view)
+
+    def test_edit(self):
+        self._command_function_test(self.c.edit)
+
+    def test_save(self):
+        call1 = mock.call(command="save configuration primary",
+                          wait=["Do you want to save configuration to primary."
+                                "cfg and overwrite it?"])
+        call2 = mock.call(command='Yes',
+                          wait=["Configuration saved to primary.cfg "
+                                "successfully."])
+        self.assertTrue(self.c.save())
+        self.assertEqual(self.c.cli.execute.mock_calls, [call1, call2])
+        # error command
+        self.c.cli.execute.side_effect = SSHClientExecuteCmdError("error")
+        self.assertFalse(self.c.save())
+        # client is None
+        self.c.cli = None
+        self.assertFalse(self.c.save())
diff --git a/tests/test_nocexec_drivers_juniper.py b/tests/test_nocexec_drivers_juniper.py
new file mode 100644
index 0000000..6e4e945
--- /dev/null
+++ b/tests/test_nocexec_drivers_juniper.py
@@ -0,0 +1,87 @@
+"""
+Tests for module nocexec.drivers.juniper
+"""
+
+import unittest
+try:
+    from unittest import mock
+except ImportError:  # pragma: no cover
+    import mock
+from nocexec.drivers.juniper import JunOS, JunOSError, JunOSCommandError
+from nocexec.exception import NetConfClientError, NetConfClientExecuteCmdError
+
+# pylint: disable=invalid-name,missing-docstring,protected-access
+
+
+class TestJunOS(unittest.TestCase):
+
+    def setUp(self):
+        self.c = JunOS(device="d")
+        self.c.cli = mock.MagicMock()
+
+    @mock.patch('nocexec.NetConfClient.__init__', return_value=None)
+    @mock.patch('nocexec.NetConfClient.connect')
+    @mock.patch('nocexec.NetConfClient.lock')
+    def test_connect(self, mock_lock, mock_connect, mock_init):
+        self.c.connect()
+        mock_init.assert_called_with(device='d',
+                                     device_params={'name': 'junos'},
+                                     exclusive=False, login='',
+                                     password='', timeout=5)
+        # not lock
+        mock_lock.return_value = False
+        with self.assertRaises(JunOSError):
+            self.c.connect()
+        # connect error
+        mock_connect.side_effect = NetConfClientError("error")
+        with self.assertRaises(JunOSError):
+            self.c.connect()
+
+    def test_disconnect(self):
+        # client initilized
+        self.c.disconnect()
+        self.c.cli.unlock.assert_called_with()
+        self.c.cli.disconnect.assert_called_with()
+
+    def _command_functions_test(self, funct_name):
+        # get ftesting functions
+        funct = getattr(self.c, funct_name)
+        cli_funct = getattr(self.c.cli, funct_name)
+        # mocking result from client
+        result = cli_funct.return_value = mock.Mock()
+        # test base call
+        self.assertEqual(funct("test"), result)
+        cli_funct.assert_called_with(command="test", tostring=False)
+        # test tostring
+        self.assertEqual(funct("test", True), result)
+        cli_funct.assert_called_with(command="test", tostring=True)
+        # test command error
+        cli_funct.side_effect = NetConfClientExecuteCmdError("error")
+        with self.assertRaises(JunOSCommandError):
+            funct("test")
+        # test client not initilized
+        self.c.cli = None
+        with self.assertRaises(JunOSError) as err:
+            funct("test")
+        self.assertEqual("no connection to the device", str(err.exception))
+
+    def test_edit(self):
+        self._command_functions_test("edit")
+
+    def test_view(self):
+        self._command_functions_test("view")
+
+    def test_save(self):
+        self.assertTrue(self.c.save())
+        # not commit
+        self.c.cli.reset_mock()
+        self.c.cli.commit.return_value = False
+        self.assertFalse(self.c.save())
+        # compare is None (not found changes)
+        self.c.cli.reset_mock()
+        self.c.cli.compare.return_value = None
+        self.assertTrue(self.c.save())
+        # errors in config
+        self.c.cli.reset_mock()
+        self.c.cli.validate.return_value = False
+        self.assertFalse(self.c.save())
diff --git a/tests/test_nocexec_module.py b/tests/test_nocexec_module.py
index c1416a1..0456a47 100644
--- a/tests/test_nocexec_module.py
+++ b/tests/test_nocexec_module.py
@@ -44,7 +44,7 @@ class TestSSHClient(unittest.TestCase):
                            login="user",
                            password="pass")
         # mocking pexpect
-        self.c.cli = mock.Mock()
+        self.c.connection = mock.Mock()
 
     @mock.patch('nocexec.SSHClient.connect')
     @mock.patch('nocexec.SSHClient.disconnect')
@@ -56,13 +56,13 @@ class TestSSHClient(unittest.TestCase):
 
     def test_disconnect(self):
         self.c.disconnect()
-        self.c.cli.close.assert_called_with()
+        self.c.connection.close.assert_called_with()
 
     @mock.patch('pexpect.spawn')
     def test_connect(self, mock_spawn):
         opts = " ".join(["-o%s=%s" % (k, v)
                          for k, v in self.c.ssh_options.items()])
-        cmd = 'ssh {0} user@test'.format(opts)
+        cmd = 'ssh {0} -p 22 user@test'.format(opts)
         # Check normal connect
         mock_spawn.return_value.expect.return_value = 0
         self.c.connect()
@@ -71,7 +71,7 @@ class TestSSHClient(unittest.TestCase):
         # check enter password
         mock_spawn.return_value.sendline.assert_called_with("pass")
         # check result
-        self.assertEqual(self.c.cli, mock_spawn.return_value)
+        self.assertEqual(self.c.connection, mock_spawn.return_value)
         # Check welcome expect errors
         for code in [1, 2]:
             mock_spawn.return_value.expect.return_value = code
@@ -86,32 +86,32 @@ class TestSSHClient(unittest.TestCase):
         mock_spawn.return_value = None
         with self.assertRaises(SSHClientError) as err:
             self.c.connect()
-        self.assertEqual(self.c.cli, None)
+        self.assertEqual(self.c.connection, None)
         self.assertIn("SSH spawn error to host test", str(err.exception))
 
     def test_execute(self):
-        self.c.cli.before.splitlines.return_value = ["line0", "line1"]
+        self.c.connection.before.splitlines.return_value = ["line0", "line1"]
         res = self.c.execute(command="test", wait=["#"])
-        self.c.cli.sendline.assert_called_with("test")
-        self.c.cli.expect.assert_called_with(
+        self.c.connection.sendline.assert_called_with("test")
+        self.c.connection.expect.assert_called_with(
             ["#", pexpect.TIMEOUT, pexpect.EOF], timeout=10)
         self.assertEqual(len(res), 2)
         for indx in [0, 1]:
             self.assertEqual(res[indx], "line%s" % indx)
         # check expect EOF
-        self.c.cli.expect.return_value = 1
+        self.c.connection.expect.return_value = 1
         with self.assertRaises(SSHClientExecuteCmdError) as err:
             self.c.execute(command="test")
         self.assertIn("Execute command 'test' EOF error", str(err.exception))
         # check expect timeout
-        self.c.cli.expect.return_value = 0
+        self.c.connection.expect.return_value = 0
         with self.assertRaises(SSHClientExecuteCmdError) as err:
             self.c.execute(command="test")
         self.assertIn("Execute command 'test' timeout", str(err.exception))
 
     def test_send(self):
         self.c.send("test")
-        self.c.cli.sendline.assert_called_with("test")
+        self.c.connection.sendline.assert_called_with("test")
 
 
 class TestNetConfClient(unittest.TestCase):
@@ -121,7 +121,7 @@ class TestNetConfClient(unittest.TestCase):
                                login="user",
                                password="pass")
         # mocking pexpect
-        self.c.cli = mock.Mock()
+        self.c.connection = mock.Mock()
         # for xml
         device_handler = ncclient.manager.make_device_handler(
             {'name': 'junos'})
@@ -139,7 +139,7 @@ class TestNetConfClient(unittest.TestCase):
 
     def test_disconnect(self):
         self.c.disconnect()
-        self.c.cli.close_session.assert_called_with()
+        self.c.connection.close_session.assert_called_with()
 
     def test_ignored_rpc_error(self):
         self.assertTrue(self.c._ignored_rpc_error(
@@ -172,12 +172,12 @@ class TestNetConfClient(unittest.TestCase):
     def test_locking(self):
 
         # check lock
-        self.c.cli.lock.return_value = NCElement(
+        self.c.connection.lock.return_value = NCElement(
             good_reply, self.tr)
         self.assertTrue(self.c._locking("lock"))
         self.assertTrue(self.c._configuration_lock)
         # check unlock
-        self.c.cli.unlock.return_value = NCElement(
+        self.c.connection.unlock.return_value = NCElement(
             good_reply, self.tr)
         self.assertTrue(self.c._locking("unlock"))
         self.assertFalse(self.c._configuration_lock)
@@ -185,16 +185,16 @@ class TestNetConfClient(unittest.TestCase):
         self.assertTrue(self.c._locking("test"))
         self.assertTrue(self.c._configuration_lock)
         # bad RCP reply lock
-        self.c.cli.lock.return_value = NCElement(bad_reply, self.tr)
+        self.c.connection.lock.return_value = NCElement(bad_reply, self.tr)
         self.assertFalse(self.c._locking("lock"))
         # bad RCP reply unlock
-        self.c.cli.unlock.return_value = NCElement(
+        self.c.connection.unlock.return_value = NCElement(
             bad_reply, self.tr)
         self.assertFalse(self.c._locking("unlock"))
         # check RPCError
-        self.c.cli.lock.side_effect = RPCError(mock.MagicMock())
+        self.c.connection.lock.side_effect = RPCError(mock.MagicMock())
         self.assertFalse(self.c._locking("lock"))
-        self.c.cli.unlock.side_effect = RPCError(mock.MagicMock())
+        self.c.connection.unlock.side_effect = RPCError(mock.MagicMock())
         self.assertFalse(self.c._locking("unlock"))
 
     @mock.patch('nocexec.NetConfClient._locking')
@@ -218,7 +218,7 @@ class TestNetConfClient(unittest.TestCase):
         mock_locking.assert_called_with(action="unlock")
 
     def test_edit(self):
-        mock_lc = self.c.cli.load_configuration
+        mock_lc = self.c.connection.load_configuration
         self.assertEqual(self.c.edit("test"), mock_lc.return_value)
         mock_lc.assert_called_with(action='set', config=["test"])
         # check tostring
@@ -238,7 +238,7 @@ class TestNetConfClient(unittest.TestCase):
             mock_ig.assert_called_with(mock_lc.side_effect)
 
     def test_view(self):
-        mock_cmd = self.c.cli.command
+        mock_cmd = self.c.connection.command
         self.assertEqual(self.c.view("test"), mock_cmd.return_value)
         # check tostring
         mock_cmd.return_value = mock.Mock()
@@ -257,7 +257,7 @@ class TestNetConfClient(unittest.TestCase):
             mock_ig.assert_called_with(mock_cmd.side_effect)
 
     def test_compare(self):
-        mock_compare = self.c.cli.compare_configuration
+        mock_compare = self.c.connection.compare_configuration
         mock_compare.return_value = NCElement(comp_reply, self.tr)
         self.assertEqual(self.c.compare().replace(" ", ""), "\ntest\n")
         mock_compare.assert_called_with(0)
@@ -270,7 +270,7 @@ class TestNetConfClient(unittest.TestCase):
         self.assertEqual(self.c.compare(), None)
 
     def test_commit(self):
-        mock_commit = self.c.cli.commit
+        mock_commit = self.c.connection.commit
         mock_commit.return_value = NCElement(good_reply, self.tr)
         self.assertTrue(self.c.commit())
         # bad reply
@@ -281,7 +281,7 @@ class TestNetConfClient(unittest.TestCase):
         self.assertFalse(self.c.commit())
 
     def test_validate(self):
-        mock_validate = self.c.cli.validate
+        mock_validate = self.c.connection.validate
         self.assertTrue(self.c.validate())
         mock_validate.assert_called_with()
         # RPC error