Skip to content

Commit dec689b

Browse files
authored
Fix SSHClient: stdin processing, missed command in result for parallel (#100)
Port part of tests to pytest: better use-cases coverage
1 parent 2a64bbf commit dec689b

11 files changed

+1482
-1502
lines changed

exec_helpers/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
"async_api",
5252
)
5353

54-
__version__ = "3.1.1"
54+
__version__ = "3.1.2"
5555
__author__ = "Alexey Stepanov"
5656
__author_email__ = "penguinolog@gmail.com"
5757
__maintainers__ = {

exec_helpers/_ssh_client_base.py

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
import platform
2828
import stat
2929
import sys
30-
import threading
3130
import time
3231
import typing
3332
import warnings
@@ -619,7 +618,14 @@ def execute_async(
619618

620619
if stdin is not None:
621620
if not _stdin.channel.closed:
622-
_stdin.write("{stdin}\n".format(stdin=stdin))
621+
if isinstance(stdin, bytes):
622+
stdin_str = stdin.decode("utf-8")
623+
elif isinstance(stdin, bytearray):
624+
stdin_str = bytes(stdin).decode("utf-8")
625+
else:
626+
stdin_str = stdin
627+
628+
_stdin.write("{stdin}\n".format(stdin=stdin_str).encode("utf-8"))
623629
_stdin.flush()
624630
else:
625631
self.logger.warning("STDIN Send failed: closed channel")
@@ -665,45 +671,37 @@ def poll_streams() -> None:
665671
result.read_stderr(src=async_result.stderr, log=self.logger, verbose=verbose)
666672

667673
@threaded.threadpooled
668-
def poll_pipes(stop: threading.Event) -> None:
669-
"""Polling task for FIFO buffers.
670-
671-
:type stop: Event
672-
"""
673-
while not stop.is_set():
674+
def poll_pipes() -> None:
675+
"""Polling task for FIFO buffers."""
676+
while not async_result.interface.status_event.is_set():
674677
time.sleep(0.1)
675678
if async_result.stdout or async_result.stderr:
676679
poll_streams()
677680

678-
if async_result.interface.status_event.is_set():
679-
result.read_stdout(src=async_result.stdout, log=self.logger, verbose=verbose)
680-
result.read_stderr(src=async_result.stderr, log=self.logger, verbose=verbose)
681-
result.exit_code = async_result.interface.exit_status
682-
683-
stop.set()
681+
result.read_stdout(src=async_result.stdout, log=self.logger, verbose=verbose)
682+
result.read_stderr(src=async_result.stderr, log=self.logger, verbose=verbose)
683+
result.exit_code = async_result.interface.exit_status
684684

685685
# channel.status_event.wait(timeout)
686686
cmd_for_log = self._mask_command(cmd=command, log_mask_re=log_mask_re)
687687

688688
# Store command with hidden data
689689
result = exec_result.ExecResult(cmd=cmd_for_log, stdin=kwargs.get("stdin"))
690690

691-
stop_event = threading.Event()
692-
693691
# pylint: disable=assignment-from-no-return
694692
# noinspection PyNoneFunctionAssignment
695-
future = poll_pipes(stop=stop_event) # type: concurrent.futures.Future
693+
future = poll_pipes() # type: concurrent.futures.Future
696694
# pylint: enable=assignment-from-no-return
697695

698696
concurrent.futures.wait([future], timeout)
699697

700698
# Process closed?
701-
if stop_event.is_set():
699+
if async_result.interface.status_event.is_set():
702700
async_result.interface.close()
703701
return result
704702

705-
stop_event.set()
706703
async_result.interface.close()
704+
async_result.interface.status_event.set()
707705
future.cancel()
708706

709707
wait_err_msg = _log_templates.CMD_WAIT_ERROR.format(result=result, timeout=timeout)
@@ -840,7 +838,7 @@ def get_result(remote: "SSHClientBase") -> exec_result.ExecResult:
840838
cmd_for_log = remote._mask_command(cmd=command, log_mask_re=kwargs.get("log_mask_re", None))
841839
# pylint: enable=protected-access
842840

843-
res = exec_result.ExecResult(cmd=cmd_for_log)
841+
res = exec_result.ExecResult(cmd=cmd_for_log, stdin=kwargs.get("stdin", None))
844842
res.read_stdout(src=async_result.stdout)
845843
res.read_stderr(src=async_result.stderr)
846844
res.exit_code = exit_code

exec_helpers/api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ def __enter__(self) -> "ExecHelper":
8686
self.lock.acquire()
8787
return self
8888

89-
def __exit__(self, exc_type: typing.Any, exc_val: typing.Any, exc_tb: typing.Any) -> None: # pragma: no cover
89+
def __exit__(self, exc_type: typing.Any, exc_val: typing.Any, exc_tb: typing.Any) -> None:
9090
"""Context manager usage."""
9191
self.lock.release()
9292

test/test_sftp.py

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
# Copyright 2018 Alexey Stepanov aka penguinolog.
2+
3+
# Copyright 2016 Mirantis, Inc.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
6+
# not use this file except in compliance with the License. You may obtain
7+
# a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14+
# License for the specific language governing permissions and limitations
15+
# under the License.
16+
17+
# pylint: disable=no-self-use
18+
19+
import os
20+
import posixpath
21+
import stat
22+
import unittest
23+
24+
import mock
25+
import paramiko
26+
27+
import exec_helpers
28+
29+
30+
host = "127.0.0.1"
31+
port = 22
32+
username = "user"
33+
password = "pass"
34+
35+
36+
@mock.patch("logging.getLogger", autospec=True)
37+
@mock.patch("paramiko.AutoAddPolicy", autospec=True, return_value="AutoAddPolicy")
38+
@mock.patch("paramiko.SSHClient", autospec=True)
39+
class TestSftp(unittest.TestCase):
40+
def tearDown(self):
41+
with mock.patch("warnings.warn"):
42+
exec_helpers.SSHClient._clear_cache()
43+
44+
@staticmethod
45+
def prepare_sftp_file_tests(client):
46+
_ssh = mock.Mock()
47+
client.return_value = _ssh
48+
_sftp = mock.Mock()
49+
open_sftp = mock.Mock(parent=_ssh, return_value=_sftp)
50+
_ssh.attach_mock(open_sftp, "open_sftp")
51+
52+
# noinspection PyTypeChecker
53+
ssh = exec_helpers.SSHClient(
54+
host=host, port=port, auth=exec_helpers.SSHAuth(username=username, password=password)
55+
)
56+
return ssh, _sftp
57+
58+
def test_exists(self, client, *args):
59+
ssh, _sftp = self.prepare_sftp_file_tests(client)
60+
lstat = mock.Mock()
61+
_sftp.attach_mock(lstat, "lstat")
62+
dst = "/etc"
63+
64+
# noinspection PyTypeChecker
65+
result = ssh.exists(dst)
66+
self.assertTrue(result)
67+
lstat.assert_called_once_with(dst)
68+
69+
# Negative scenario
70+
lstat.reset_mock()
71+
lstat.side_effect = IOError
72+
73+
# noinspection PyTypeChecker
74+
result = ssh.exists(dst)
75+
self.assertFalse(result)
76+
lstat.assert_called_once_with(dst)
77+
78+
def test_stat(self, client, *args):
79+
ssh, _sftp = self.prepare_sftp_file_tests(client)
80+
stat = mock.Mock()
81+
_sftp.attach_mock(stat, "stat")
82+
stat.return_value = paramiko.sftp_attr.SFTPAttributes()
83+
stat.return_value.st_size = 0
84+
stat.return_value.st_uid = 0
85+
stat.return_value.st_gid = 0
86+
dst = "/etc/passwd"
87+
88+
# noinspection PyTypeChecker
89+
result = ssh.stat(dst)
90+
self.assertEqual(result.st_size, 0)
91+
self.assertEqual(result.st_uid, 0)
92+
self.assertEqual(result.st_gid, 0)
93+
94+
def test_isfile(self, client, *args):
95+
class Attrs:
96+
def __init__(self, mode):
97+
self.st_mode = mode
98+
99+
ssh, _sftp = self.prepare_sftp_file_tests(client)
100+
lstat = mock.Mock()
101+
_sftp.attach_mock(lstat, "lstat")
102+
lstat.return_value = Attrs(stat.S_IFREG)
103+
dst = "/etc/passwd"
104+
105+
# noinspection PyTypeChecker
106+
result = ssh.isfile(dst)
107+
self.assertTrue(result)
108+
lstat.assert_called_once_with(dst)
109+
110+
# Negative scenario
111+
lstat.reset_mock()
112+
lstat.return_value = Attrs(stat.S_IFDIR)
113+
114+
# noinspection PyTypeChecker
115+
result = ssh.isfile(dst)
116+
self.assertFalse(result)
117+
lstat.assert_called_once_with(dst)
118+
119+
lstat.reset_mock()
120+
lstat.side_effect = IOError
121+
122+
# noinspection PyTypeChecker
123+
result = ssh.isfile(dst)
124+
self.assertFalse(result)
125+
lstat.assert_called_once_with(dst)
126+
127+
def test_isdir(self, client, *args):
128+
class Attrs:
129+
def __init__(self, mode):
130+
self.st_mode = mode
131+
132+
ssh, _sftp = self.prepare_sftp_file_tests(client)
133+
lstat = mock.Mock()
134+
_sftp.attach_mock(lstat, "lstat")
135+
lstat.return_value = Attrs(stat.S_IFDIR)
136+
dst = "/etc/passwd"
137+
138+
# noinspection PyTypeChecker
139+
result = ssh.isdir(dst)
140+
self.assertTrue(result)
141+
lstat.assert_called_once_with(dst)
142+
143+
# Negative scenario
144+
lstat.reset_mock()
145+
lstat.return_value = Attrs(stat.S_IFREG)
146+
147+
# noinspection PyTypeChecker
148+
result = ssh.isdir(dst)
149+
self.assertFalse(result)
150+
lstat.assert_called_once_with(dst)
151+
152+
lstat.reset_mock()
153+
lstat.side_effect = IOError
154+
# noinspection PyTypeChecker
155+
result = ssh.isdir(dst)
156+
self.assertFalse(result)
157+
lstat.assert_called_once_with(dst)
158+
159+
@mock.patch("exec_helpers.ssh_client.SSHClient.exists")
160+
@mock.patch("exec_helpers.ssh_client.SSHClient.execute")
161+
def test_mkdir(self, execute, exists, *args):
162+
exists.side_effect = [False, True]
163+
164+
dst = "~/tst dir"
165+
escaped_dst = r"~/tst\ dir"
166+
167+
# noinspection PyTypeChecker
168+
ssh = exec_helpers.SSHClient(
169+
host=host, port=port, auth=exec_helpers.SSHAuth(username=username, password=password)
170+
)
171+
172+
# Path not exists
173+
# noinspection PyTypeChecker
174+
ssh.mkdir(dst)
175+
exists.assert_called_once_with(dst)
176+
execute.assert_called_once_with("mkdir -p {}\n".format(escaped_dst))
177+
178+
# Path exists
179+
exists.reset_mock()
180+
execute.reset_mock()
181+
182+
# noinspection PyTypeChecker
183+
ssh.mkdir(dst)
184+
exists.assert_called_once_with(dst)
185+
execute.assert_not_called()
186+
187+
@mock.patch("exec_helpers.ssh_client.SSHClient.execute")
188+
def test_rm_rf(self, execute, *args):
189+
dst = "~/tst"
190+
191+
# noinspection PyTypeChecker
192+
ssh = exec_helpers.SSHClient(
193+
host=host, port=port, auth=exec_helpers.SSHAuth(username=username, password=password)
194+
)
195+
196+
# Path not exists
197+
# noinspection PyTypeChecker
198+
ssh.rm_rf(dst)
199+
execute.assert_called_once_with("rm -rf {}".format(dst))
200+
201+
def test_open(self, client, *args):
202+
ssh, _sftp = self.prepare_sftp_file_tests(client)
203+
fopen = mock.Mock(return_value=True)
204+
_sftp.attach_mock(fopen, "open")
205+
206+
dst = "/etc/passwd"
207+
mode = "r"
208+
# noinspection PyTypeChecker
209+
result = ssh.open(dst)
210+
fopen.assert_called_once_with(dst, mode)
211+
self.assertTrue(result)
212+
213+
@mock.patch("exec_helpers.ssh_client.logger", autospec=True)
214+
@mock.patch("exec_helpers.ssh_client.SSHClient.exists")
215+
@mock.patch("os.path.exists", autospec=True)
216+
@mock.patch("exec_helpers.ssh_client.SSHClient.isdir")
217+
@mock.patch("os.path.isdir", autospec=True)
218+
def test_download(self, isdir, remote_isdir, exists, remote_exists, logger, client, policy, _logger):
219+
ssh, _sftp = self.prepare_sftp_file_tests(client)
220+
isdir.return_value = True
221+
exists.side_effect = [True, False, False]
222+
remote_isdir.side_effect = [False, False, True]
223+
remote_exists.side_effect = [True, False, False]
224+
225+
dst = "/etc/environment"
226+
target = "/tmp/environment"
227+
# noinspection PyTypeChecker
228+
result = ssh.download(destination=dst, target=target)
229+
self.assertTrue(result)
230+
isdir.assert_called_once_with(target)
231+
exists.assert_called_once_with(posixpath.join(target, os.path.basename(dst)))
232+
remote_isdir.assert_called_once_with(dst)
233+
remote_exists.assert_called_once_with(dst)
234+
_sftp.assert_has_calls((mock.call.get(dst, posixpath.join(target, os.path.basename(dst))),))
235+
236+
# Negative scenarios
237+
# noinspection PyTypeChecker
238+
result = ssh.download(destination=dst, target=target)
239+
self.assertFalse(result)
240+
241+
# noinspection PyTypeChecker
242+
ssh.download(destination=dst, target=target)
243+
244+
@mock.patch("exec_helpers.ssh_client.SSHClient.isdir")
245+
@mock.patch("os.path.isdir", autospec=True)
246+
def test_upload_file(self, isdir, remote_isdir, client, *args):
247+
ssh, _sftp = self.prepare_sftp_file_tests(client)
248+
isdir.return_value = False
249+
remote_isdir.return_value = False
250+
target = "/etc/environment"
251+
source = "/tmp/environment"
252+
253+
# noinspection PyTypeChecker
254+
ssh.upload(source=source, target=target)
255+
isdir.assert_called_once_with(source)
256+
remote_isdir.assert_called_once_with(target)
257+
_sftp.assert_has_calls((mock.call.put(source, target),))
258+
259+
@mock.patch("exec_helpers.ssh_client.SSHClient.exists")
260+
@mock.patch("exec_helpers.ssh_client.SSHClient.mkdir")
261+
@mock.patch("os.walk")
262+
@mock.patch("exec_helpers.ssh_client.SSHClient.isdir")
263+
@mock.patch("os.path.isdir", autospec=True)
264+
def test_upload_dir(self, isdir, remote_isdir, walk, mkdir, exists, client, *args):
265+
ssh, _sftp = self.prepare_sftp_file_tests(client)
266+
isdir.return_value = True
267+
remote_isdir.return_value = True
268+
exists.return_value = True
269+
target = "/etc"
270+
source = "/tmp/bash"
271+
filename = "bashrc"
272+
walk.return_value = ((source, "", [filename]),)
273+
expected_path = posixpath.join(target, os.path.basename(source))
274+
expected_file = posixpath.join(expected_path, filename)
275+
276+
# noinspection PyTypeChecker
277+
ssh.upload(source=source, target=target)
278+
isdir.assert_called_once_with(source)
279+
remote_isdir.assert_called_once_with(target)
280+
mkdir.assert_called_once_with(expected_path)
281+
exists.assert_called_once_with(expected_file)
282+
_sftp.assert_has_calls(
283+
(
284+
mock.call.unlink(expected_file),
285+
mock.call.put(os.path.normpath(os.path.join(source, filename)), expected_file),
286+
)
287+
)

0 commit comments

Comments
 (0)