vitro 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. vitro/__init__.py +13 -0
  2. vitro/configs/__init__.py +8 -0
  3. vitro/configs/logging.json +40 -0
  4. vitro/devices/__init__.py +1 -0
  5. vitro/devices/base_devices/__init__.py +5 -0
  6. vitro/devices/base_devices/vitro_device.py +57 -0
  7. vitro/exceptions.py +98 -0
  8. vitro/libraries/__init__.py +1 -0
  9. vitro/libraries/connection_factory.py +78 -0
  10. vitro/libraries/connections/__init__.py +1 -0
  11. vitro/libraries/connections/connect_and_run.py +70 -0
  12. vitro/libraries/connections/ldap_authenticated_serial.py +122 -0
  13. vitro/libraries/connections/local_cmd.py +159 -0
  14. vitro/libraries/connections/ser2net_connection.py +84 -0
  15. vitro/libraries/connections/serial_connection.py +93 -0
  16. vitro/libraries/connections/ssh_connection.py +181 -0
  17. vitro/libraries/connections/telnet.py +122 -0
  18. vitro/libraries/device_manager.py +107 -0
  19. vitro/libraries/docker_factory/__init__.py +1 -0
  20. vitro/libraries/docker_factory/docker_compose_generator.py +226 -0
  21. vitro/libraries/docker_factory/templates/docker-compose.acs.tmpl.json +19 -0
  22. vitro/libraries/docker_factory/templates/docker-compose.ext_voip.tmpl.json +16 -0
  23. vitro/libraries/docker_factory/templates/docker-compose.fxs.tmpl.json +14 -0
  24. vitro/libraries/docker_factory/templates/docker-compose.lan.tmpl.json +17 -0
  25. vitro/libraries/docker_factory/templates/docker-compose.orchestrator.tmpl.json +20 -0
  26. vitro/libraries/docker_factory/templates/docker-compose.sip.tmpl.json +16 -0
  27. vitro/libraries/docker_factory/templates/docker-compose.tmpl.json +81 -0
  28. vitro/libraries/docker_factory/templates/docker-compose.wan.tmpl.json +18 -0
  29. vitro/libraries/interactive_shell.py +299 -0
  30. vitro/libraries/utils.py +195 -0
  31. vitro/libraries/vitro_config.py +303 -0
  32. vitro/libraries/vitro_pexpect.py +139 -0
  33. vitro/main.py +101 -0
  34. vitro/plugins/__init__.py +1 -0
  35. vitro/plugins/core.py +208 -0
  36. vitro/plugins/hookspecs/__init__.py +1 -0
  37. vitro/plugins/hookspecs/core.py +224 -0
  38. vitro/plugins/hookspecs/devices.py +360 -0
  39. vitro/plugins/no_reservation.py +22 -0
  40. vitro/plugins/setup_environment.py +179 -0
  41. vitro/py.typed +0 -0
  42. vitro/type_hints.py +5 -0
  43. vitro-0.8.0.dist-info/METADATA +162 -0
  44. vitro-0.8.0.dist-info/RECORD +48 -0
  45. vitro-0.8.0.dist-info/WHEEL +5 -0
  46. vitro-0.8.0.dist-info/entry_points.txt +7 -0
  47. vitro-0.8.0.dist-info/licenses/LICENSE +34 -0
  48. vitro-0.8.0.dist-info/top_level.txt +1 -0
vitro/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """Automated testing of network devices."""
2
+
3
+ __version__ = "2025.12.17a0"
4
+
5
+ from pluggy import HookimplMarker, HookspecMarker
6
+
7
+ PROJECT_NAME = "vitro"
8
+
9
+ hookspec = HookspecMarker(PROJECT_NAME)
10
+ hookimpl = HookimplMarker(PROJECT_NAME)
11
+
12
+
13
+ __all__ = ["hookimpl", "hookspec"]
@@ -0,0 +1,8 @@
1
+ """Vitro configs package."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ LOGGING_CONFIG = json.loads(
7
+ (Path(__file__).parent / "logging.json").read_text(encoding="utf-8"),
8
+ )
@@ -0,0 +1,40 @@
1
+ {
2
+ "disable_existing_loggers": true,
3
+ "formatters": {
4
+ "pexpect": {
5
+ "class": "logging.Formatter",
6
+ "format": "%(asctime)s %(levelname)-8s- %(message)-100s (%(name)s)"
7
+ },
8
+ "root": {
9
+ "class": "logging.Formatter",
10
+ "format": "%(asctime)s %(levelname)-8s- %(message)-100s (%(name)s:%(funcName)s:%(lineno)d)"
11
+ }
12
+ },
13
+ "handlers": {
14
+ "pexpect": {
15
+ "class": "logging.StreamHandler",
16
+ "formatter": "pexpect",
17
+ "stream": "ext://sys.stderr"
18
+ },
19
+ "root": {
20
+ "class": "logging.StreamHandler",
21
+ "formatter": "root",
22
+ "stream": "ext://sys.stderr"
23
+ }
24
+ },
25
+ "loggers": {
26
+ "pexpect": {
27
+ "handlers": [
28
+ "pexpect"
29
+ ],
30
+ "propagate": false
31
+ }
32
+ },
33
+ "root": {
34
+ "handlers": [
35
+ "root"
36
+ ],
37
+ "level": "DEBUG"
38
+ },
39
+ "version": 1
40
+ }
@@ -0,0 +1 @@
1
+ """Vitro devices package."""
@@ -0,0 +1,5 @@
1
+ """Vitro base devices package."""
2
+
3
+ from vitro.devices.base_devices.vitro_device import VitroDevice
4
+
5
+ __all__ = ["VitroDevice"]
@@ -0,0 +1,57 @@
1
+ """Vitro base device template."""
2
+
3
+ from argparse import Namespace
4
+
5
+ from vitro.libraries.vitro_pexpect import VitroPexpect
6
+ from vitro.type_hints import DeviceConfigType
7
+
8
+
9
+ class VitroDevice:
10
+ """Vitro base device which all devices inherit from."""
11
+
12
+ def __init__(self, config: DeviceConfigType, cmdline_args: Namespace) -> None:
13
+ """Initialize vitro base device.
14
+
15
+ :param config: device configuration
16
+ :param cmdline_args: command line arguments
17
+ """
18
+ self._config: DeviceConfigType = config
19
+ self._cmdline_args = cmdline_args
20
+
21
+ def _extract_property_value(self, property_name: str) -> str:
22
+ name = self._config.get(property_name)
23
+ if name is None:
24
+ msg = f"{property_name} is not set in the configuration: {self._config=}"
25
+ raise RuntimeError(msg)
26
+ return name
27
+
28
+ @property
29
+ def config(self) -> DeviceConfigType:
30
+ """Get device configuration.
31
+
32
+ :returns: device configuration
33
+ """
34
+ return self._config
35
+
36
+ @property
37
+ def device_name(self) -> str:
38
+ """Get name of the device.
39
+
40
+ :returns: device name
41
+ """
42
+ return self._extract_property_value("name")
43
+
44
+ @property
45
+ def device_type(self) -> str:
46
+ """Get type of the device.
47
+
48
+ :returns: device type
49
+ """
50
+ return self._extract_property_value("type")
51
+
52
+ def get_interactive_consoles(self) -> dict[str, VitroPexpect]:
53
+ """Get interactive consoles from device.
54
+
55
+ :returns: interactive consoles of the device
56
+ """
57
+ return {}
vitro/exceptions.py ADDED
@@ -0,0 +1,98 @@
1
+ """Vitro exceptions for all plugins and modules used by framework."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ class VitroError(Exception):
7
+ """Base exception all vitro exceptions inherit from."""
8
+
9
+
10
+ class ShellPromptUndefinedError(VitroError):
11
+ """Raise this when shell prompt is not defined."""
12
+
13
+
14
+ class DeviceConnectionError(VitroError):
15
+ """Raise this on device connection error."""
16
+
17
+
18
+ class SSHConnectionError(DeviceConnectionError):
19
+ """Raise this on SSH connection failure."""
20
+
21
+
22
+ class SCPConnectionError(DeviceConnectionError):
23
+ """Raise this on SCP connection failure."""
24
+
25
+
26
+ class EnvConfigError(VitroError):
27
+ """Raise this on environment configuration error."""
28
+
29
+
30
+ class DeviceRequirementError(VitroError):
31
+ """Raise this on device requirement error."""
32
+
33
+
34
+ class DeviceNotFoundError(VitroError):
35
+ """Raise this on device is not available."""
36
+
37
+
38
+ class FileLockTimeoutError(VitroError):
39
+ """Raise this on file lock timeout."""
40
+
41
+
42
+ class ConfigurationFailureError(VitroError):
43
+ """Raise this on device configuration failure."""
44
+
45
+
46
+ class DeviceBootFailureError(VitroError):
47
+ """Raise this on device boot failure."""
48
+
49
+
50
+ class TR069ResponseErrorError(VitroError):
51
+ """Raise this on TR069 response error."""
52
+
53
+
54
+ class TR069FaultCodeError(VitroError):
55
+ """Raise this on TR069 response error."""
56
+
57
+ faultdict: dict[str, Any]
58
+
59
+
60
+ class UseCaseFailureError(VitroError):
61
+ """Raise this on failures in use cases."""
62
+
63
+
64
+ class NotSupportedError(VitroError):
65
+ """Raise this on feature not supported."""
66
+
67
+
68
+ class SNMPError(VitroError):
69
+ """Raise this on any SNMP related error."""
70
+
71
+
72
+ class VoiceError(VitroError):
73
+ """Raise this on any voice related errors."""
74
+
75
+
76
+ # TODO: maybe move to testsuite
77
+ class TeardownError(VitroError):
78
+ """Raise this on any test teardown failure."""
79
+
80
+
81
+ class ContingencyCheckError(VitroError):
82
+ """Raise this on any contingency check failure."""
83
+
84
+
85
+ class WifiError(VitroError):
86
+ """Raise this on any wifi related errors."""
87
+
88
+
89
+ class MulticastError(VitroError):
90
+ """Raise this on any multicast related errors."""
91
+
92
+
93
+ class CodeError(VitroError):
94
+ """Raise this if an code assert fails.
95
+
96
+ This exception is only meant for custom assert
97
+ clause used inside libraries.
98
+ """
@@ -0,0 +1 @@
1
+ """Vitro libraries package."""
@@ -0,0 +1,78 @@
1
+ """Connection decider module."""
2
+
3
+ from typing import Any
4
+
5
+ from vitro.exceptions import EnvConfigError
6
+ from vitro.libraries.connections.ldap_authenticated_serial import (
7
+ LdapAuthenticatedSerial,
8
+ )
9
+ from vitro.libraries.connections.local_cmd import LocalCmd
10
+ from vitro.libraries.connections.ser2net_connection import Ser2NetConnection
11
+ from vitro.libraries.connections.serial_connection import SerialConnection
12
+ from vitro.libraries.connections.ssh_connection import SSHConnection
13
+ from vitro.libraries.connections.telnet import TelnetConnection
14
+ from vitro.libraries.vitro_pexpect import VitroPexpect
15
+
16
+
17
+ def connection_factory(
18
+ connection_type: str,
19
+ connection_name: str,
20
+ **kwargs: Any, # noqa: ANN401
21
+ ) -> VitroPexpect:
22
+ """Return connection of given type.
23
+
24
+ :param connection_type: type of the connection
25
+ :param connection_name: name of the connection
26
+ :param kwargs: arguments to the connection
27
+ :returns: VitroPexpect: connection of given type
28
+ :raises EnvConfigError: when given connection type is not supported
29
+ """
30
+ connection_dispatcher = {
31
+ "ssh_connection": SSHConnection,
32
+ "authenticated_ssh": SSHConnection,
33
+ "ldap_authenticated_serial": LdapAuthenticatedSerial,
34
+ "local_cmd": LocalCmd,
35
+ "serial": SerialConnection,
36
+ "ser2net": _ser2net_param_parser,
37
+ "telnet": _telnet_param_parser,
38
+ }
39
+ connection_obj = connection_dispatcher.get(connection_type)
40
+ if connection_obj is not None and callable(connection_obj):
41
+ if connection_type == "ssh_connection":
42
+ kwargs.pop("password")
43
+ return connection_obj(connection_name, **kwargs)
44
+ # Handle unsupported connection types
45
+ msg = f"Unsupported connection type: {connection_type}"
46
+ raise EnvConfigError(msg)
47
+
48
+
49
+ def _telnet_param_parser(
50
+ connection_name: str,
51
+ **kwargs: Any, # noqa: ANN401
52
+ ) -> TelnetConnection:
53
+ return TelnetConnection(
54
+ session_name=connection_name,
55
+ command="telnet",
56
+ save_console_logs=kwargs.pop("save_console_logs"),
57
+ args=[
58
+ kwargs["ip_addr"],
59
+ kwargs["port"],
60
+ kwargs["shell_prompt"],
61
+ ],
62
+ )
63
+
64
+
65
+ def _ser2net_param_parser(
66
+ connection_name: str,
67
+ **kwargs: Any, # noqa: ANN401
68
+ ) -> Ser2NetConnection:
69
+ return Ser2NetConnection(
70
+ connection_name,
71
+ "telnet",
72
+ kwargs["save_console_logs"],
73
+ [
74
+ kwargs["ip_addr"],
75
+ kwargs["port"],
76
+ kwargs["shell_prompt"],
77
+ ],
78
+ )
@@ -0,0 +1 @@
1
+ """Vitro connections package."""
@@ -0,0 +1,70 @@
1
+ """Connect and run module."""
2
+
3
+ from collections.abc import Callable
4
+ from time import sleep
5
+ from typing import ParamSpec, Protocol, TypeVar, runtime_checkable
6
+
7
+ from vitro.exceptions import DeviceConnectionError
8
+
9
+ P = ParamSpec("P")
10
+ T = TypeVar("T")
11
+
12
+
13
+ @runtime_checkable
14
+ class RuntimeConnectable(Protocol):
15
+ """Runtine connectable protocol class."""
16
+
17
+ def connect_console(self) -> None:
18
+ """Connect to the console."""
19
+
20
+ def disconnect_console(self) -> None:
21
+ """Disconnect from the console."""
22
+
23
+ def is_console_connected(self) -> bool:
24
+ """Get status of the connection."""
25
+
26
+
27
+ def connect_and_run(func: Callable[P, T]) -> Callable[P, T]:
28
+ """Connect run and disconnect to console at runtime.
29
+
30
+ Note: This is implemented only for instance methods
31
+
32
+ :param func: the decorated method
33
+ :return: True or False
34
+ :rtype: Callable[P, T]
35
+ """
36
+
37
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
38
+ instance = args[0]
39
+ exc_to_raise = None
40
+ if not isinstance(instance, RuntimeConnectable):
41
+ msg = (
42
+ f"Provided instance {instance} do not ,"
43
+ f"follows the protocol RuntimeConnectable .i.e {RuntimeConnectable}"
44
+ )
45
+ raise TypeError(msg)
46
+
47
+ if not instance.is_console_connected():
48
+ # Adding a retry
49
+ for _ in range(10):
50
+ try:
51
+ instance.connect_console()
52
+ break
53
+ except DeviceConnectionError:
54
+ sleep(15)
55
+ raise
56
+ else:
57
+ raise DeviceConnectionError
58
+
59
+ try:
60
+ output = func(*args, **kwargs)
61
+ except Exception as e: # noqa: BLE001 pylint: disable=W0718
62
+ exc_to_raise = e
63
+ finally:
64
+ if instance.is_console_connected():
65
+ instance.disconnect_console()
66
+ if exc_to_raise:
67
+ raise exc_to_raise
68
+ return output
69
+
70
+ return wrapper
@@ -0,0 +1,122 @@
1
+ """SSH connection module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ import pexpect
8
+
9
+ from vitro.exceptions import DeviceConnectionError
10
+ from vitro.libraries.connections.ssh_connection import SSHConnection
11
+
12
+ if TYPE_CHECKING:
13
+ from pexpect.spawnbase import _InputRePattern
14
+
15
+ _CONNECTION_FAILED_STR: str = "Failed to connect to device via serial"
16
+ _EOF_INDEX = 2
17
+
18
+
19
+ class LdapAuthenticatedSerial(SSHConnection):
20
+ """Connect to a serial with ldap credentials."""
21
+
22
+ def __init__( # noqa: PLR0913
23
+ self,
24
+ name: str,
25
+ ip_addr: str,
26
+ ldap_credentials: str,
27
+ shell_prompt: list[_InputRePattern],
28
+ port: int = 22,
29
+ save_console_logs: str = "",
30
+ **kwargs: dict[str, Any], # ignore other arguments # noqa: ARG002
31
+ ) -> None:
32
+ """Initialize ldap authenticated serial connection.
33
+
34
+ :param name: connection name
35
+ :type name: str
36
+ :param ip_addr: server ip address
37
+ :type ip_addr: str
38
+ :param ldap_credentials: ldap credentials
39
+ :type ldap_credentials: str
40
+ :param shell_prompt: shell prompt patterns
41
+ :type shell_prompt: list[str]
42
+ :param port: port number, defaults to 22
43
+ :type port: int
44
+ :param save_console_logs: save console logs to disk, defaults to ""
45
+ :type save_console_logs: str
46
+ :param kwargs: other keyword arguments
47
+ :raises ValueError: invalid LDAP credentials
48
+ """
49
+ if ";" not in ldap_credentials:
50
+ msg = "Invalid LDAP credentials"
51
+ raise ValueError(msg)
52
+ username, password = ldap_credentials.split(";")
53
+ super().__init__(
54
+ name,
55
+ ip_addr,
56
+ username,
57
+ shell_prompt,
58
+ port,
59
+ password,
60
+ save_console_logs,
61
+ )
62
+
63
+ def login_to_server(self, password: str | None = None) -> None:
64
+ """Login to serial server.
65
+
66
+ :param password: LDAP password
67
+ :type password: str
68
+ :raises DeviceConnectionError: failed to connect to device via serial
69
+ """
70
+ if password is None:
71
+ password = self._password
72
+ if self.expect(["Password:", pexpect.EOF, pexpect.TIMEOUT]):
73
+ raise DeviceConnectionError(_CONNECTION_FAILED_STR)
74
+ self.sendline(password)
75
+
76
+ if (
77
+ self.expect_exact(
78
+ ["OpenGear Serial Server", pexpect.TIMEOUT, pexpect.EOF],
79
+ timeout=10,
80
+ )
81
+ == _EOF_INDEX
82
+ ):
83
+ raise DeviceConnectionError(_CONNECTION_FAILED_STR)
84
+ # In case of SSH communication over different geological WAN:
85
+ # The SSH channel does not start to display data post connection.
86
+ # Instead the user needs to enter some key to refresh. e.g. ENTER
87
+ # This is generally due to poor connection.
88
+ # Providing a few input below and flushing the buffer after 5 sec.
89
+ self.sendline()
90
+ self.sendline()
91
+ self.expect(pexpect.TIMEOUT, timeout=5)
92
+
93
+ async def login_to_server_async(self, password: str | None = None) -> None:
94
+ """Login to serial server.
95
+
96
+ :param password: LDAP password
97
+ :type password: str
98
+ :raises DeviceConnectionError: failed to connect to device via serial
99
+ """
100
+ if password is None:
101
+ password = self._password
102
+ if await self.expect(["Password:", pexpect.EOF, pexpect.TIMEOUT], async_=True):
103
+ raise DeviceConnectionError(_CONNECTION_FAILED_STR)
104
+ self.sendline(password)
105
+
106
+ if (
107
+ await self.expect_exact(
108
+ ["OpenGear Serial Server", pexpect.TIMEOUT, pexpect.EOF],
109
+ timeout=10,
110
+ async_=True,
111
+ )
112
+ == _EOF_INDEX
113
+ ):
114
+ raise DeviceConnectionError(_CONNECTION_FAILED_STR)
115
+ # In case of SSH communication over different geological WAN:
116
+ # The SSH channel does not start to display data post connection.
117
+ # Instead the user needs to enter some key to refresh. e.g. ENTER
118
+ # This is generally due to poor connection.
119
+ # Providing a few input below and flushing the buffer after 5 sec.
120
+ self.sendline()
121
+ self.sendline()
122
+ await self.expect(pexpect.TIMEOUT, timeout=5, async_=True)
@@ -0,0 +1,159 @@
1
+ """Connect to a device with a local command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ import pexpect
8
+
9
+ from vitro.exceptions import (
10
+ DeviceConnectionError,
11
+ ShellPromptUndefinedError,
12
+ VitroError,
13
+ )
14
+ from vitro.libraries.vitro_pexpect import VitroPexpect
15
+
16
+ if TYPE_CHECKING:
17
+ from pexpect.spawnbase import _InputRePattern
18
+
19
+ _CONNECTION_ERROR_THRESHOLD = 2
20
+ _CONNECTION_FAILED_STR: str = "Connection failed with Local Command"
21
+
22
+
23
+ class LocalCmd(VitroPexpect):
24
+ """Connect to a device with a local command."""
25
+
26
+ def __init__( # pylint: disable=too-many-arguments # noqa: PLR0913
27
+ self, # pylint: disable=unused-argument
28
+ name: str,
29
+ conn_command: str,
30
+ save_console_logs: str,
31
+ shell_prompt: list[_InputRePattern] | None = None,
32
+ args: list[str] | None = None,
33
+ *,
34
+ username: str = "root",
35
+ password: str | None = None,
36
+ **kwargs: Any, # noqa: ANN401
37
+ ) -> None:
38
+ """Initialize local command connection.
39
+
40
+ :param name: connection name
41
+ :type name: str
42
+ :param conn_command: command to start the session
43
+ :type conn_command: str
44
+ :param save_console_logs: save console logs to disk
45
+ :type save_console_logs: str
46
+ :param shell_prompt: shell prompt pattern, defaults to None
47
+ :type shell_prompt: list[str]
48
+ :param args: arguments to the command, defaults to None
49
+ :type args: list[str], optional
50
+ :param username: effective user inside the local command session.
51
+ Defaults to ``"root"``, matching the default ``docker exec``
52
+ behaviour. Set when the local command launches as a
53
+ non-root user (e.g. ``docker exec --user alice ...``) so
54
+ :meth:`sudo_sendline` knows whether escalation is required.
55
+ :type username: str
56
+ :param password: password used for sudo escalation when
57
+ ``username != "root"``. Required only when the sudoers
58
+ policy prompts for one; may be ``None`` for NOPASSWD
59
+ configurations.
60
+ :type password: str | None
61
+ :param kwargs: additional keyword args
62
+ """
63
+ self._shell_prompt = shell_prompt
64
+ self._username = username
65
+ self._password = password
66
+ if args is None:
67
+ args = []
68
+ super().__init__(
69
+ name,
70
+ conn_command,
71
+ save_console_logs,
72
+ args,
73
+ **kwargs,
74
+ )
75
+
76
+ # pylint: disable=duplicate-code
77
+ def login_to_server(self, password: str | None = None) -> None:
78
+ """Login.
79
+
80
+ :param password: ssh password
81
+ :raises DeviceConnectionError: connection failed via local command
82
+ :raises ValueError: if shell prompt is unavailable
83
+ """
84
+ if password is not None:
85
+ if self.expect(
86
+ ["password:", pexpect.EOF, pexpect.TIMEOUT],
87
+ ):
88
+ raise DeviceConnectionError(_CONNECTION_FAILED_STR)
89
+ self.sendline(password)
90
+ # TODO: temp fix for now. To be decided if shell prompt to be used.
91
+ if not self._shell_prompt:
92
+ raise ShellPromptUndefinedError
93
+ if (
94
+ self.expect(
95
+ [
96
+ pexpect.EOF,
97
+ pexpect.TIMEOUT,
98
+ *self._shell_prompt,
99
+ ],
100
+ )
101
+ < _CONNECTION_ERROR_THRESHOLD
102
+ ):
103
+ raise DeviceConnectionError(_CONNECTION_FAILED_STR)
104
+
105
+ def execute_command(self, command: str, timeout: int = -1) -> str:
106
+ """Execute a command in the local command session.
107
+
108
+ :param command: command to execute
109
+ :param timeout: timeout in seconds. defaults to -1
110
+ :returns: command output
111
+ :raises ValueError: if shell prompt is unavailable
112
+ """
113
+ if not self._shell_prompt:
114
+ raise ShellPromptUndefinedError
115
+ self.sendline(command)
116
+ self.expect_exact(command)
117
+ self.expect(self.linesep)
118
+ # TODO: is this needed? is the shell prompt of Local(Jenkins or any user)?
119
+ self.expect(self._shell_prompt, timeout=timeout)
120
+ return self.get_last_output()
121
+
122
+ def sudo_sendline(self, cmd: str) -> None:
123
+ """Add sudo in the sendline if username is not root.
124
+
125
+ Mirrors :meth:`SSHConnection.sudo_sendline`: when ``_username``
126
+ is not ``"root"``, prime sudo with ``sudo true``, supply
127
+ ``_password`` if sudo prompts for one, then send the command
128
+ prefixed with ``sudo``. For ``_username == "root"`` the method
129
+ collapses to a plain :meth:`sendline` — identical to the root
130
+ branch of :class:`SSHConnection`.
131
+
132
+ :param cmd: command to send
133
+ :raises VitroError: if sudo prompts for a password but none was
134
+ configured on the connection.
135
+ :raises ShellPromptUndefinedError: if no shell prompt was
136
+ configured on the connection.
137
+ """
138
+ if not self._shell_prompt:
139
+ raise ShellPromptUndefinedError
140
+ if self._username != "root":
141
+ self.sendline("sudo true")
142
+ password_requested = self.expect(
143
+ [*self._shell_prompt, "password for .*:", "Password:"],
144
+ )
145
+ if password_requested:
146
+ if self._password is None:
147
+ msg = (
148
+ "sudo prompted for a password but none was configured "
149
+ f"for LocalCmd (username={self._username!r}). "
150
+ "Supply a password at construction time or use a "
151
+ "NOPASSWD sudoers entry for this user."
152
+ )
153
+ raise VitroError(msg)
154
+ self.sendline(self._password)
155
+ self.expect(self._shell_prompt)
156
+ cmd = "sudo " + cmd
157
+ self.sendline(cmd)
158
+
159
+ # pylint: enable=duplicate-code