# Copyright (C) 2002-2022, Stefan Schwarzer # and ftputil contributors (see `doc/contributors.txt`) # See the file LICENSE for licensing terms. """ `FTPHost` is the central class of the `ftputil` library. See `__init__.py` for an example. """ import datetime import errno import ftplib import stat import sys import time import ftputil.error import ftputil.file import ftputil.file_transfer import ftputil.path import ftputil.path_encoding import ftputil.session import ftputil.stat import ftputil.tool __all__ = ["FTPHost"] # The "protected" attributes PyLint talks about aren't intended for clients of # the library. `FTPHost` objects need to use some of these library-internal # attributes though. # pylint: disable=protected-access # For Python versions 3.8 and below, ftputil has implicitly defaulted to # latin-1 encoding. Prefer that behavior for Python 3.9 and up as well instead # of using the encoding that is the default for `ftplib.FTP` in the Python # version. if ftputil.path_encoding.RUNNING_UNDER_PY39_AND_UP: class default_session_factory(ftplib.FTP): def __init__(self, *args, **kwargs): # Python 3.9 defines `encoding` as a keyword-only argument, so test # only for the `encoding` argument in `kwargs`. # # Only use the ftputil default encoding if the caller didn't pass # an encoding. if "encoding" not in kwargs: kwargs["encoding"] = ftputil.path_encoding.DEFAULT_ENCODING super().__init__(*args, **kwargs) else: default_session_factory = ftplib.FTP ##################################################################### # `FTPHost` class with several methods similar to those of `os` class FTPHost: """ FTP host class. """ # Implementation notes: # # Upon every request of a file (`FTPFile` object) a new FTP session is # created (or reused from a cache), leading to a child session of the # `FTPHost` object from which the file is requested. # # This is needed because opening an `FTPFile` will make the local session # object wait for the completion of the transfer. In fact, code like this # would block indefinitely, if the `RETR` request would be made on the # `_session` of the object host: # # host = FTPHost(ftp_server, user, password) # f = host.open("index.html") # host.getcwd() # would block! # # On the other hand, the initially constructed host object will store # references to already established `FTPFile` objects and reuse an # associated connection if its associated `FTPFile` has been closed. def __init__(self, *args, **kwargs): """ Abstract initialization of `FTPHost` object. """ # Store arguments for later operations. self._args = args self._kwargs = kwargs # XXX: Maybe put the following in a `reset` method. # The time shift setting shouldn't be reset though. Make a session # according to these arguments. self._session = self._make_session() # Simulate `os.path`. self.path = ftputil.path._Path(self) # lstat, stat, listdir services. self._stat = ftputil.stat._Stat(self) self.stat_cache = self._stat._lstat_cache self.stat_cache.enable() with ftputil.error.ftplib_error_to_ftp_os_error: current_dir = self._session.pwd() self._cached_current_dir = self.path.normpath( ftputil.tool.as_str_path(current_dir, encoding=self._encoding) ) # Associated `FTPHost` objects for data transfer. self._children = [] # This is only set to something else than `None` if this instance # represents an `FTPFile`. self._file = None # Now opened. self.closed = False # Set curdir, pardir etc. for the remote host. RFC 959 states that this # is, strictly speaking, dependent on the server OS but it seems to # work at least with Unix and Windows servers. self.curdir, self.pardir, self.sep = ".", "..", "/" # Set default time shift (used in `upload_if_newer` and # `download_if_newer`). self._time_shift = 0.0 # Don't use `LIST -a` option by default. If the server doesn't # understand the `-a` option and interprets it as a path, the results # can be surprising. See ticket #110. self.use_list_a_option = False def keep_alive(self): """ Try to keep the connection alive in order to avoid server timeouts. Note that this won't help if the connection has already timed out! In this case, `keep_alive` will raise an `TemporaryError`. (Actually, if you get a server timeout, the error - for a specific connection - will be permanent.) """ # Warning: Don't call this method on `FTPHost` instances which # represent file transfers. This may fail in confusing ways. with ftputil.error.ftplib_error_to_ftp_os_error: # Ignore return value. self._session.pwd() # # Dealing with child sessions and file-like objects (rather low-level) # def _make_session(self): """ Return a new session object according to the current state of this `FTPHost` instance. """ # Don't modify original attributes below. args = self._args[:] kwargs = self._kwargs.copy() # If a session factory has been given on the instantiation of this # `FTPHost` object, use the same factory for this `FTPHost` object's # child sessions. factory = kwargs.pop("session_factory", default_session_factory) with ftputil.error.ftplib_error_to_ftp_os_error: session = factory(*args, **kwargs) if not hasattr(session, "encoding"): raise ftputil.error.NoEncodingError( f"session instance {session!r} must have an `encoding` attribute" ) self._encoding = session.encoding return session def _copy(self): """ Return a copy of this `FTPHost` object. """ # The copy includes a new session factory return value (aka session) # but doesn't copy the state of `self.getcwd()`. return self.__class__(*self._args, **self._kwargs) def _available_child(self): """ Return an available (i. e. one whose `_file` object is closed and doesn't have a timed-out server connection) child (`FTPHost` object) from the pool of children or `None` if there aren't any. """ # TODO: Currently timed-out child sessions aren't removed and may # collect over time. In very busy or long running processes, this might # slow down an application because the same stale child sessions have # to be processed again and again. for host in self._children: # Test for timeouts only after testing for a closed file: # - If a file isn't closed, save time; don't bother to access the # remote server. # - If a file transfer on the child is in progress, requesting the # directory is an invalid operation because of the way the FTP # state machine works (see RFC 959). if host._file.closed: try: host._session.pwd() # Under high load, a 226 status response from a previous # download may arrive too late, so that it's "seen" in the # `pwd` call. For now, skip the potential child session; it # will be considered again when `_available_child` is called # the next time. except ftplib.error_reply: continue # Timed-out sessions raise `error_temp`. except ftplib.error_temp: continue # The server may have closed the connection which may cause # `host._session.getline` to raise an `EOFError` (see ticket # #114). except EOFError: continue # Under high load, there may be a socket read timeout during # the last FTP file `close` (see ticket #112). Note that a # socket timeout is quite different from an FTP session # timeout. except OSError: continue else: # Everything's ok; use this `FTPHost` instance. return host # Be explicit. return None def open( self, path, mode="r", buffering=None, encoding=None, errors=None, newline=None, *, rest=None, ): """ Return an open file(-like) object which is associated with this `FTPHost` object. The arguments `path`, `mode`, `buffering`, `encoding`, `errors` and `newlines` have the same meaning as for `open`. If `rest` is given as an integer, - reading will start at the byte (zero-based) `rest` - writing will overwrite the remote file from byte `rest` This method tries to reuse a child but will generate a new one if none is available. """ # Support the same arguments as `open`. # pylint: disable=too-many-arguments path = ftputil.tool.as_str_path(path, encoding=self._encoding) host = self._available_child() if host is None: host = self._copy() self._children.append(host) host._file = ftputil.file.FTPFile(host) basedir = self.getcwd() # Prepare for changing the directory (see whitespace workaround in # method `_dir`). if host.path.isabs(path): effective_path = path else: effective_path = host.path.join(basedir, path) effective_dir, effective_file = host.path.split(effective_path) try: # This will fail if the directory isn't accessible at all. host.chdir(effective_dir) except ftputil.error.PermanentError: # Similarly to a failed `file` in a local file system, raise an # `IOError`, not an `OSError`. raise ftputil.error.FTPIOError( "remote directory '{}' doesn't " "exist or has insufficient access rights".format(effective_dir) ) host._file._open( effective_file, mode=mode, buffering=buffering, encoding=encoding, errors=errors, newline=newline, rest=rest, ) if "w" in mode: # Invalidate cache entry because size and timestamps will change. self.stat_cache.invalidate(effective_path) return host._file def close(self): """ Close host connection. """ if self.closed: return # Close associated children. for host in self._children: # Children have a `_file` attribute which is an `FTPFile` object. host._file.close() host.close() # Now deal with ourself. try: with ftputil.error.ftplib_error_to_ftp_os_error: self._session.close() finally: # If something went wrong before, the host/session is probably # defunct and subsequent calls to `close` won't help either, so # consider the host/session closed for practical purposes. self.stat_cache.clear() self._children = [] self.closed = True # # Setting a custom directory parser # def set_parser(self, parser): """ Set the parser for extracting stat results from directory listings. The parser interface is described in the documentation, but here are the most important things: - A parser should derive from `ftputil.stat.Parser`. - The parser has to implement two methods, `parse_line` and `ignores_line`. For the latter, there's a probably useful default in the class `ftputil.stat.Parser`. - `parse_line` should try to parse a line of a directory listing and return a `ftputil.stat.StatResult` instance. If parsing isn't possible, raise `ftputil.error.ParserError` with a useful error message. - `ignores_line` should return a true value if the line isn't assumed to contain stat information. """ # The cache contents, if any, probably aren't useful. self.stat_cache.clear() # Set the parser explicitly, don't allow "smart" switching anymore. self._stat._parser = parser self._stat._allow_parser_switching = False # # Time shift adjustment between client (i. e. us) and server # @staticmethod def __rounded_time_shift(time_shift): """ Return the given time shift in seconds, but rounded to 15-minute units. The argument is also assumed to be given in seconds. """ minute = 60.0 # Avoid division by zero below. if time_shift == 0: return 0.0 # Use a positive value for rounding. absolute_time_shift = abs(time_shift) signum = time_shift / absolute_time_shift # Round absolute time shift to 15-minute units. absolute_rounded_time_shift = int( (absolute_time_shift + (7.5 * minute)) / (15.0 * minute) ) * (15.0 * minute) # Return with correct sign. return signum * absolute_rounded_time_shift def __assert_valid_time_shift(self, time_shift): """ Perform sanity checks on the time shift value (given in seconds). If the value is invalid, raise a `TimeShiftError`, else simply return `None`. """ minute = 60.0 # seconds hour = 60.0 * minute absolute_rounded_time_shift = abs(self.__rounded_time_shift(time_shift)) # Test 1: Fail if the absolute time shift is greater than a full day # (24 hours). if absolute_rounded_time_shift > 24 * hour: raise ftputil.error.TimeShiftError( "time shift abs({0:.2f} s) > 1 day".format(time_shift) ) # Test 2: Fail if the deviation between given time shift and 15-minute # units is greater than a certain limit. maximum_deviation = 5 * minute if abs(time_shift - self.__rounded_time_shift(time_shift)) > maximum_deviation: raise ftputil.error.TimeShiftError( "time shift ({0:.2f} s) deviates more than {1:d} s " "from 15-minute units".format(time_shift, int(maximum_deviation)) ) def set_time_shift(self, time_shift): """ Set the time shift value. By (my) definition, the time shift value is the difference of the time zone used in server listings and UTC, i. e. time_shift =def= t_server - utc <=> t_server = utc + time_shift <=> utc = t_server - time_shift The time shift is measured in seconds. """ self.__assert_valid_time_shift(time_shift) old_time_shift = self.time_shift() if time_shift != old_time_shift: # If the time shift changed, all entries in the cache will have # wrong times with respect to the updated time shift, therefore # clear the cache. self.stat_cache.clear() self._time_shift = self.__rounded_time_shift(time_shift) def time_shift(self): """ Return the time shift between FTP server and client. See the docstring of `set_time_shift` for more on this value. """ return self._time_shift def synchronize_times(self): """ Synchronize the local times of FTP client and server. This is necessary to let `upload_if_newer` and `download_if_newer` work correctly. If `synchronize_times` isn't applicable (see below), the time shift can still be set explicitly with `set_time_shift`. This implementation of `synchronize_times` requires _all_ of the following: - The connection between server and client is established. - The client has write access to the directory that is current when `synchronize_times` is called. The common usage pattern of `synchronize_times` is to call it directly after the connection is established. (As can be concluded from the points above, this requires write access to the login directory.) If `synchronize_times` fails, it raises a `TimeShiftError`. """ helper_file_name = "_ftputil_sync_" # Open a dummy file for writing in the current directory on the FTP # host, then close it. try: # May raise `FTPIOError` if directory isn't writable. file_ = self.open(helper_file_name, "w") file_.close() except ftputil.error.FTPIOError: raise ftputil.error.TimeShiftError( """couldn't write helper file in directory '{}'""".format(self.getcwd()) ) # If everything worked up to here it should be possible to stat and # then remove the just-written file. try: server_time = self.path.getmtime(helper_file_name) self.unlink(helper_file_name) except ftputil.error.FTPOSError: # If we got a `TimeShiftError` exception above, we should't come # here: if we didn't get a `TimeShiftError` above, deletion should # be possible. The only reason for an exception I can think of here # is a race condition by removing the helper file or write # permission from the directory or helper file after it has been # written to. raise ftputil.error.TimeShiftError( "could write helper file but not unlink it" ) # Calculate the difference between server and client. now = time.time() time_shift = server_time - now # As the time shift for this host instance isn't set yet, the directory # parser will calculate times one year in the past if the time zone of # the server is east from ours. Thus the time shift will be off by a # year as well (see ticket #55). if time_shift < -360 * 24 * 60 * 60: # Read one year and recalculate the time shift. We don't know how # many days made up that year (it might have been a leap year), so # go the route via `datetime.replace`. server_datetime = datetime.datetime.fromtimestamp( server_time, tz=datetime.timezone.utc ) server_datetime = server_datetime.replace(year=server_datetime.year + 1) time_shift = server_datetime.timestamp() - now self.set_time_shift(time_shift) # # Operations based on file-like objects (rather high-level), like upload # and download # # XXX: This has a different API from `shutil.copyfileobj`, on which this # method is modeled. But I don't think it makes sense to change this method # here because the method is probably rarely used and a change would break # client code. @staticmethod def copyfileobj( source, target, max_chunk_size=ftputil.file_transfer.MAX_COPY_CHUNK_SIZE, callback=None, ): """ Copy data from file-like object `source` to file-like object `target`. """ ftputil.file_transfer.copyfileobj(source, target, max_chunk_size, callback) def _upload_files(self, source_path, target_path): """ Return a `LocalFile` and `RemoteFile` as source and target, respectively. The strings `source_path` and `target_path` are the (absolute or relative) paths of the local and the remote file, respectively. """ source_file = ftputil.file_transfer.LocalFile(source_path, "rb") # Passing `self` (the `FTPHost` instance) here is correct. target_file = ftputil.file_transfer.RemoteFile(self, target_path, "wb") return source_file, target_file def upload(self, source, target, callback=None): """ Upload a file from the local source (name) to the remote target (name). If a callable `callback` is given, it's called after every chunk of transferred data. The chunk size is a constant defined in `file_transfer`. The callback will be called with a single argument, the data chunk that was transferred before the callback was called. """ if source in ["", b""]: raise IOError("path argument `source` is empty") ftputil.tool.raise_for_empty_path(target, path_argument_name="target") target = ftputil.tool.as_str_path(target, encoding=self._encoding) source_file, target_file = self._upload_files(source, target) ftputil.file_transfer.copy_file( source_file, target_file, conditional=False, callback=callback ) def upload_if_newer(self, source, target, callback=None): """ Upload a file only if it's newer than the target on the remote host or if the target file does not exist. See the method `upload` for the meaning of the parameters. If an upload was necessary, return `True`, else return `False`. If a callable `callback` is given, it's called after every chunk of transferred data. The chunk size is a constant defined in `file_transfer`. The callback will be called with a single argument, the data chunk that was transferred before the callback was called. """ ftputil.tool.raise_for_empty_path(source, path_argument_name="source") if target in ["", b""]: raise IOError("path argument `target` is empty") target = ftputil.tool.as_str_path(target, encoding=self._encoding) source_file, target_file = self._upload_files(source, target) return ftputil.file_transfer.copy_file( source_file, target_file, conditional=True, callback=callback ) def _download_files(self, source_path, target_path): """ Return a `RemoteFile` and `LocalFile` as source and target, respectively. The strings `source_path` and `target_path` are the (absolute or relative) paths of the remote and the local file, respectively. """ source_file = ftputil.file_transfer.RemoteFile(self, source_path, "rb") target_file = ftputil.file_transfer.LocalFile(target_path, "wb") return source_file, target_file def download(self, source, target, callback=None): """ Download a file from the remote source (name) to the local target (name). If a callable `callback` is given, it's called after every chunk of transferred data. The chunk size is a constant defined in `file_transfer`. The callback will be called with a single argument, the data chunk that was transferred before the callback was called. """ ftputil.tool.raise_for_empty_path(source, path_argument_name="source") if target in ["", b""]: raise IOError("path argument `target` is empty") source = ftputil.tool.as_str_path(source, encoding=self._encoding) source_file, target_file = self._download_files(source, target) ftputil.file_transfer.copy_file( source_file, target_file, conditional=False, callback=callback ) def download_if_newer(self, source, target, callback=None): """ Download a file only if it's newer than the target on the local host or if the target file does not exist. See the method `download` for the meaning of the parameters. If a download was necessary, return `True`, else return `False`. If a callable `callback` is given, it's called after every chunk of transferred data. The chunk size is a constant defined in `file_transfer`. The callback will be called with a single argument, the data chunk that was transferred before the callback was called. """ if source in ["", b""]: raise IOError("path argument `source` is empty") ftputil.tool.raise_for_empty_path(target, path_argument_name="target") source = ftputil.tool.as_str_path(source, encoding=self._encoding) source_file, target_file = self._download_files(source, target) return ftputil.file_transfer.copy_file( source_file, target_file, conditional=True, callback=callback ) # # Helper methods to descend into a directory before executing a command # def _check_inaccessible_login_directory(self): """ Raise an `InaccessibleLoginDirError` exception if we can't change to the login directory. This test is only reliable if the current directory is the login directory. """ presumable_login_dir = self.getcwd() # Bail out with an internal error rather than modify the current # directory without hope of restoration. try: self.chdir(presumable_login_dir) except ftputil.error.PermanentError: raise ftputil.error.InaccessibleLoginDirError( "directory '{}' is not accessible".format(presumable_login_dir) ) def _robust_ftp_command(self, command, path, descend_deeply=False): """ Run an FTP command on a path. The return value of the method is the return value of the command. If `descend_deeply` is true (the default is false), descend deeply, i. e. change the directory to the end of the path. """ # If we can't change to the yet-current directory, the code below won't # work (see below), so in this case rather raise an exception than # giving wrong results. self._check_inaccessible_login_directory() # Some FTP servers don't behave as expected if the directory portion of # the path contains whitespace; some even yield strange results if the # command isn't executed in the current directory. Therefore, change to # the directory which contains the item to run the command on and # invoke the command just there. # # Remember old working directory. old_dir = self.getcwd() try: if descend_deeply: # Invoke the command in (not: on) the deepest directory. self.chdir(path) # Workaround for some servers that give recursive listings when # called with a dot as path; see issue #33, # http://ftputil.sschwarzer.net/trac/ticket/33 return command(self, "") else: # Invoke the command in the "next to last" directory. head, tail = self.path.split(path) self.chdir(head) return command(self, tail) finally: self.chdir(old_dir) # # Miscellaneous utility methods resembling functions in `os` # def getcwd(self): """ Return the current directory path. """ return self._cached_current_dir def chdir(self, path): """ Change the directory on the host to `path`. """ path = ftputil.tool.as_str_path(path, encoding=self._encoding) with ftputil.error.ftplib_error_to_ftp_os_error: self._session.cwd(path) # The path given as the argument is relative to the old current # directory, therefore join them. self._cached_current_dir = self.path.normpath( self.path.join(self._cached_current_dir, path) ) # Ignore unused argument `mode` # pylint: disable=unused-argument def mkdir(self, path, mode=None): """ Make the directory path on the remote host. The argument `mode` is ignored and only "supported" for similarity with `os.mkdir`. """ ftputil.tool.raise_for_empty_path(path) path = ftputil.tool.as_str_path(path, encoding=self._encoding) def command(self, path): """Callback function.""" with ftputil.error.ftplib_error_to_ftp_os_error: self._session.mkd(path) self._robust_ftp_command(command, path) # TODO: The virtual directory support doesn't have unit tests yet because # the mocking most likely would be quite complicated. The tests should be # added when mainly the `mock` library is used instead of the mock code in # `test.mock_ftplib`. # # Ignore unused argument `mode` # pylint: disable=unused-argument def makedirs(self, path, mode=None, exist_ok=False): """ Make the directory `path`, but also make not yet existing intermediate directories, like `os.makedirs`. The value of `mode` is only accepted for compatibility with `os.makedirs` but otherwise ignored. If `exist_ok` is `False` (the default) and the leaf directory exists, raise a `PermanentError` with `errno` 17. """ ftputil.tool.raise_for_empty_path(path) path = ftputil.tool.as_str_path(path, encoding=self._encoding) path = self.path.abspath(path) directories = path.split(self.sep) old_dir = self.getcwd() try: # Try to build the directory chain from the "uppermost" to the # "lowermost" directory. for index in range(1, len(directories)): # Re-insert the separator which got lost by using `path.split`. next_directory = self.sep + self.path.join(*directories[: index + 1]) # If we have "virtual directories" (see #86), just listing the # parent directory won't tell us if a directory actually # exists. So try to change into the directory. try: self.chdir(next_directory) except ftputil.error.PermanentError: # Directory presumably doesn't exist. try: self.mkdir(next_directory) except ftputil.error.PermanentError: # Find out the cause of the error. Re-raise the # exception only if the directory didn't exist already, # else something went _really_ wrong, e. g. there's a # regular file with the name of the directory. if not self.path.isdir(next_directory): raise else: # Directory exists. If we are at the last directory # component and `exist_ok` is `False`, this is an error. if (index == len(directories) - 1) and (not exist_ok): # Before PEP 3151, if `exist_ok` is `False`, trying to # create an existing directory in the local file system # results in an `OSError` with `errno.EEXIST, so # emulate this also for FTP. ftp_os_error = ftputil.error.PermanentError( "path {!r} exists".format(path) ) ftp_os_error.errno = errno.EEXIST raise ftp_os_error finally: self.chdir(old_dir) def rmdir(self, path): """ Remove the _empty_ directory `path` on the remote host. Compatibility note: Previous versions of ftputil could possibly delete non-empty directories as well, - if the server allowed it. This is no longer supported. """ ftputil.tool.raise_for_empty_path(path) path = ftputil.tool.as_str_path(path, encoding=self._encoding) path = self.path.abspath(path) if self.listdir(path): raise ftputil.error.PermanentError("directory '{}' not empty".format(path)) # XXX: How does `rmd` work with links? def command(self, path): """Callback function.""" with ftputil.error.ftplib_error_to_ftp_os_error: self._session.rmd(path) # Always invalidate the cache. If `_robust_ftp_command` raises an # exception, we can't tell for sure if the removal failed on the server # vs. it succeeded, but something went wrong after that. try: self._robust_ftp_command(command, path) finally: self.stat_cache.invalidate(path) def remove(self, path): """ Remove the file or link given by `path`. Raise a `PermanentError` if the path doesn't exist, but maybe raise other exceptions depending on the state of the server (e. g. timeout). """ ftputil.tool.raise_for_empty_path(path) path = ftputil.tool.as_str_path(path, encoding=self._encoding) path = self.path.abspath(path) # Though `isfile` includes also links to files, `islink` is needed to # include links to directories. if ( self.path.isfile(path) or self.path.islink(path) or not self.path.exists(path) ): # If the path doesn't exist, let the removal command trigger an # exception with a more appropriate error message. def command(self, path): """Callback function.""" with ftputil.error.ftplib_error_to_ftp_os_error: self._session.delete(path) # Always invalidate the cache. If `_robust_ftp_command` raises an # exception, we can't tell for sure if the removal failed on the # server vs. it succeeded, but something went wrong after that. try: self._robust_ftp_command(command, path) finally: self.stat_cache.invalidate(path) else: raise ftputil.error.PermanentError( "remove/unlink can only delete files and links, " "not directories" ) unlink = remove def rmtree(self, path, ignore_errors=False, onerror=None): """ Remove the given remote, possibly non-empty, directory tree. The interface of this method is rather complex, in favor of compatibility with `shutil.rmtree`. If `ignore_errors` is set to a true value, errors are ignored. If `ignore_errors` is a false value _and_ `onerror` isn't set, all exceptions occurring during the tree iteration and processing are raised. These exceptions are all of type `PermanentError`. To distinguish between error situations, pass in a callable for `onerror`. This callable must accept three arguments: `func`, `path` and `exc_info`. `func` is a bound method object, _for example_ `your_host_object.listdir`. `path` is the path that was the recent argument of the respective method (`listdir`, `remove`, `rmdir`). `exc_info` is the exception info as it's got from `sys.exc_info`. Implementation note: The code is copied from `shutil.rmtree` in Python 2.4 and adapted to ftputil. """ ftputil.tool.raise_for_empty_path(path) path = ftputil.tool.as_str_path(path, encoding=self._encoding) # The following code is an adapted version of Python 2.4's # `shutil.rmtree` function. if ignore_errors: def new_onerror(*args): """Do nothing.""" # pylint: disable=unused-argument pass elif onerror is None: def new_onerror(*args): """Re-raise exception.""" # pylint: disable=misplaced-bare-raise, unused-argument raise else: new_onerror = onerror names = [] try: names = self.listdir(path) except ftputil.error.PermanentError: new_onerror(self.listdir, path, sys.exc_info()) for name in names: full_name = self.path.join(path, name) try: mode = self.lstat(full_name).st_mode except ftputil.error.PermanentError: mode = 0 if stat.S_ISDIR(mode): self.rmtree(full_name, ignore_errors, new_onerror) else: try: self.remove(full_name) except ftputil.error.PermanentError: new_onerror(self.remove, full_name, sys.exc_info()) try: self.rmdir(path) except ftputil.error.FTPOSError: new_onerror(self.rmdir, path, sys.exc_info()) def rename(self, source, target): """ Rename the `source` on the FTP host to `target`. """ ftputil.tool.raise_for_empty_path(source, path_argument_name="source") ftputil.tool.raise_for_empty_path(target, path_argument_name="target") source = ftputil.tool.as_str_path(source, encoding=self._encoding) target = ftputil.tool.as_str_path(target, encoding=self._encoding) source_head, source_tail = self.path.split(source) target_head, target_tail = self.path.split(target) # Avoid code duplication below. # # Use `source_arg` and `target_arg` instead of `source` and `target` to # make it clearer that we use the arguments passed to # `rename_with_cleanup`, not any variables from the scope outside # `rename_with_cleanup`. def rename_with_cleanup(source_arg, target_arg): try: with ftputil.error.ftplib_error_to_ftp_os_error: self._session.rename(source_arg, target_arg) # Always invalidate the cache entries in case the rename succeeds # on the server, but the server doesn't manage to tell the client. finally: source_absolute_path = self.path.abspath(source_arg) target_absolute_path = self.path.abspath(target_arg) self.stat_cache.invalidate(source_absolute_path) self.stat_cache.invalidate(target_absolute_path) # The following code is in spirit similar to the code in the method # `_robust_ftp_command`, though we do _not_ do _everything_ imaginable. self._check_inaccessible_login_directory() paths_contain_whitespace = (" " in source_head) or (" " in target_head) if paths_contain_whitespace and source_head == target_head: # Both items are in the same directory. old_dir = self.getcwd() try: self.chdir(source_head) rename_with_cleanup(source_tail, target_tail) finally: self.chdir(old_dir) else: # Use straightforward command. rename_with_cleanup(source, target) # XXX: One could argue to put this method into the `_Stat` class, but I # refrained from that because then `_Stat` would have to know about # `FTPHost`'s `_session` attribute and in turn about `_session`'s `dir` # method. def _dir(self, path): """ Return a directory listing as made by FTP's `LIST` command as a list of strings. """ # Don't use `self.path.isdir` in this method because that would cause a # call of `(l)stat` and thus a call to `_dir`, so we would end up with # an infinite recursion. def _FTPHost_dir_command(self, path): """Callback function.""" lines = [] def callback(line): """Callback function.""" lines.append(ftputil.tool.as_str(line, encoding=self._encoding)) with ftputil.error.ftplib_error_to_ftp_os_error: if self.use_list_a_option: self._session.dir("-a", path, callback) else: self._session.dir(path, callback) return lines lines = self._robust_ftp_command( _FTPHost_dir_command, path, descend_deeply=True ) return lines # The `listdir`, `lstat` and `stat` methods don't use `_robust_ftp_command` # because they implicitly already use `_dir` which actually uses # `_robust_ftp_command`. def listdir(self, path): """ Return a list of directories, files etc. in the directory named `path`. If the directory listing from the server can't be parsed with any of the available parsers raise a `ParserError`. """ ftputil.tool.raise_for_empty_path(path) original_path = path path = ftputil.tool.as_str_path(path, encoding=self._encoding) items = self._stat._listdir(path) return [ ftputil.tool.same_string_type_as(original_path, item, self._encoding) for item in items ] def lstat(self, path, _exception_for_missing_path=True): """ Return an object similar to that returned by `os.lstat`. If the directory listing from the server can't be parsed with any of the available parsers, raise a `ParserError`. If the directory _can_ be parsed and the `path` is _not_ found, raise a `PermanentError`. (`_exception_for_missing_path` is an implementation aid and _not_ intended for use by ftputil clients.) """ ftputil.tool.raise_for_empty_path(path) path = ftputil.tool.as_str_path(path, encoding=self._encoding) return self._stat._lstat(path, _exception_for_missing_path) def stat(self, path, _exception_for_missing_path=True): """ Return info from a "stat" call on `path`. If the directory containing `path` can't be parsed, raise a `ParserError`. If the directory containing `path` can be parsed but the `path` can't be found, raise a `PermanentError`. Also raise a `PermanentError` if there's an endless (cyclic) chain of symbolic links "behind" the `path`. (`_exception_for_missing_path` is an implementation aid and _not_ intended for use by ftputil clients.) """ ftputil.tool.raise_for_empty_path(path) path = ftputil.tool.as_str_path(path, encoding=self._encoding) return self._stat._stat(path, _exception_for_missing_path) def walk(self, top, topdown=True, onerror=None, followlinks=False): """ Iterate over directory tree and return a tuple (dirpath, dirnames, filenames) on each iteration, like the `os.walk` function (see https://docs.python.org/library/os.html#os.walk ). """ ftputil.tool.raise_for_empty_path(top, path_argument_name="top") top = ftputil.tool.as_str_path(top, encoding=self._encoding) # The following code is copied from `os.walk` in Python 2.4 and adapted # to ftputil. try: names = self.listdir(top) except ftputil.error.FTPOSError as err: if onerror is not None: onerror(err) return dirs, nondirs = [], [] for name in names: if self.path.isdir(self.path.join(top, name)): dirs.append(name) else: nondirs.append(name) if topdown: yield top, dirs, nondirs for name in dirs: path = self.path.join(top, name) if followlinks or not self.path.islink(path): yield from self.walk(path, topdown, onerror, followlinks) if not topdown: yield top, dirs, nondirs def chmod(self, path, mode): """ Change the mode of a remote `path` (a string) to the integer `mode`. This integer uses the same bits as the mode value returned by the `stat` and `lstat` commands. If something goes wrong, raise a `TemporaryError` or a `PermanentError`, according to the status code returned by the server. In particular, a non-existent path usually causes a `PermanentError`. """ ftputil.tool.raise_for_empty_path(path) path = ftputil.tool.as_str_path(path, encoding=self._encoding) path = self.path.abspath(path) def command(self, path): """Callback function.""" with ftputil.error.ftplib_error_to_ftp_os_error: self._session.voidcmd("SITE CHMOD 0{0:o} {1}".format(mode, path)) self._robust_ftp_command(command, path) self.stat_cache.invalidate(path) def __getstate__(self): raise TypeError("cannot serialize FTPHost object") # # Context manager methods # def __enter__(self): # Return `self`, so it can be accessed as the variable component of the # `with` statement. return self def __exit__(self, exc_type, exc_val, exc_tb): # We don't need the `exc_*` arguments here. # pylint: disable=unused-argument self.close() # Be explicit. return False