roll up of fixes for sros modules (#22972)

* fixes action handlers for sros
* fixes sros_config module execution to use AnsibleModule
* fixes sros_command module to use socket connection
* adds sros to constants
pull/22976/head
Peter Sprygada 8 years ago committed by GitHub
parent 33624fe96f
commit 3169cbd493

@ -329,7 +329,8 @@ DEFAULT_TEST_PLUGIN_PATH = get_config(p, DEFAULTS, 'test_plugins', 'ANSIBL
DEFAULT_STRATEGY_PLUGIN_PATH = get_config(p, DEFAULTS, 'strategy_plugins', 'ANSIBLE_STRATEGY_PLUGINS',
'~/.ansible/plugins/strategy:/usr/share/ansible/plugins/strategy', value_type='pathlist')
NETWORK_GROUP_MODULES = get_config(p, DEFAULTS, 'network_group_modules','NETWORK_GROUP_MODULES', ['eos', 'nxos', 'ios', 'iosxr', 'junos', 'vyos'],
NETWORK_GROUP_MODULES = get_config(p, DEFAULTS, 'network_group_modules','NETWORK_GROUP_MODULES', ['eos', 'nxos', 'ios', 'iosxr', 'junos',
'vyos', 'sros'],
value_type='list')
DEFAULT_STRATEGY = get_config(p, DEFAULTS, 'strategy', 'ANSIBLE_STRATEGY', 'linear')

@ -30,7 +30,7 @@ import re
from ansible.module_utils.six.moves import zip
from ansible.module_utils.network_common import to_list
DEFAULT_COMMENT_TOKENS = ['#', '!', '/*', '*/']
DEFAULT_COMMENT_TOKENS = ['#', '!', '/*', '*/', 'echo']
class ConfigLine(object):

@ -28,82 +28,93 @@
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
import re
from ansible.module_utils.network import NetworkModule, NetworkError
from ansible.module_utils.network import register_transport, to_list
from ansible.module_utils.shell import CliBase
from ansible.module_utils.netcli import Command
class Cli(CliBase):
NET_PASSWD_RE = re.compile(r"[\r\n]?password: $", re.I)
CLI_PROMPTS_RE = [
re.compile(r"[\r\n]?[\w+\-\.:\/\[\]]+(?:\([^\)]+\)){,3}(?:>|#) ?$"),
re.compile(r"\[\w+\@[\w\-\.]+(?: [^\]])\] ?[>#\$] ?$")
]
CLI_ERRORS_RE = [
re.compile(r"^\r\nError:", re.M),
]
def __init__(self):
super(Cli, self).__init__()
self._rollback_enabled = None
@property
def rollback_enabled(self):
if self._rollback_enabled is not None:
return self._rollback_enabled
resp = self.execute(['show system rollback'])
match = re.search(r'^Rollback Location\s+:\s(\S+)', resp[0], re.M)
self._rollback_enabled = match.group(1) != 'None'
from ansible.module_utils.basic import env_fallback
from ansible.module_utils.network_common import to_list, ComplexList
from ansible.module_utils.connection import exec_command
_DEVICE_CONFIGS = {}
sros_argument_spec = {
'host': dict(),
'port': dict(type='int'),
'username': dict(fallback=(env_fallback, ['ANSIBLE_NET_USERNAME'])),
'password': dict(fallback=(env_fallback, ['ANSIBLE_NET_PASSWORD']), no_log=True),
'ssh_keyfile': dict(fallback=(env_fallback, ['ANSIBLE_NET_SSH_KEYFILE']), type='path'),
'timeout': dict(type='int'),
'provider': dict(type='dict')
}
def check_args(module, warnings):
provider = module.params['provider'] or {}
for key in sros_argument_spec:
if key != 'provider' and module.params[key]:
warnings.append('argument %s has been deprecated and will be '
'removed in a future version' % key)
def get_config(module, flags=[]):
cmd = 'admin display-config '
cmd += ' '.join(flags)
cmd = cmd.strip()
try:
return _DEVICE_CONFIGS[cmd]
except KeyError:
rc, out, err = exec_command(module, cmd)
if rc != 0:
module.fail_json(msg='unable to retrieve current config', stderr=err)
cfg = str(out).strip()
_DEVICE_CONFIGS[cmd] = cfg
return cfg
def to_commands(module, commands):
spec = {
'command': dict(key=True),
'prompt': dict(),
'answer': dict()
}
transform = ComplexList(spec, module)
return transform(commands)
def run_commands(module, commands, check_rc=True):
responses = list()
commands = to_commands(module, to_list(commands))
for cmd in commands:
cmd = module.jsonify(cmd)
rc, out, err = exec_command(module, cmd)
if check_rc and rc != 0:
module.fail_json(msg=err, rc=rc)
responses.append(out)
return responses
def load_config(module, commands):
for command in to_list(commands):
rc, out, err = exec_command(module, command)
if rc != 0:
module.fail_json(msg=err, command=command, rc=rc)
exec_command(module, 'exit all')
def rollback_enabled(self):
if self._rollback_enabled is not None:
return self._rollback_enabled
def connect(self, params, **kwargs):
super(Cli, self).connect(params, kickstart=False, **kwargs)
self.shell.send('environment no more')
self._connected = True
### implementation of netcli.Cli ###
def run_commands(self, commands, **kwargs):
return self.execute(to_list(commands))
### implementation of netcfg.Config ###
def configure(self, commands, **kwargs):
cmds = to_list(commands)
responses = self.execute(cmds)
self.execute(['exit all'])
return responses
def get_config(self, detail=False, **kwargs):
cmd = 'admin display-config'
if detail:
cmd += ' detail'
return self.execute(cmd)[0]
def load_config(self, commands):
resp = self.execute(['show system rollback'])
match = re.search(r'^Rollback Location\s+:\s(\S+)', resp[0], re.M)
self._rollback_enabled = match.group(1) != 'None'
return self._rollback_enabled
def load_config_w_rollback(self, commands):
if self.rollback_enabled:
self.execute(['admin rollback save'])
try:
self.configure(commands)
except NetworkError:
if self.rollback_enabled:
self.execute(['admin rollback save'])
try:
self.configure(commands)
except NetworkError:
if self.rollback_enabled:
self.execute(['admin rollback revert latest-rb',
'admin rollback delete latest-rb'])
raise
if self.rollback_enabled:
self.execute(['admin rollback delete latest-rb'])
def save_config(self):
self.execute(['admin save'])
self.execute(['admin rollback revert latest-rb',
'admin rollback delete latest-rb'])
raise
Cli = register_transport('cli', default=True)(Cli)
if self.rollback_enabled:
self.execute(['admin rollback delete latest-rb'])

@ -16,10 +16,11 @@
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
#
ANSIBLE_METADATA = {'metadata_version': '1.0',
'status': ['preview'],
'supported_by': 'community'}
ANSIBLE_METADATA = {
'metadata_version': '1.0',
'status': ['preview'],
'supported_by': 'community'
}
DOCUMENTATION = """
---
@ -143,34 +144,45 @@ failed_conditions:
type: list
sample: ['...', '...']
"""
from ansible.module_utils.basic import get_exception
from ansible.module_utils.netcli import CommandRunner
from ansible.module_utils.netcli import AddCommandError, FailedConditionsError
from ansible.module_utils.sros import NetworkModule, NetworkError
import time
VALID_KEYS = ['command', 'output', 'prompt', 'response']
from ansible.module_utils.sros import run_commands
from ansible.module_utils.sros import sros_argument_spec, check_args
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.network_common import ComplexList
from ansible.module_utils.netcli import Conditional
from ansible.module_utils.six import string_types
def to_lines(stdout):
for item in stdout:
if isinstance(item, basestring):
if isinstance(item, string_types):
item = str(item).split('\n')
yield item
def parse_commands(module):
for cmd in module.params['commands']:
if isinstance(cmd, basestring):
cmd = dict(command=cmd, output=None)
elif 'command' not in cmd:
module.fail_json(msg='command keyword argument is required')
elif cmd.get('output') not in [None, 'text']:
module.fail_json(msg='invalid output specified for command')
elif not set(cmd.keys()).issubset(VALID_KEYS):
module.fail_json(msg='unknown keyword specified')
yield cmd
def parse_commands(module, warnings):
command = ComplexList(dict(
command=dict(key=True),
prompt=dict(),
answer=dict()
), module)
commands = command(module.params['commands'])
for index, item in enumerate(commands):
if module.check_mode and not item['command'].startswith('show'):
warnings.append(
'only show commands are supported when using check mode, not '
'executing `%s`' % item['command']
)
elif item['command'].startswith('conf'):
module.fail_json(
msg='sros_command does not support running config mode '
'commands. Please use sros_config instead'
)
return commands
def main():
spec = dict(
# { command: <str>, output: <str>, prompt: <str>, response: <str> }
"""main entry point for module execution
"""
argument_spec = dict(
commands=dict(type='list', required=True),
wait_for=dict(type='list', aliases=['waitfor']),
@ -180,59 +192,52 @@ def main():
interval=dict(default=1, type='int')
)
module = NetworkModule(argument_spec=spec,
connect_on_load=False,
argument_spec.update(sros_argument_spec)
module = AnsibleModule(argument_spec=argument_spec,
supports_check_mode=True)
commands = list(parse_commands(module))
conditionals = module.params['wait_for'] or list()
result = {'changed': False}
warnings = list()
check_args(module, warnings)
commands = parse_commands(module, warnings)
result['warnings'] = warnings
runner = CommandRunner(module)
for cmd in commands:
if module.check_mode and not cmd['command'].startswith('show'):
warnings.append('only show commands are supported when using '
'check mode, not executing `%s`' % cmd['command'])
else:
if cmd['command'].startswith('conf'):
module.fail_json(msg='sros_command does not support running '
'config mode commands. Please use '
'sros_config instead')
try:
runner.add_command(**cmd)
except AddCommandError:
exc = get_exception()
warnings.append('duplicate command detected: %s' % cmd)
for item in conditionals:
runner.add_conditional(item)
runner.retries = module.params['retries']
runner.interval = module.params['interval']
runner.match = module.params['match']
try:
runner.run()
except FailedConditionsError:
exc = get_exception()
module.fail_json(msg=str(exc), failed_conditions=exc.failed_conditions)
except NetworkError:
exc = get_exception()
module.fail_json(msg=str(exc))
result = dict(changed=False, stdout=list())
for cmd in commands:
try:
output = runner.get_command(cmd['command'])
except ValueError:
output = 'command not executed due to check_mode, see warnings'
result['stdout'].append(output)
wait_for = module.params['wait_for'] or list()
conditionals = [Conditional(c) for c in wait_for]
result['warnings'] = warnings
result['stdout_lines'] = list(to_lines(result['stdout']))
retries = module.params['retries']
interval = module.params['interval']
match = module.params['match']
while retries > 0:
responses = run_commands(module, commands)
for item in list(conditionals):
if item(responses):
if match == 'any':
conditionals = list()
break
conditionals.remove(item)
if not conditionals:
break
time.sleep(interval)
retries -= 1
if conditionals:
failed_conditions = [item.raw for item in conditionals]
msg = 'One or more conditional statements have not be satisfied'
module.fail_json(msg=msg, failed_conditions=failed_conditions)
result = {
'changed': False,
'stdout': responses,
'stdout_lines': list(to_lines(responses))
}
module.exit_json(**result)

@ -16,9 +16,11 @@
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
#
ANSIBLE_METADATA = {'metadata_version': '1.0',
'status': ['preview'],
'supported_by': 'community'}
ANSIBLE_METADATA = {
'metadata_version': '1.0',
'status': ['preview'],
'supported_by': 'community'
}
DOCUMENTATION = """
@ -203,16 +205,22 @@ updates:
description: The set of commands that will be pushed to the remote device
returned: always
type: list
sample: ['...', '...']
sample: ['config system name "sros01"']
commands:
description: The set of commands that will be pushed to the remote device
returned: always
type: list
sample: ['config system name "sros01"']
backup_path:
description: The full path to the backup file
returned: when backup is yes
type: path
sample: /playbooks/ansible/backup/sros_config.2016-07-16@22:28:34
"""
from ansible.module_utils.basic import get_exception
from ansible.module_utils.sros import NetworkModule, NetworkError
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.netcfg import NetworkConfig, dumps
from ansible.module_utils.sros import sros_argument_spec, check_args
from ansible.module_utils.sros import load_config, run_commands, get_config
def sanitize_config(lines):
commands = list()
@ -224,15 +232,17 @@ def sanitize_config(lines):
commands.append(line)
return commands
def get_config(module, result):
def get_active_config(module):
contents = module.params['config']
if not contents:
defaults = module.params['defaults']
contents = module.config.get_config(detail=defaults)
return NetworkConfig(device_os='sros', contents=contents)
flags = []
if module.params['defaults']:
flags = ['detail']
return get_config(module, flags)
return contents
def get_candidate(module):
candidate = NetworkConfig(device_os='sros')
candidate = NetworkConfig(indent=4)
if module.params['src']:
candidate.load(module.params['src'])
elif module.params['lines']:
@ -246,33 +256,23 @@ def run(module, result):
candidate = get_candidate(module)
if match != 'none':
config = get_config(module, result)
config_text = get_active_config(module)
config = NetworkConfig(indent=4, contents=config_text)
configobjs = candidate.difference(config)
else:
configobjs = candidate.items
if configobjs:
commands = dumps(configobjs, 'lines')
commands = dumps(configobjs, 'commands')
commands = sanitize_config(commands.split('\n'))
result['commands'] = commands
result['updates'] = commands
# check if creating checkpoints is possible
if not module.connection.rollback_enabled:
warn = 'Cannot create checkpoint. Please enable this feature ' \
'using the sros_rollback module. Automatic rollback ' \
'will be disabled'
result['warnings'].append(warn)
# send the configuration commands to the device and merge
# them with the current running config
if not module.check_mode:
module.config.load_config(commands)
result['changed'] = True
if module.params['save']:
if not module.check_mode:
module.config.save_config()
load_config(module, commands)
result['changed'] = True
def main():
@ -293,23 +293,30 @@ def main():
save=dict(type='bool', default=False),
)
argument_spec.update(sros_argument_spec)
mutually_exclusive = [('lines', 'src')]
module = NetworkModule(argument_spec=argument_spec,
connect_on_load=False,
module = AnsibleModule(argument_spec=argument_spec,
mutually_exclusive=mutually_exclusive,
supports_check_mode=True)
result = dict(changed=False, warnings=list())
warnings = list()
check_args(module, warnings)
if warnings:
result['warnings'] = warnings
if module.params['backup']:
result['__backup__'] = module.config.get_config()
result['__backup__'] = get_config(module)
run(module, result)
try:
run(module, result)
except NetworkError:
exc = get_exception()
module.fail_json(msg=str(exc), **exc.kwargs)
if module.params['save']:
if not module.check_mode:
run_commands(module, ['admin save'])
result['changed'] = True
module.exit_json(**result)

@ -16,9 +16,11 @@
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
#
ANSIBLE_METADATA = {'metadata_version': '1.0',
'status': ['preview'],
'supported_by': 'community'}
ANSIBLE_METADATA = {
'metadata_version': '1.0',
'status': ['preview'],
'supported_by': 'community'
}
DOCUMENTATION = """
@ -107,10 +109,12 @@ updates:
type: list
sample: ['...', '...']
"""
from ansible.module_utils.basic import get_exception
from ansible.module_utils.sros import NetworkModule, NetworkError
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.sros import load_config, get_config
from ansible.module_utils.sros import sros_argument_spec, check_args
from ansible.module_utils.netcfg import NetworkConfig, dumps
def invoke(name, *args, **kwargs):
func = globals().get(name)
if func:
@ -136,7 +140,7 @@ def present(module, commands):
invoke(setter, module, commands)
def absent(module, commands):
config = module.config.get_config()
config = get_config(module)
if 'rollback-location' in config:
commands.append('configure system rollback no rollback-location')
if 'rescue-location' in config:
@ -166,27 +170,9 @@ def set_rescue_location(module, commands):
value = module.params['rescue_location']
commands.append('configure system rollback rescue-location "%s"' % value)
def get_config(module):
contents = module.config.get_config()
return NetworkConfig(device_os='sros', contents=contents)
def load_config(module, commands, result):
candidate = NetworkConfig(device_os='sros', contents='\n'.join(commands))
config = get_config(module)
configobjs = candidate.difference(config)
if configobjs:
commands = dumps(configobjs, 'lines')
commands = sanitize_config(commands.split('\n'))
result['updates'] = commands
# send the configuration commands to the device and merge
# them with the current running config
if not module.check_mode:
module.config(commands)
result['changed'] = True
def get_device_config(module):
contents = get_config(module)
return NetworkConfig(indent=4, contents=contents)
def main():
""" main entry point for module execution
@ -202,8 +188,9 @@ def main():
state=dict(default='present', choices=['present', 'absent'])
)
module = NetworkModule(argument_spec=argument_spec,
connect_on_load=False,
argument_spec.update(sros_argument_spec)
module = AnsibleModule(argument_spec=argument_spec,
supports_check_mode=True)
state = module.params['state']
@ -213,11 +200,24 @@ def main():
commands = list()
invoke(state, module, commands)
try:
load_config(module, commands, result)
except NetworkError:
exc = get_exception()
module.fail_json(msg=str(exc), **exc.kwargs)
candidate = NetworkConfig(indent=4, contents='\n'.join(commands))
config = get_device_config(module)
configobjs = candidate.difference(config)
if configobjs:
#commands = dumps(configobjs, 'lines')
commands = dumps(configobjs, 'commands')
commands = sanitize_config(commands.split('\n'))
result['updates'] = commands
result['commands'] = commands
# send the configuration commands to the device and merge
# them with the current running config
if not module.check_mode:
load_config(module, commands)
result['changed'] = True
module.exit_json(**result)

@ -0,0 +1,111 @@
#
# (c) 2016 Red Hat Inc.
#
# This file is part of Ansible
#
# Ansible is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
#
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
import os
import sys
import copy
from ansible.plugins.action.normal import ActionModule as _ActionModule
from ansible.utils.path import unfrackpath
from ansible.plugins import connection_loader
from ansible.module_utils.sros import sros_argument_spec
from ansible.module_utils.basic import AnsibleFallbackNotFound
from ansible.module_utils.six import iteritems
from ansible.module_utils._text import to_bytes
try:
from __main__ import display
except ImportError:
from ansible.utils.display import Display
display = Display()
class ActionModule(_ActionModule):
def run(self, tmp=None, task_vars=None):
if self._play_context.connection != 'local':
return dict(
failed=True,
msg='invalid connection specified, expected connection=local, '
'got %s' % self._play_context.connection
)
provider = self.load_provider()
pc = copy.deepcopy(self._play_context)
pc.connection = 'network_cli'
pc.network_os = 'sros'
pc.remote_addr = provider['host'] or self._play_context.remote_addr
pc.port = provider['port'] or self._play_context.port or 22
pc.remote_user = provider['username'] or self._play_context.connection_user
pc.password = provider['password'] or self._play_context.password
pc.private_key_file = provider['ssh_keyfile'] or self._play_context.private_key_file
pc.timeout = provider['timeout'] or self._play_context.timeout
display.vvv('using connection plugin %s' % pc.connection, pc.remote_addr)
connection = self._shared_loader_obj.connection_loader.get('persistent', pc, sys.stdin)
socket_path = self._get_socket_path(pc)
display.vvvv('socket_path: %s' % socket_path, pc.remote_addr)
if not os.path.exists(socket_path):
# start the connection if it isn't started
rc, out, err = connection.exec_command('open_shell()')
if not rc == 0:
return {'failed': True, 'msg': 'unable to open shell', 'rc': rc}
task_vars['ansible_socket'] = socket_path
return super(ActionModule, self).run(tmp, task_vars)
def _get_socket_path(self, play_context):
ssh = connection_loader.get('ssh', class_only=True)
cp = ssh._create_control_path(play_context.remote_addr, play_context.port, play_context.remote_user)
path = unfrackpath("$HOME/.ansible/pc")
return cp % dict(directory=path)
def load_provider(self):
provider = self._task.args.get('provider', {})
for key, value in iteritems(sros_argument_spec):
if key != 'provider' and key not in provider:
if key in self._task.args:
provider[key] = self._task.args[key]
elif 'fallback' in value:
provider[key] = self._fallback(value['fallback'])
elif key not in provider:
provider[key] = None
return provider
def _fallback(self, fallback):
strategy = fallback[0]
args = []
kwargs = {}
for item in fallback[1:]:
if isinstance(item, dict):
kwargs = item
else:
args = item
try:
return strategy(*args, **kwargs)
except AnsibleFallbackNotFound:
pass

@ -19,10 +19,95 @@
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
from ansible.plugins.action import ActionBase
from ansible.plugins.action.net_config import ActionModule as NetActionModule
import os
import re
import time
import glob
class ActionModule(NetActionModule, ActionBase):
pass
from ansible.plugins.action.sros import ActionModule as _ActionModule
from ansible.module_utils._text import to_text
from ansible.module_utils.six.moves.urllib.parse import urlsplit
from ansible.utils.vars import merge_hash
PRIVATE_KEYS_RE = re.compile('__.+__')
class ActionModule(_ActionModule):
def run(self, tmp=None, task_vars=None):
if self._task.args.get('src'):
try:
self._handle_template()
except ValueError as exc:
return dict(failed=True, msg=exc.message)
result = super(ActionModule, self).run(tmp, task_vars)
if self._task.args.get('backup') and result.get('__backup__'):
# User requested backup and no error occurred in module.
# NOTE: If there is a parameter error, _backup key may not be in results.
filepath = self._write_backup(task_vars['inventory_hostname'],
result['__backup__'])
result['backup_path'] = filepath
# strip out any keys that have two leading and two trailing
# underscore characters
for key in result.keys():
if PRIVATE_KEYS_RE.match(key):
del result[key]
return result
def _get_working_path(self):
cwd = self._loader.get_basedir()
if self._task._role is not None:
cwd = self._task._role._role_path
return cwd
def _write_backup(self, host, contents):
backup_path = self._get_working_path() + '/backup'
if not os.path.exists(backup_path):
os.mkdir(backup_path)
for fn in glob.glob('%s/%s*' % (backup_path, host)):
os.remove(fn)
tstamp = time.strftime("%Y-%m-%d@%H:%M:%S", time.localtime(time.time()))
filename = '%s/%s_config.%s' % (backup_path, host, tstamp)
open(filename, 'w').write(contents)
return filename
def _handle_template(self):
src = self._task.args.get('src')
working_path = self._get_working_path()
if os.path.isabs(src) or urlsplit('src').scheme:
source = src
else:
source = self._loader.path_dwim_relative(working_path, 'templates', src)
if not source:
source = self._loader.path_dwim_relative(working_path, src)
if not os.path.exists(source):
raise ValueError('path specified in src not found')
try:
with open(source, 'r') as f:
template_data = to_text(f.read())
except IOError:
return dict(failed=True, msg='unable to load src file')
# Create a template search path in the following order:
# [working_path, self_role_path, dependent_role_paths, dirname(source)]
searchpath = [working_path]
if self._task._role is not None:
searchpath.append(self._task._role._role_path)
if hasattr(self._task, "_block:"):
dep_chain = self._task._block.get_dep_chain()
if dep_chain is not None:
for role in dep_chain:
searchpath.append(role._role_path)
searchpath.append(os.path.dirname(source))
self._templar.environment.loader.searchpath = searchpath
self._task.args['src'] = self._templar.template(template_data)

@ -34,7 +34,7 @@ class TerminalModule(TerminalBase):
]
terminal_stderr_re = [
re.compile(r"^\r\nError:"),
re.compile(r"Error:"),
]
def on_open_shell(self):

Loading…
Cancel
Save