1""" 2File transfer via SFTP and/or SCP. 3""" 4 5import os 6import posixpath 7import stat 8 9from .util import debug # TODO: actual logging! LOL 10 11# TODO: figure out best way to direct folks seeking rsync, to patchwork's rsync 12# call (which needs updating to use invoke.run() & fab 2 connection methods, 13# but is otherwise suitable). 14# UNLESS we want to try and shoehorn it into this module after all? Delegate 15# any recursive get/put to it? Requires users to have rsync available of 16# course. 17 18 19class Transfer(object): 20 """ 21 `.Connection`-wrapping class responsible for managing file upload/download. 22 23 .. versionadded:: 2.0 24 """ 25 26 # TODO: SFTP clear default, but how to do SCP? subclass? init kwarg? 27 28 def __init__(self, connection): 29 self.connection = connection 30 31 @property 32 def sftp(self): 33 return self.connection.sftp() 34 35 def is_remote_dir(self, path): 36 try: 37 return stat.S_ISDIR(self.sftp.stat(path).st_mode) 38 except IOError: 39 return False 40 41 def get(self, remote, local=None, preserve_mode=True): 42 """ 43 Download a file from the current connection to the local filesystem. 44 45 :param str remote: 46 Remote file to download. 47 48 May be absolute, or relative to the remote working directory. 49 50 .. note:: 51 Most SFTP servers set the remote working directory to the 52 connecting user's home directory, and (unlike most shells) do 53 *not* expand tildes (``~``). 54 55 For example, instead of saying ``get("~/tmp/archive.tgz")``, 56 say ``get("tmp/archive.tgz")``. 57 58 :param local: 59 Local path to store downloaded file in, or a file-like object. 60 61 **If None or another 'falsey'/empty value is given** (the default), 62 the remote file is downloaded to the current working directory (as 63 seen by `os.getcwd`) using its remote filename. 64 65 **If a string is given**, it should be a path to a local directory 66 or file and is subject to similar behavior as that seen by common 67 Unix utilities or OpenSSH's ``sftp`` or ``scp`` tools. 68 69 For example, if the local path is a directory, the remote path's 70 base filename will be added onto it (so ``get('foo/bar/file.txt', 71 '/tmp/')`` would result in creation or overwriting of 72 ``/tmp/file.txt``). 73 74 .. note:: 75 When dealing with nonexistent file paths, normal Python file 76 handling concerns come into play - for example, a ``local`` 77 path containing non-leaf directories which do not exist, will 78 typically result in an `OSError`. 79 80 **If a file-like object is given**, the contents of the remote file 81 are simply written into it. 82 83 :param bool preserve_mode: 84 Whether to `os.chmod` the local file so it matches the remote 85 file's mode (default: ``True``). 86 87 :returns: A `.Result` object. 88 89 .. versionadded:: 2.0 90 """ 91 # TODO: how does this API change if we want to implement 92 # remote-to-remote file transfer? (Is that even realistic?) 93 # TODO: handle v1's string interpolation bits, especially the default 94 # one, or at least think about how that would work re: split between 95 # single and multiple server targets. 96 # TODO: callback support 97 # TODO: how best to allow changing the behavior/semantics of 98 # remote/local (e.g. users might want 'safer' behavior that complains 99 # instead of overwriting existing files) - this likely ties into the 100 # "how to handle recursive/rsync" and "how to handle scp" questions 101 102 # Massage remote path 103 if not remote: 104 raise ValueError("Remote path must not be empty!") 105 orig_remote = remote 106 remote = posixpath.join( 107 self.sftp.getcwd() or self.sftp.normalize("."), remote 108 ) 109 110 # Massage local path: 111 # - handle file-ness 112 # - if path, fill with remote name if empty, & make absolute 113 orig_local = local 114 is_file_like = hasattr(local, "write") and callable(local.write) 115 if not local: 116 local = posixpath.basename(remote) 117 if not is_file_like: 118 local = os.path.abspath(local) 119 120 # Run Paramiko-level .get() (side-effects only. womp.) 121 # TODO: push some of the path handling into Paramiko; it should be 122 # responsible for dealing with path cleaning etc. 123 # TODO: probably preserve warning message from v1 when overwriting 124 # existing files. Use logging for that obviously. 125 # 126 # If local appears to be a file-like object, use sftp.getfo, not get 127 if is_file_like: 128 self.sftp.getfo(remotepath=remote, fl=local) 129 else: 130 self.sftp.get(remotepath=remote, localpath=local) 131 # Set mode to same as remote end 132 # TODO: Push this down into SFTPClient sometime (requires backwards 133 # incompat release.) 134 if preserve_mode: 135 remote_mode = self.sftp.stat(remote).st_mode 136 mode = stat.S_IMODE(remote_mode) 137 os.chmod(local, mode) 138 # Return something useful 139 return Result( 140 orig_remote=orig_remote, 141 remote=remote, 142 orig_local=orig_local, 143 local=local, 144 connection=self.connection, 145 ) 146 147 def put(self, local, remote=None, preserve_mode=True): 148 """ 149 Upload a file from the local filesystem to the current connection. 150 151 :param local: 152 Local path of file to upload, or a file-like object. 153 154 **If a string is given**, it should be a path to a local (regular) 155 file (not a directory). 156 157 .. note:: 158 When dealing with nonexistent file paths, normal Python file 159 handling concerns come into play - for example, trying to 160 upload a nonexistent ``local`` path will typically result in an 161 `OSError`. 162 163 **If a file-like object is given**, its contents are written to the 164 remote file path. 165 166 :param str remote: 167 Remote path to which the local file will be written. 168 169 .. note:: 170 Most SFTP servers set the remote working directory to the 171 connecting user's home directory, and (unlike most shells) do 172 *not* expand tildes (``~``). 173 174 For example, instead of saying ``put("archive.tgz", 175 "~/tmp/")``, say ``put("archive.tgz", "tmp/")``. 176 177 In addition, this means that 'falsey'/empty values (such as the 178 default value, ``None``) are allowed and result in uploading to 179 the remote home directory. 180 181 .. note:: 182 When ``local`` is a file-like object, ``remote`` is required 183 and must refer to a valid file path (not a directory). 184 185 :param bool preserve_mode: 186 Whether to ``chmod`` the remote file so it matches the local file's 187 mode (default: ``True``). 188 189 :returns: A `.Result` object. 190 191 .. versionadded:: 2.0 192 """ 193 if not local: 194 raise ValueError("Local path must not be empty!") 195 196 is_file_like = hasattr(local, "write") and callable(local.write) 197 198 # Massage remote path 199 orig_remote = remote 200 if is_file_like: 201 local_base = getattr(local, "name", None) 202 else: 203 local_base = os.path.basename(local) 204 if not remote: 205 if is_file_like: 206 raise ValueError( 207 "Must give non-empty remote path when local is a file-like object!" # noqa 208 ) 209 else: 210 remote = local_base 211 debug("Massaged empty remote path into {!r}".format(remote)) 212 elif self.is_remote_dir(remote): 213 # non-empty local_base implies a) text file path or b) FLO which 214 # had a non-empty .name attribute. huzzah! 215 if local_base: 216 remote = posixpath.join(remote, local_base) 217 else: 218 if is_file_like: 219 raise ValueError( 220 "Can't put a file-like-object into a directory unless it has a non-empty .name attribute!" # noqa 221 ) 222 else: 223 # TODO: can we ever really end up here? implies we want to 224 # reorganize all this logic so it has fewer potential holes 225 raise ValueError( 226 "Somehow got an empty local file basename ({!r}) when uploading to a directory ({!r})!".format( # noqa 227 local_base, remote 228 ) 229 ) 230 231 prejoined_remote = remote 232 remote = posixpath.join( 233 self.sftp.getcwd() or self.sftp.normalize("."), remote 234 ) 235 if remote != prejoined_remote: 236 msg = "Massaged relative remote path {!r} into {!r}" 237 debug(msg.format(prejoined_remote, remote)) 238 239 # Massage local path 240 orig_local = local 241 if not is_file_like: 242 local = os.path.abspath(local) 243 if local != orig_local: 244 debug( 245 "Massaged relative local path {!r} into {!r}".format( 246 orig_local, local 247 ) 248 ) # noqa 249 250 # Run Paramiko-level .put() (side-effects only. womp.) 251 # TODO: push some of the path handling into Paramiko; it should be 252 # responsible for dealing with path cleaning etc. 253 # TODO: probably preserve warning message from v1 when overwriting 254 # existing files. Use logging for that obviously. 255 # 256 # If local appears to be a file-like object, use sftp.putfo, not put 257 if is_file_like: 258 msg = "Uploading file-like object {!r} to {!r}" 259 debug(msg.format(local, remote)) 260 pointer = local.tell() 261 try: 262 local.seek(0) 263 self.sftp.putfo(fl=local, remotepath=remote) 264 finally: 265 local.seek(pointer) 266 else: 267 debug("Uploading {!r} to {!r}".format(local, remote)) 268 self.sftp.put(localpath=local, remotepath=remote) 269 # Set mode to same as local end 270 # TODO: Push this down into SFTPClient sometime (requires backwards 271 # incompat release.) 272 if preserve_mode: 273 local_mode = os.stat(local).st_mode 274 mode = stat.S_IMODE(local_mode) 275 self.sftp.chmod(remote, mode) 276 # Return something useful 277 return Result( 278 orig_remote=orig_remote, 279 remote=remote, 280 orig_local=orig_local, 281 local=local, 282 connection=self.connection, 283 ) 284 285 286class Result(object): 287 """ 288 A container for information about the result of a file transfer. 289 290 See individual attribute/method documentation below for details. 291 292 .. note:: 293 Unlike similar classes such as `invoke.runners.Result` or 294 `fabric.runners.Result` (which have a concept of "warn and return 295 anyways on failure") this class has no useful truthiness behavior. If a 296 file transfer fails, some exception will be raised, either an `OSError` 297 or an error from within Paramiko. 298 299 .. versionadded:: 2.0 300 """ 301 302 # TODO: how does this differ from put vs get? field stating which? (feels 303 # meh) distinct classes differing, for now, solely by name? (also meh) 304 def __init__(self, local, orig_local, remote, orig_remote, connection): 305 #: The local path the file was saved as, or the object it was saved 306 #: into if a file-like object was given instead. 307 #: 308 #: If a string path, this value is massaged to be absolute; see 309 #: `.orig_local` for the original argument value. 310 self.local = local 311 #: The original value given as the returning method's ``local`` 312 #: argument. 313 self.orig_local = orig_local 314 #: The remote path downloaded from. Massaged to be absolute; see 315 #: `.orig_remote` for the original argument value. 316 self.remote = remote 317 #: The original argument value given as the returning method's 318 #: ``remote`` argument. 319 self.orig_remote = orig_remote 320 #: The `.Connection` object this result was obtained from. 321 self.connection = connection 322 323 # TODO: ensure str/repr makes it easily differentiable from run() or 324 # local() result objects (and vice versa). 325