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