Skip to content

Commit 5d21eee

Browse files
authored
Host attr (#256)
* Migrate hosts to property, add removal of clients on set. * Added iterator support for host list reassignment. * Added tests. * Updated documentation. * Updated changelog. * Removed unused params.
1 parent 6f3481f commit 5d21eee

File tree

5 files changed

+133
-16
lines changed

5 files changed

+133
-16
lines changed

Changelog.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Changes
1010
* Added interactive shell support to single and parallel clients - see `documentation <https://parallel-ssh.readthedocs.io/en/latest/advanced.html#interactive-shells>`_.
1111
* Added ``pssh.utils.enable_debug_logger`` function.
1212
* ``ParallelSSHClient`` timeout parameter is now also applied to *starting* remote commands via ``run_command``.
13+
* Assigning to ``ParallelSSHClient.hosts`` cleans up clients of hosts no longer in host list - #220
1314

1415
Fixes
1516
-----

doc/advanced.rst

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -788,18 +788,21 @@ Iterators and filtering
788788
Any type of iterator may be used as hosts list, including generator and list comprehension expressions.
789789

790790
:List comprehension:
791+
791792
.. code-block:: python
792793
793794
hosts = ['dc1.myhost1', 'dc2.myhost2']
794795
client = ParallelSSHClient([h for h in hosts if h.find('dc1')])
795796
796797
:Generator:
798+
797799
.. code-block:: python
798800
799801
hosts = ['dc1.myhost1', 'dc2.myhost2']
800802
client = ParallelSSHClient((h for h in hosts if h.find('dc1')))
801803
802804
:Filter:
805+
803806
.. code-block:: python
804807
805808
hosts = ['dc1.myhost1', 'dc2.myhost2']
@@ -808,12 +811,20 @@ Any type of iterator may be used as hosts list, including generator and list com
808811
809812
.. note ::
810813
811-
Since generators by design only iterate over a sequence once then stop, ``client.hosts`` should be re-assigned after each call to ``run_command`` when using generators as target of ``client.hosts``.
814+
Assigning a generator to host list is possible as shown above, and the generator is consumed into a list on assignment.
815+
816+
Multiple calls to ``run_command`` will use the same hosts read from the provided generator.
817+
812818
813819
Overriding hosts list
814820
=======================
815821

816-
Hosts list can be modified in place. A call to ``run_command`` will create new connections as necessary and output will only contain output for the hosts ``run_command`` executed on.
822+
Hosts list can be modified in place.
823+
824+
A call to ``run_command`` will create new connections as necessary and output will only be returned for the hosts ``run_command`` executed on.
825+
826+
Clients for hosts that are no longer on the host list are removed on host list assignment. Reading output from hosts removed from host list is feasible, as long as their output objects or interactive shells are in scope.
827+
817828

818829
.. code-block:: python
819830
@@ -825,3 +836,31 @@ Hosts list can be modified in place. A call to ``run_command`` will create new c
825836
host='otherhost'
826837
exit_code=None
827838
<..>
839+
840+
841+
When wanting to reassign host list frequently, it is best to sort or otherwise ensure order is maintained to avoid reconnections on hosts that are still in the host list but in a different order.
842+
843+
For example, the following will cause reconnections on both hosts, though both are still in the list.
844+
845+
.. code-block:: python
846+
847+
client.hosts = ['host1', 'host2']
848+
client.hosts = ['host2', 'host1']
849+
850+
851+
In such cases it would be best to maintain order to avoid reconnections. This is also true when adding or removing hosts in host list.
852+
853+
No change in clients occurs in the following case.
854+
855+
.. code-block:: python
856+
857+
client.hosts = sorted(['host1', 'host2'])
858+
client.hosts = sorted(['host2', 'host1'])
859+
860+
861+
Clients for hosts that would be removed by a reassignment can be calculated with:
862+
863+
.. code-block:: python
864+
865+
set(enumerate(client.hosts)).difference(
866+
set(enumerate(new_hosts)))

pssh/clients/base/parallel.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,10 @@ def __init__(self, hosts, user=None, password=None, port=None, pkey=None,
4343
timeout=120, pool_size=10,
4444
host_config=None, retry_delay=RETRY_DELAY,
4545
identity_auth=True):
46-
if isinstance(hosts, str) or isinstance(hosts, bytes):
47-
raise TypeError(
48-
"Hosts must be list or other iterable, not string. "
49-
"For example: ['localhost'] not 'localhost'.")
5046
self.allow_agent = allow_agent
5147
self.pool_size = pool_size
5248
self.pool = gevent.pool.Pool(size=self.pool_size)
53-
self.hosts = hosts
49+
self._hosts = self._validate_hosts(hosts)
5450
self.user = user
5551
self.password = password
5652
self.port = port
@@ -64,6 +60,31 @@ def __init__(self, hosts, user=None, password=None, port=None, pkey=None,
6460
self.identity_auth = identity_auth
6561
self._check_host_config()
6662

63+
def _validate_hosts(self, _hosts):
64+
if _hosts is None:
65+
raise ValueError
66+
elif isinstance(_hosts, str) or isinstance(_hosts, bytes):
67+
raise TypeError(
68+
"Hosts must be list or other iterable, not string. "
69+
"For example: ['localhost'] not 'localhost'.")
70+
elif hasattr(_hosts, '__next__') or hasattr(_hosts, 'next'):
71+
_hosts = list(_hosts)
72+
return _hosts
73+
74+
@property
75+
def hosts(self):
76+
return self._hosts
77+
78+
@hosts.setter
79+
def hosts(self, _hosts):
80+
_hosts = self._validate_hosts(_hosts)
81+
cur_vals = set(enumerate(self._hosts))
82+
new_vals = set(enumerate(_hosts))
83+
to_remove = cur_vals.difference(new_vals)
84+
for i, host in to_remove:
85+
self._host_clients.pop((i, host), None)
86+
self._hosts = _hosts
87+
6788
def _check_host_config(self):
6889
if self.host_config is None:
6990
return

pssh/clients/base/single.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,6 @@ def __init__(self, host,
154154
self.pkey = _validate_pkey_path(pkey, self.host)
155155
self.identity_auth = identity_auth
156156
self._keepalive_greenlet = None
157-
self._stdout_buffer = None
158-
self._stderr_buffer = None
159-
self._stdout_reader = None
160-
self._stderr_reader = None
161157
self._init()
162158

163159
def _init(self):

tests/native/test_parallel_client.py

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -836,10 +836,10 @@ def test_pssh_hosts_iterator_hosts_modification(self):
836836
self.assertEqual(len(hosts), len(output),
837837
msg="Did not get output from all hosts. Got output for " \
838838
"%s/%s hosts" % (len(output), len(hosts),))
839-
# Run again without re-assigning host list, should do nothing
839+
# Run again without re-assigning host list, should run the same
840840
output = client.run_command(self.cmd)
841-
self.assertEqual(len(output), 0)
842-
# Re-assigning host list with new hosts should work
841+
self.assertEqual(len(output), len(hosts))
842+
# Re-assigning host list with new hosts should also work
843843
hosts = ['127.0.0.2', '127.0.0.3']
844844
client.hosts = iter(hosts)
845845
output = client.run_command(self.cmd)
@@ -1044,8 +1044,7 @@ def test_host_config_bad_entries(self):
10441044
hosts = ['localhost', 'localhost']
10451045
host_config = [HostConfig()]
10461046
self.assertRaises(ValueError, ParallelSSHClient, hosts, host_config=host_config)
1047-
# Can't sanity check generators
1048-
ParallelSSHClient(iter(hosts), host_config=host_config)
1047+
self.assertRaises(ValueError, ParallelSSHClient, iter(hosts), host_config=host_config)
10491048

10501049
def test_pssh_client_override_allow_agent_authentication(self):
10511050
"""Test running command with allow_agent set to False"""
@@ -1262,6 +1261,67 @@ def test_retries(self):
12621261
client.hosts = [host]
12631262
self.assertRaises(UnknownHostException, client.run_command, self.cmd)
12641263

1264+
def test_setting_hosts(self):
1265+
host2 = '127.0.0.3'
1266+
server2 = OpenSSHServer(host2, port=self.port)
1267+
server2.start_server()
1268+
client = ParallelSSHClient(
1269+
[self.host], port=self.port,
1270+
num_retries=1, retry_delay=1,
1271+
pkey=self.user_key,
1272+
)
1273+
joinall(client.connect_auth())
1274+
_client = list(client._host_clients.values())[0]
1275+
client.hosts = [self.host]
1276+
joinall(client.connect_auth())
1277+
try:
1278+
self.assertEqual(len(client._host_clients), 1)
1279+
_client_after = list(client._host_clients.values())[0]
1280+
self.assertEqual(id(_client), id(_client_after))
1281+
client.hosts = ['127.0.0.2', self.host, self.host]
1282+
self.assertEqual(len(client._host_clients), 0)
1283+
joinall(client.connect_auth())
1284+
self.assertEqual(len(client._host_clients), 2)
1285+
client.hosts = ['127.0.0.2', self.host, self.host]
1286+
self.assertListEqual([(1, self.host), (2, self.host)],
1287+
sorted(list(client._host_clients.keys())))
1288+
self.assertEqual(len(client._host_clients), 2)
1289+
hosts = [self.host, self.host, host2]
1290+
client.hosts = hosts
1291+
joinall(client.connect_auth())
1292+
self.assertListEqual([(0, self.host), (1, self.host), (2, host2)],
1293+
sorted(list(client._host_clients.keys())))
1294+
self.assertEqual(len(client._host_clients), 3)
1295+
hosts = [host2, self.host, self.host]
1296+
client.hosts = hosts
1297+
joinall(client.connect_auth())
1298+
self.assertListEqual([(0, host2), (1, self.host), (2, self.host)],
1299+
sorted(list(client._host_clients.keys())))
1300+
self.assertEqual(len(client._host_clients), 3)
1301+
client.hosts = [self.host]
1302+
self.assertEqual(len(client._host_clients), 0)
1303+
joinall(client.connect_auth())
1304+
self.assertEqual(len(client._host_clients), 1)
1305+
client.hosts = [self.host, host2]
1306+
joinall(client.connect_auth())
1307+
self.assertListEqual([(0, self.host), (1, host2)],
1308+
sorted(list(client._host_clients.keys())))
1309+
self.assertEqual(len(client._host_clients), 2)
1310+
try:
1311+
client.hosts = None
1312+
except ValueError:
1313+
pass
1314+
else:
1315+
raise AssertionError
1316+
try:
1317+
client.hosts = ''
1318+
except TypeError:
1319+
pass
1320+
else:
1321+
raise AssertionError
1322+
finally:
1323+
server2.stop()
1324+
12651325
def test_unknown_host_failure(self):
12661326
"""Test connection error failure case - ConnectionErrorException"""
12671327
host = ''.join([random.choice(string.ascii_letters) for n in range(8)])

0 commit comments

Comments
 (0)