@@ -47,16 +47,17 @@ class AuthenticationException(Exception):
4747
4848class SSHClient (object ):
4949 """Wrapper class over paramiko.SSHClient with sane defaults
50- Honours ~/.ssh/config entries for host username overrides"""
50+ Honours ~/.ssh/config and /etc/ssh/ssh_config entries for host username \
51+ overrides"""
5152
5253 def __init__ (self , host ,
5354 user = None , password = None , port = None ):
54- """Connect to host honoring any user set configuration in ~/.ssh/config
55- or /etc/ssh/ssh_config
55+ """Connect to host honouring any user set configuration in ~/.ssh/config \
56+ or /etc/ssh/ssh_config
5657
5758 :param host: Hostname to connect to
5859 :type host: str
59- :param user: (Optional) User to login as. Defaults to logged in user or\
60+ :param user: (Optional) User to login as. Defaults to logged in user or \
6061 user from ~/.ssh/config if set
6162 :type user: str
6263 :raises: ssh_client.AuthenticationException on authentication error
@@ -106,7 +107,24 @@ def _connect(self):
106107 raise AuthenticationException (e )
107108
108109 def exec_command (self , command , sudo = False , ** kwargs ):
109- """Wrapper to paramiko.SSHClient.exec_command"""
110+ """Wrapper to :mod:`paramiko.SSHClient.exec_command`
111+
112+ Opens a new SSH session with a pty and runs command with given \
113+ `kwargs` if any. Greenlet then yields (sleeps) while waiting for \
114+ command to finish executing or channel to close indicating the same.
115+
116+ :param command: Shell command to execute
117+ :type command: str
118+ :param sudo: (Optional) Run with sudo. Defaults to False
119+ :type sudo: bool
120+ :param kwargs: (Optional) Keyword arguments to be passed to remote \
121+ command
122+ :type kwargs: dict
123+ :rtype: Tuple of `(channel, hostname, stdout, stderr)`. \
124+ Channel is the remote SSH channel, needed to ensure all of stdout has \
125+ been got, hostname is remote hostname the copy is to, stdout and \
126+ stderr are buffers containing command output.
127+ """
110128 channel = self .client .get_transport ().open_session ()
111129 channel .get_pty ()
112130 (_ , stdout , stderr ) = (channel .makefile ('wb' ), channel .makefile ('rb' ),
@@ -129,22 +147,36 @@ def _make_sftp(self):
129147 return paramiko .SFTPClient .from_transport (transport )
130148
131149 def mkdir (self , sftp , directory ):
132- """Make directory via SFTP channel"""
150+ """Make directory via SFTP channel
151+
152+ :param sftp: SFTP client object
153+ :type sftp: :mod:`paramiko.SFTPClient`
154+ :param directory: Remote directory to create
155+ :type directory: str
156+
157+ Catches and logs at error level remote IOErrors on creating directory."""
133158 try :
134159 sftp .mkdir (directory )
135160 except IOError , error :
136161 logger .error ("Error occured creating directory on %s - %s" ,
137162 self .host , error )
138163
139164 def copy_file (self , local_file , remote_file ):
140- """Copy local file to host via SFTP
165+ """Copy local file to host via SFTP/SCP
166+
167+ Copy is done natively using SFTP/SCP version 2 protocol, no scp command \
168+ is used or required.
169+
170+ :param local_file: Local filepath to copy to remote host
171+ :type local_file: str
172+ :param remote_file: Remote filepath on remote host to copy file to
173+ :type remote_file: str
141174 """
142175 sftp = self ._make_sftp ()
143176 destination = remote_file .split (os .path .sep )
144177 filename = destination [0 ] if len (destination ) == 1 else destination [- 1 ]
145178 remote_file = os .path .sep .join (destination )
146179 destination = destination [:- 1 ]
147- # import ipdb; ipdb.set_trace()
148180 for directory in destination :
149181 try :
150182 sftp .stat (directory )
@@ -160,29 +192,35 @@ def copy_file(self, local_file, remote_file):
160192 local_file , self .host , remote_file )
161193
162194class ParallelSSHClient (object ):
163- """Uses SSHClient, runs command on multiple hosts in parallel"""
195+ """
196+ Uses :mod:`pssh.SSHClient`, performs tasks over SSH on multiple hosts in \
197+ parallel"""
164198
165199 def __init__ (self , hosts ,
166200 user = None , password = None , port = None ,
167201 pool_size = 10 ):
168- """Connect to hosts
169-
202+ """
170203 :param hosts: Hosts to connect to
171204 :type hosts: list(str)
172205 :param pool_size: Pool size - how many commands to run in parallel
173206 :type pool_size: int
174207 :param user: (Optional) User to login as. Defaults to logged in user or\
175- user from ~/.ssh/config if set
208+ user from ~/.ssh/config or /etc/ssh/ssh_config if set
176209 :type user: str
177210 :param password: (Optional) Password to use for login. Defaults to\
178211 no password
179212 :type password: str
180-
213+ :param port: (Optional) Port number to use for SSH connection. Defaults\
214+ to None which uses SSH default
215+ :type port: int
216+ :param pool_size: (Optional) Greenlet pool size. Controls on how many\
217+ hosts to execute tasks in parallel. Defaults to 10
218+ :type pool_size: int
181219 :raises: paramiko.AuthenticationException on authentication error
182220 :raises: ssh_client.UnknownHostException on DNS resolution error
183221 :raises: ssh_client.ConnectionErrorException on error connecting
184-
185- Example:
222+
223+ ** Example**
186224
187225 >>> client = ParallelSSHClient(['myhost1', 'myhost2'])
188226 >>> cmds = client.exec_command('ls -ltrh /tmp/aasdfasdf', sudo = True)
@@ -191,6 +229,26 @@ def __init__(self, hosts,
191229 [myhost2] ls: cannot access /tmp/aasdfasdf: No such file or directory
192230 >>> print output
193231 [{'myhost1': {'exit_code': 2}}, {'myhost2': {'exit_code': 2}}]
232+
233+ .. note ::
234+
235+ **Connection persistence**
236+
237+ Connections to hosts will remain established for the duration of the
238+ object's life. To close them, just `del` or reuse the object reference.
239+
240+ >>> client = ParallelSSHClient(['localhost'])
241+ >>> cmds = client.exec_command('ls -ltrh /tmp/aasdfasdf')
242+ >>> cmds[0].join()
243+
244+ :netstat: ``tcp 0 0 127.0.0.1:53054 127.0.0.1:22 ESTABLISHED``
245+
246+ Connection remains active after commands have finished executing. Any \
247+ additional commands will use the same connection.
248+
249+ >>> del client
250+
251+ Connection is terminated.
194252 """
195253 self .pool = gevent .pool .Pool (size = pool_size )
196254 self .pool_size = pool_size
@@ -211,7 +269,7 @@ def exec_command(self, *args, **kwargs):
211269
212270 :rtype: List of :mod:`gevent.Greenlet`
213271
214- Example:
272+ ** Example** :
215273
216274 >>> cmds = client.exec_command('ls -ltrh')
217275
@@ -222,7 +280,13 @@ def exec_command(self, *args, **kwargs):
222280
223281 Alternatively/in addition print stdout for each command:
224282
225- >>> print [get_stdout(cmd) for cmd in cmds]"""
283+ >>> print [get_stdout(cmd) for cmd in cmds]
284+
285+ Retrieving stdout implies join, meaning get_stdout will wait
286+ for completion of all commands before returning output.
287+
288+ You may call get_stdout on already completed greenlets to re-get
289+ their output as many times as you want."""
226290 return [self .pool .spawn (self ._exec_command , host , * args , ** kwargs )
227291 for host in self .hosts ]
228292
@@ -235,7 +299,21 @@ def _exec_command(self, host, *args, **kwargs):
235299 return self .host_clients [host ].exec_command (* args , ** kwargs )
236300
237301 def get_stdout (self , greenlet ):
238- """Print stdout from greenlet and return exit code for host"""
302+ """Print stdout from greenlet and return exit code for host
303+ :param greenlet: Greenlet object containing an \
304+ SSH channel reference, hostname, stdout and stderr buffers
305+ :type greenlet: :mod:`gevent.Greenlet`
306+
307+ :mod:`pssh.get_stdout` will close the open SSH channel but this does **not**
308+ close the established connection to the remote host, only the
309+ authenticated SSH channel within it. This is standard practise
310+ in SSH when a command has finished executing. A new command
311+ will open a new channel which is very fast on already established
312+ connections.
313+
314+ :rtype: Dictionary containing {host: {'exit_code': exit code}} entry \
315+ for example {'myhost1': {'exit_code': 0}}
316+ """
239317 channel , host , stdout , stderr = greenlet .get ()
240318 for line in stdout :
241319 host_logger .info ("[%s]\t %s" , host , line .strip (),)
@@ -245,7 +323,25 @@ def get_stdout(self, greenlet):
245323 return {host : {'exit_code' : channel .recv_exit_status ()}}
246324
247325 def copy_file (self , local_file , remote_file ):
248- """Copy local file to remote file in parallel"""
326+ """Copy local file to remote file in parallel
327+
328+ :param local_file: Local filepath to copy to remote host
329+ :type local_file: str
330+ :param remote_file: Remote filepath on remote host to copy file to
331+ :type remote_file: str
332+
333+ .. note ::
334+ Remote directories in `remote_file` that do not exist will be
335+ created as long as permissions allow.
336+
337+ .. note ::
338+ Path separation is handled client side so it is possible to copy
339+ to/from hosts with differing path separators, like from/to Linux
340+ and Windows.
341+
342+ :rtype: List(:mod:`gevent.Greenlet`) of greenlets for remote copy \
343+ commands
344+ """
249345 return [self .pool .spawn (self ._copy_file , host , local_file , remote_file )
250346 for host in self .hosts ]
251347
0 commit comments