Repository: PyFilesystem/pyfilesystem2

Issue:
original timestamp (ctime, mtime) cannot survive a copy_fs or mirror
as long as there's actual "copy" operation from one fs to another, then those "touched" files will lost their original ctime or mtime, which would be updated to the time when that copy operation happened.
this is not expected feature. though calling it "critical bug" might be too harsh but still it's much better if it could be fixed.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3f933fb..c24b0e6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -30,6 +30,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 - Better documentation of the `writable` parameter of `fs.open_fs`, and
   hint about using `fs.wrap.read_only` when a read-only filesystem is
   required. Closes [#441](https://github.com/PyFilesystem/pyfilesystem2/issues/441).
+- Copy and move operations now provide a parameter `preserve_time` that, when
+  passed as `True`, makes sure the "mtime" of the destination file will be
+  the same as that of the source file.
 
 ### Changed
 
diff --git a/fs/_bulk.py b/fs/_bulk.py
index 9b0b8b7..12eef85 100644
--- a/fs/_bulk.py
+++ b/fs/_bulk.py
@@ -11,14 +11,14 @@ import typing
 
 from six.moves.queue import Queue
 
-from .copy import copy_file_internal
+from .copy import copy_file_internal, copy_modified_time
 from .errors import BulkCopyFailed
 from .tools import copy_file_data
 
 if typing.TYPE_CHECKING:
     from .base import FS
     from types import TracebackType
-    from typing import IO, List, Optional, Text, Type
+    from typing import List, Optional, Text, Type, IO, Tuple
 
 
 class _Worker(threading.Thread):
@@ -75,11 +75,13 @@ class _CopyTask(_Task):
 class Copier(object):
     """Copy files in worker threads."""
 
-    def __init__(self, num_workers=4):
-        # type: (int) -> None
+    def __init__(self, num_workers=4, preserve_time=False):
+        # type: (int, bool) -> None
         if num_workers < 0:
             raise ValueError("num_workers must be >= 0")
         self.num_workers = num_workers
+        self.preserve_time = preserve_time
+        self.all_tasks = []  # type: List[Tuple[FS, Text, FS, Text]]
         self.queue = None  # type: Optional[Queue[_Task]]
         self.workers = []  # type: List[_Worker]
         self.errors = []  # type: List[Exception]
@@ -97,10 +99,18 @@ class Copier(object):
     def stop(self):
         """Stop the workers (will block until they are finished)."""
         if self.running and self.num_workers:
+            # Notify the workers that all tasks have arrived
+            # and wait for them to finish.
             for _worker in self.workers:
                 self.queue.put(None)
             for worker in self.workers:
                 worker.join()
+
+            # If the "last modified" time is to be preserved, do it now.
+            if self.preserve_time:
+                for args in self.all_tasks:
+                    copy_modified_time(*args)
+
             # Free up references held by workers
             del self.workers[:]
             self.queue.join()
@@ -124,13 +134,16 @@ class Copier(object):
         if traceback is None and self.errors:
             raise BulkCopyFailed(self.errors)
 
-    def copy(self, src_fs, src_path, dst_fs, dst_path):
-        # type: (FS, Text, FS, Text) -> None
+    def copy(self, src_fs, src_path, dst_fs, dst_path, preserve_time=False):
+        # type: (FS, Text, FS, Text, bool) -> None
         """Copy a file from one fs to another."""
         if self.queue is None:
             # This should be the most performant for a single-thread
-            copy_file_internal(src_fs, src_path, dst_fs, dst_path)
+            copy_file_internal(
+                src_fs, src_path, dst_fs, dst_path, preserve_time=self.preserve_time
+            )
         else:
+            self.all_tasks.append((src_fs, src_path, dst_fs, dst_path))
             src_file = src_fs.openbin(src_path, "r")
             try:
                 dst_file = dst_fs.openbin(dst_path, "w")
diff --git a/fs/base.py b/fs/base.py
index 4966d0c..8d8ee4c 100644
--- a/fs/base.py
+++ b/fs/base.py
@@ -22,6 +22,7 @@ import warnings
 import six
 
 from . import copy, errors, fsencode, iotools, move, tools, walk, wildcard
+from .copy import copy_modified_time
 from .glob import BoundGlobber
 from .mode import validate_open_mode
 from .path import abspath, join, normpath
@@ -393,8 +394,14 @@ class FS(object):
         """
         self._closed = True
 
-    def copy(self, src_path, dst_path, overwrite=False):
-        # type: (Text, Text, bool) -> None
+    def copy(
+        self,
+        src_path,  # type: Text
+        dst_path,  # type: Text
+        overwrite=False,  # type: bool
+        preserve_time=False,  # type: bool
+    ):
+        # type: (...) -> None
         """Copy file contents from ``src_path`` to ``dst_path``.
 
         Arguments:
@@ -402,6 +409,8 @@ class FS(object):
             dst_path (str): Path to destination file.
             overwrite (bool): If `True`, overwrite the destination file
                 if it exists (defaults to `False`).
+            preserve_time (bool): If `True`, try to preserve mtime of the
+                resource (defaults to `False`).
 
         Raises:
             fs.errors.DestinationExists: If ``dst_path`` exists,
@@ -417,9 +426,17 @@ class FS(object):
             with closing(self.open(src_path, "rb")) as read_file:
                 # FIXME(@althonos): typing complains because open return IO
                 self.upload(dst_path, read_file)  # type: ignore
+            if preserve_time:
+                copy_modified_time(self, src_path, self, dst_path)
 
-    def copydir(self, src_path, dst_path, create=False):
-        # type: (Text, Text, bool) -> None
+    def copydir(
+        self,
+        src_path,  # type: Text
+        dst_path,  # type: Text
+        create=False,  # type: bool
+        preserve_time=False,  # type: bool
+    ):
+        # type: (...) -> None
         """Copy the contents of ``src_path`` to ``dst_path``.
 
         Arguments:
@@ -427,6 +444,8 @@ class FS(object):
             dst_path (str): Path to destination directory.
             create (bool): If `True`, then ``dst_path`` will be created
                 if it doesn't exist already (defaults to `False`).
+            preserve_time (bool): If `True`, try to preserve mtime of the
+                resource (defaults to `False`).
 
         Raises:
             fs.errors.ResourceNotFound: If the ``dst_path``
@@ -440,7 +459,7 @@ class FS(object):
                 raise errors.ResourceNotFound(dst_path)
             if not self.getinfo(src_path).is_dir:
                 raise errors.DirectoryExpected(src_path)
-            copy.copy_dir(self, src_path, self, dst_path)
+            copy.copy_dir(self, src_path, self, dst_path, preserve_time=preserve_time)
 
     def create(self, path, wipe=False):
         # type: (Text, bool) -> bool
@@ -1027,8 +1046,8 @@ class FS(object):
         """
         return self._lock
 
-    def movedir(self, src_path, dst_path, create=False):
-        # type: (Text, Text, bool) -> None
+    def movedir(self, src_path, dst_path, create=False, preserve_time=False):
+        # type: (Text, Text, bool, bool) -> None
         """Move directory ``src_path`` to ``dst_path``.
 
         Arguments:
@@ -1036,6 +1055,8 @@ class FS(object):
             dst_path (str): Path to destination directory.
             create (bool): If `True`, then ``dst_path`` will be created
                 if it doesn't exist already (defaults to `False`).
+            preserve_time (bool): If `True`, try to preserve mtime of the
+                resources (defaults to `False`).
 
         Raises:
             fs.errors.ResourceNotFound: if ``dst_path`` does not exist,
@@ -1047,7 +1068,7 @@ class FS(object):
         with self._lock:
             if not create and not self.exists(dst_path):
                 raise errors.ResourceNotFound(dst_path)
-            move.move_dir(self, src_path, self, dst_path)
+            move.move_dir(self, src_path, self, dst_path, preserve_time=preserve_time)
 
     def makedirs(
         self,
@@ -1092,8 +1113,8 @@ class FS(object):
                     raise
             return self.opendir(path)
 
-    def move(self, src_path, dst_path, overwrite=False):
-        # type: (Text, Text, bool) -> None
+    def move(self, src_path, dst_path, overwrite=False, preserve_time=False):
+        # type: (Text, Text, bool, bool) -> None
         """Move a file from ``src_path`` to ``dst_path``.
 
         Arguments:
@@ -1102,6 +1123,8 @@ class FS(object):
                 file will be written to.
             overwrite (bool): If `True`, destination path will be
                 overwritten if it exists.
+            preserve_time (bool): If `True`, try to preserve mtime of the
+                resources (defaults to `False`).
 
         Raises:
             fs.errors.FileExpected: If ``src_path`` maps to a
@@ -1128,11 +1151,15 @@ class FS(object):
                 except OSError:
                     pass
                 else:
+                    if preserve_time:
+                        copy_modified_time(self, src_path, self, dst_path)
                     return
         with self._lock:
             with self.open(src_path, "rb") as read_file:
                 # FIXME(@althonos): typing complains because open return IO
                 self.upload(dst_path, read_file)  # type: ignore
+            if preserve_time:
+                copy_modified_time(self, src_path, self, dst_path)
             self.remove(src_path)
 
     def open(
diff --git a/fs/copy.py b/fs/copy.py
index 03108c0..95650b9 100644
--- a/fs/copy.py
+++ b/fs/copy.py
@@ -25,6 +25,7 @@ def copy_fs(
     walker=None,  # type: Optional[Walker]
     on_copy=None,  # type: Optional[_OnCopy]
     workers=0,  # type: int
+    preserve_time=False,  # type: bool
 ):
     # type: (...) -> None
     """Copy the contents of one filesystem to another.
@@ -40,9 +41,13 @@ def copy_fs(
             dst_path)``.
         workers (int): Use `worker` threads to copy data, or ``0`` (default) for
             a single-threaded copy.
+        preserve_time (bool): If `True`, try to preserve mtime of the
+            resources (defaults to `False`).
 
     """
-    return copy_fs_if(src_fs, dst_fs, "always", walker, on_copy, workers)
+    return copy_fs_if(
+        src_fs, dst_fs, "always", walker, on_copy, workers, preserve_time=preserve_time
+    )
 
 
 def copy_fs_if_newer(
@@ -51,6 +56,7 @@ def copy_fs_if_newer(
     walker=None,  # type: Optional[Walker]
     on_copy=None,  # type: Optional[_OnCopy]
     workers=0,  # type: int
+    preserve_time=False,  # type: bool
 ):
     # type: (...) -> None
     """Copy the contents of one filesystem to another, checking times.
@@ -62,7 +68,9 @@ def copy_fs_if_newer(
     warnings.warn(
         "copy_fs_if_newer is deprecated. Use copy_fs_if instead.", DeprecationWarning
     )
-    return copy_fs_if(src_fs, dst_fs, "newer", walker, on_copy, workers)
+    return copy_fs_if(
+        src_fs, dst_fs, "newer", walker, on_copy, workers, preserve_time=preserve_time
+    )
 
 
 def copy_fs_if(
@@ -72,6 +80,7 @@ def copy_fs_if(
     walker=None,  # type: Optional[Walker]
     on_copy=None,  # type: Optional[_OnCopy]
     workers=0,  # type: int
+    preserve_time=False,  # type: bool
 ):
     # type: (...) -> None
     """Copy the contents of one filesystem to another, depending on a condition.
@@ -88,6 +97,8 @@ def copy_fs_if(
             dst_path)``.
         workers (int): Use ``worker`` threads to copy data, or ``0`` (default)
             for a single-threaded copy.
+        preserve_time (bool): If `True`, try to preserve mtime of the
+            resources (defaults to `False`).
 
     See Also:
         `~fs.copy.copy_file_if` for the full list of supported values for the
@@ -103,6 +114,7 @@ def copy_fs_if(
         walker=walker,
         on_copy=on_copy,
         workers=workers,
+        preserve_time=preserve_time,
     )
 
 
@@ -111,6 +123,7 @@ def copy_file(
     src_path,  # type: Text
     dst_fs,  # type: Union[FS, Text]
     dst_path,  # type: Text
+    preserve_time=False,  # type: bool
 ):
     # type: (...) -> None
     """Copy a file from one filesystem to another.
@@ -122,9 +135,13 @@ def copy_file(
         src_path (str): Path to a file on the source filesystem.
         dst_fs (FS or str): Destination filesystem (instance or URL).
         dst_path (str): Path to a file on the destination filesystem.
+        preserve_time (bool): If `True`, try to preserve mtime of the
+            resource (defaults to `False`).
 
     """
-    copy_file_if(src_fs, src_path, dst_fs, dst_path, "always")
+    copy_file_if(
+        src_fs, src_path, dst_fs, dst_path, "always", preserve_time=preserve_time
+    )
 
 
 def copy_file_if_newer(
@@ -132,6 +149,7 @@ def copy_file_if_newer(
     src_path,  # type: Text
     dst_fs,  # type: Union[FS, Text]
     dst_path,  # type: Text
+    preserve_time=False,  # type: bool
 ):
     # type: (...) -> bool
     """Copy a file from one filesystem to another, checking times.
@@ -144,7 +162,9 @@ def copy_file_if_newer(
         "copy_file_if_newer is deprecated. Use copy_file_if instead.",
         DeprecationWarning,
     )
-    return copy_file_if(src_fs, src_path, dst_fs, dst_path, "newer")
+    return copy_file_if(
+        src_fs, src_path, dst_fs, dst_path, "newer", preserve_time=preserve_time
+    )
 
 
 def copy_file_if(
@@ -153,6 +173,7 @@ def copy_file_if(
     dst_fs,  # type: Union[FS, Text]
     dst_path,  # type: Text
     condition,  # type: Text
+    preserve_time=False,  # type: bool
 ):
     # type: (...) -> bool
     """Copy a file from one filesystem to another, depending on a condition.
@@ -184,6 +205,8 @@ def copy_file_if(
         dst_fs (FS or str): Destination filesystem (instance or URL).
         dst_path (str): Path to a file on the destination filesystem.
         condition (str): Name of the condition to check for each file.
+        preserve_time (bool): If `True`, try to preserve mtime of the
+            resource (defaults to `False`).
 
     Returns:
         bool: `True` if the file copy was executed, `False` otherwise.
@@ -195,7 +218,14 @@ def copy_file_if(
                 _src_fs, src_path, _dst_fs, dst_path, condition
             )
             if do_copy:
-                copy_file_internal(_src_fs, src_path, _dst_fs, dst_path, True)
+                copy_file_internal(
+                    _src_fs,
+                    src_path,
+                    _dst_fs,
+                    dst_path,
+                    preserve_time=preserve_time,
+                    lock=True,
+                )
             return do_copy
 
 
@@ -204,6 +234,7 @@ def copy_file_internal(
     src_path,  # type: Text
     dst_fs,  # type: FS
     dst_path,  # type: Text
+    preserve_time=False,  # type: bool
     lock=False,  # type: bool
 ):
     # type: (...) -> None
@@ -219,13 +250,15 @@ def copy_file_internal(
         src_path (str): Path to a file on the source filesystem.
         dst_fs (FS): Destination filesystem.
         dst_path (str): Path to a file on the destination filesystem.
+        preserve_time (bool): If `True`, try to preserve mtime of the
+            resource (defaults to `False`).
         lock (bool): Lock both filesystems before copying.
 
     """
     if src_fs is dst_fs:
         # Same filesystem, so we can do a potentially optimized
         # copy
-        src_fs.copy(src_path, dst_path, overwrite=True)
+        src_fs.copy(src_path, dst_path, overwrite=True, preserve_time=preserve_time)
         return
 
     def _copy_locked():
@@ -236,6 +269,9 @@ def copy_file_internal(
             with src_fs.openbin(src_path) as read_file:
                 dst_fs.upload(dst_path, read_file)
 
+        if preserve_time:
+            copy_modified_time(src_fs, src_path, dst_fs, dst_path)
+
     if lock:
         with src_fs.lock(), dst_fs.lock():
             _copy_locked()
@@ -283,6 +319,7 @@ def copy_dir(
     walker=None,  # type: Optional[Walker]
     on_copy=None,  # type: Optional[_OnCopy]
     workers=0,  # type: int
+    preserve_time=False,  # type: bool
 ):
     # type: (...) -> None
     """Copy a directory from one filesystem to another.
@@ -300,9 +337,21 @@ def copy_dir(
             ``(src_fs, src_path, dst_fs, dst_path)``.
         workers (int): Use ``worker`` threads to copy data, or ``0`` (default) for
             a single-threaded copy.
+        preserve_time (bool): If `True`, try to preserve mtime of the
+            resources (defaults to `False`).
 
     """
-    copy_dir_if(src_fs, src_path, dst_fs, dst_path, "always", walker, on_copy, workers)
+    copy_dir_if(
+        src_fs,
+        src_path,
+        dst_fs,
+        dst_path,
+        "always",
+        walker,
+        on_copy,
+        workers,
+        preserve_time=preserve_time,
+    )
 
 
 def copy_dir_if_newer(
@@ -313,6 +362,7 @@ def copy_dir_if_newer(
     walker=None,  # type: Optional[Walker]
     on_copy=None,  # type: Optional[_OnCopy]
     workers=0,  # type: int
+    preserve_time=False,  # type: bool
 ):
     # type: (...) -> None
     """Copy a directory from one filesystem to another, checking times.
@@ -324,7 +374,17 @@ def copy_dir_if_newer(
     warnings.warn(
         "copy_dir_if_newer is deprecated. Use copy_dir_if instead.", DeprecationWarning
     )
-    copy_dir_if(src_fs, src_path, dst_fs, dst_path, "newer", walker, on_copy, workers)
+    copy_dir_if(
+        src_fs,
+        src_path,
+        dst_fs,
+        dst_path,
+        "newer",
+        walker,
+        on_copy,
+        workers,
+        preserve_time=preserve_time,
+    )
 
 
 def copy_dir_if(
@@ -336,6 +396,7 @@ def copy_dir_if(
     walker=None,  # type: Optional[Walker]
     on_copy=None,  # type: Optional[_OnCopy]
     workers=0,  # type: int
+    preserve_time=False,  # type: bool
 ):
     # type: (...) -> None
     """Copy a directory from one filesystem to another, depending on a condition.
@@ -354,6 +415,8 @@ def copy_dir_if(
             dst_path)``.
         workers (int): Use ``worker`` threads to copy data, or ``0`` (default) for
             a single-threaded copy.
+        preserve_time (bool): If `True`, try to preserve mtime of the
+            resources (defaults to `False`).
 
     See Also:
         `~fs.copy.copy_file_if` for the full list of supported values for the
@@ -374,7 +437,9 @@ def copy_dir_if(
     ) as _dst_fs:
         with _src_fs.lock(), _dst_fs.lock():
             _thread_safe = is_thread_safe(_src_fs, _dst_fs)
-            with Copier(num_workers=workers if _thread_safe else 0) as copier:
+            with Copier(
+                num_workers=workers if _thread_safe else 0, preserve_time=preserve_time
+            ) as copier:
                 for dir_path in walker.files(_src_fs, _src_path):
                     copy_path = combine(_dst_path, frombase(_src_path, dir_path))
                     if _copy_is_necessary(
@@ -432,3 +497,31 @@ def _copy_is_necessary(
 
     else:
         raise ValueError("{} is not a valid copy condition.".format(condition))
+
+
+def copy_modified_time(
+    src_fs,  # type: Union[FS, Text]
+    src_path,  # type: Text
+    dst_fs,  # type: Union[FS, Text]
+    dst_path,  # type: Text
+):
+    # type: (...) -> None
+    """Copy modified time metadata from one file to another.
+
+    Arguments:
+        src_fs (FS or str): Source filesystem (instance or URL).
+        src_path (str): Path to a directory on the source filesystem.
+        dst_fs (FS or str): Destination filesystem (instance or URL).
+        dst_path (str): Path to a directory on the destination filesystem.
+
+    """
+    namespaces = ("details",)
+    with manage_fs(src_fs, writeable=False) as _src_fs:
+        with manage_fs(dst_fs, create=True) as _dst_fs:
+            src_meta = _src_fs.getinfo(src_path, namespaces)
+            src_details = src_meta.raw.get("details", {})
+            dst_details = {}
+            for value in ("metadata_changed", "modified"):
+                if value in src_details:
+                    dst_details[value] = src_details[value]
+            _dst_fs.setinfo(dst_path, {"details": dst_details})
diff --git a/fs/ftpfs.py b/fs/ftpfs.py
index 515709d..86f0495 100644
--- a/fs/ftpfs.py
+++ b/fs/ftpfs.py
@@ -6,6 +6,7 @@ from __future__ import unicode_literals
 
 import array
 import calendar
+import datetime
 import io
 import itertools
 import socket
@@ -19,8 +20,7 @@ try:
     from ftplib import FTP_TLS
 except ImportError as err:
     FTP_TLS = err  # type: ignore
-from ftplib import error_perm
-from ftplib import error_temp
+from ftplib import error_perm, error_temp
 from typing import cast
 
 from six import PY2
@@ -836,8 +836,32 @@ class FTPFS(FS):
 
     def setinfo(self, path, info):
         # type: (Text, RawInfo) -> None
-        if not self.exists(path):
-            raise errors.ResourceNotFound(path)
+        use_mfmt = False
+        if "MFMT" in self.features:
+            info_details = None
+            if "modified" in info:
+                info_details = info["modified"]
+            elif "details" in info:
+                info_details = info["details"]
+            if info_details and "modified" in info_details:
+                use_mfmt = True
+                mtime = cast(float, info_details["modified"])
+
+        if use_mfmt:
+            with ftp_errors(self, path):
+                cmd = (
+                    "MFMT "
+                    + datetime.datetime.utcfromtimestamp(mtime).strftime("%Y%m%d%H%M%S")
+                    + " "
+                    + _encode(path, self.ftp.encoding)
+                )
+                try:
+                    self.ftp.sendcmd(cmd)
+                except error_perm:
+                    pass
+        else:
+            if not self.exists(path):
+                raise errors.ResourceNotFound(path)
 
     def readbytes(self, path):
         # type: (Text) -> bytes
diff --git a/fs/memoryfs.py b/fs/memoryfs.py
index 7965a13..c34d60a 100644
--- a/fs/memoryfs.py
+++ b/fs/memoryfs.py
@@ -15,6 +15,7 @@ import six
 
 from . import errors
 from .base import FS
+from .copy import copy_modified_time
 from .enums import ResourceType, Seek
 from .info import Info
 from .mode import Mode
@@ -444,7 +445,7 @@ class MemoryFS(FS):
                 parent_dir.set_entry(dir_name, new_dir)
             return self.opendir(path)
 
-    def move(self, src_path, dst_path, overwrite=False):
+    def move(self, src_path, dst_path, overwrite=False, preserve_time=False):
         src_dir, src_name = split(self.validatepath(src_path))
         dst_dir, dst_name = split(self.validatepath(dst_path))
 
@@ -465,7 +466,10 @@ class MemoryFS(FS):
             dst_dir_entry.set_entry(dst_name, src_entry)
             src_dir_entry.remove_entry(src_name)
 
-    def movedir(self, src_path, dst_path, create=False):
+            if preserve_time:
+                copy_modified_time(self, src_path, self, dst_path)
+
+    def movedir(self, src_path, dst_path, create=False, preserve_time=False):
         src_dir, src_name = split(self.validatepath(src_path))
         dst_dir, dst_name = split(self.validatepath(dst_path))
 
@@ -484,6 +488,9 @@ class MemoryFS(FS):
             dst_dir_entry.set_entry(dst_name, src_entry)
             src_dir_entry.remove_entry(src_name)
 
+            if preserve_time:
+                copy_modified_time(self, src_path, self, dst_path)
+
     def openbin(self, path, mode="r", buffering=-1, **options):
         # type: (Text, Text, int, **Any) -> BinaryIO
         _mode = Mode(mode)
diff --git a/fs/mirror.py b/fs/mirror.py
index 6b989e6..dd00ff7 100644
--- a/fs/mirror.py
+++ b/fs/mirror.py
@@ -57,6 +57,7 @@ def mirror(
     walker=None,  # type: Optional[Walker]
     copy_if_newer=True,  # type: bool
     workers=0,  # type: int
+    preserve_time=False,  # type: bool
 ):
     # type: (...) -> None
     """Mirror files / directories from one filesystem to another.
@@ -73,6 +74,8 @@ def mirror(
         workers (int): Number of worker threads used
             (0 for single threaded). Set to a relatively low number
             for network filesystems, 4 would be a good start.
+        preserve_time (bool): If `True`, try to preserve mtime of the
+            resources (defaults to `False`).
 
     """
 
@@ -83,22 +86,30 @@ def mirror(
         return manage_fs(dst_fs, create=True)
 
     with src() as _src_fs, dst() as _dst_fs:
-        with _src_fs.lock(), _dst_fs.lock():
-            _thread_safe = is_thread_safe(_src_fs, _dst_fs)
-            with Copier(num_workers=workers if _thread_safe else 0) as copier:
+        _thread_safe = is_thread_safe(_src_fs, _dst_fs)
+        with Copier(
+            num_workers=workers if _thread_safe else 0, preserve_time=preserve_time
+        ) as copier:
+            with _src_fs.lock(), _dst_fs.lock():
                 _mirror(
                     _src_fs,
                     _dst_fs,
                     walker=walker,
                     copy_if_newer=copy_if_newer,
                     copy_file=copier.copy,
+                    preserve_time=preserve_time,
                 )
 
 
 def _mirror(
-    src_fs, dst_fs, walker=None, copy_if_newer=True, copy_file=copy_file_internal
+    src_fs,  # type: FS
+    dst_fs,  # type: FS
+    walker=None,  # type: Optional[Walker]
+    copy_if_newer=True,  # type: bool
+    copy_file=copy_file_internal,  # type: Callable[[FS, str, FS, str, bool], None]
+    preserve_time=False,  # type: bool
 ):
-    # type: (FS, FS, Optional[Walker], bool, Callable[[FS, str, FS, str], None]) -> None
+    # type: (...) -> None
     walker = walker or Walker()
     walk = walker.walk(src_fs, namespaces=["details"])
     for path, dirs, files in walk:
@@ -122,7 +133,7 @@ def _mirror(
                     # Compare file info
                     if copy_if_newer and not _compare(_file, dst_file):
                         continue
-            copy_file(src_fs, _path, dst_fs, _path)
+            copy_file(src_fs, _path, dst_fs, _path, preserve_time)
 
         # Make directories
         for _dir in dirs:
diff --git a/fs/move.py b/fs/move.py
index 1d8e26c..56e9e5c 100644
--- a/fs/move.py
+++ b/fs/move.py
@@ -15,8 +15,13 @@ if typing.TYPE_CHECKING:
     from typing import Text, Union
 
 
-def move_fs(src_fs, dst_fs, workers=0):
-    # type: (Union[Text, FS], Union[Text, FS], int) -> None
+def move_fs(
+    src_fs,  # type: Union[Text, FS]
+    dst_fs,  # type:Union[Text, FS]
+    workers=0,  # type: int
+    preserve_time=False,  # type: bool
+):
+    # type: (...) -> None
     """Move the contents of a filesystem to another filesystem.
 
     Arguments:
@@ -24,9 +29,11 @@ def move_fs(src_fs, dst_fs, workers=0):
         dst_fs (FS or str): Destination filesystem (instance or URL).
         workers (int): Use `worker` threads to copy data, or ``0`` (default) for
             a single-threaded copy.
+        preserve_time (bool): If `True`, try to preserve mtime of the
+            resources (defaults to `False`).
 
     """
-    move_dir(src_fs, "/", dst_fs, "/", workers=workers)
+    move_dir(src_fs, "/", dst_fs, "/", workers=workers, preserve_time=preserve_time)
 
 
 def move_file(
@@ -34,6 +41,7 @@ def move_file(
     src_path,  # type: Text
     dst_fs,  # type: Union[Text, FS]
     dst_path,  # type: Text
+    preserve_time=False,  # type: bool
 ):
     # type: (...) -> None
     """Move a file from one filesystem to another.
@@ -43,17 +51,27 @@ def move_file(
         src_path (str): Path to a file on ``src_fs``.
         dst_fs (FS or str): Destination filesystem (instance or URL).
         dst_path (str): Path to a file on ``dst_fs``.
+        preserve_time (bool): If `True`, try to preserve mtime of the
+            resources (defaults to `False`).
 
     """
     with manage_fs(src_fs) as _src_fs:
         with manage_fs(dst_fs, create=True) as _dst_fs:
             if _src_fs is _dst_fs:
                 # Same filesystem, may be optimized
-                _src_fs.move(src_path, dst_path, overwrite=True)
+                _src_fs.move(
+                    src_path, dst_path, overwrite=True, preserve_time=preserve_time
+                )
             else:
                 # Standard copy and delete
                 with _src_fs.lock(), _dst_fs.lock():
-                    copy_file(_src_fs, src_path, _dst_fs, dst_path)
+                    copy_file(
+                        _src_fs,
+                        src_path,
+                        _dst_fs,
+                        dst_path,
+                        preserve_time=preserve_time,
+                    )
                     _src_fs.remove(src_path)
 
 
@@ -63,6 +81,7 @@ def move_dir(
     dst_fs,  # type: Union[Text, FS]
     dst_path,  # type: Text
     workers=0,  # type: int
+    preserve_time=False,  # type: bool
 ):
     # type: (...) -> None
     """Move a directory from one filesystem to another.
@@ -74,6 +93,8 @@ def move_dir(
         dst_path (str): Path to a directory on ``dst_fs``.
         workers (int): Use ``worker`` threads to copy data, or ``0``
             (default) for a single-threaded copy.
+        preserve_time (bool): If `True`, try to preserve mtime of the
+            resources (defaults to `False`).
 
     """
 
@@ -86,5 +107,12 @@ def move_dir(
     with src() as _src_fs, dst() as _dst_fs:
         with _src_fs.lock(), _dst_fs.lock():
             _dst_fs.makedir(dst_path, recreate=True)
-            copy_dir(src_fs, src_path, dst_fs, dst_path, workers=workers)
+            copy_dir(
+                src_fs,
+                src_path,
+                dst_fs,
+                dst_path,
+                workers=workers,
+                preserve_time=preserve_time,
+            )
             _src_fs.removetree(src_path)
diff --git a/fs/osfs.py b/fs/osfs.py
index 5beb16b..ac43471 100644
--- a/fs/osfs.py
+++ b/fs/osfs.py
@@ -49,6 +49,7 @@ from .error_tools import convert_os_errors
 from .mode import Mode, validate_open_mode
 from .errors import FileExpected, NoURL
 from ._url_tools import url_quote
+from .copy import copy_modified_time
 
 if typing.TYPE_CHECKING:
     from typing import (
@@ -431,8 +432,8 @@ class OSFS(FS):
         if hasattr(errno, "ENOTSUP"):
             _sendfile_error_codes.add(errno.ENOTSUP)
 
-        def copy(self, src_path, dst_path, overwrite=False):
-            # type: (Text, Text, bool) -> None
+        def copy(self, src_path, dst_path, overwrite=False, preserve_time=False):
+            # type: (Text, Text, bool, bool) -> None
             with self._lock:
                 # validate and canonicalise paths
                 _src_path, _dst_path = self._check_copy(src_path, dst_path, overwrite)
@@ -452,6 +453,8 @@ class OSFS(FS):
                             while sent > 0:
                                 sent = sendfile(fd_dst, fd_src, offset, maxsize)
                                 offset += sent
+                    if preserve_time:
+                        copy_modified_time(self, src_path, self, dst_path)
                 except OSError as e:
                     # the error is not a simple "sendfile not supported" error
                     if e.errno not in self._sendfile_error_codes:
@@ -461,8 +464,8 @@ class OSFS(FS):
 
     else:
 
-        def copy(self, src_path, dst_path, overwrite=False):
-            # type: (Text, Text, bool) -> None
+        def copy(self, src_path, dst_path, overwrite=False, preserve_time=False):
+            # type: (Text, Text, bool, bool) -> None
             with self._lock:
                 _src_path, _dst_path = self._check_copy(src_path, dst_path, overwrite)
                 shutil.copy2(self.getsyspath(_src_path), self.getsyspath(_dst_path))
diff --git a/fs/wrap.py b/fs/wrap.py
index e03ef80..21fc10d 100644
--- a/fs/wrap.py
+++ b/fs/wrap.py
@@ -199,8 +199,8 @@ class WrapReadOnly(WrapFS[_F], typing.Generic[_F]):
         self.check()
         raise ResourceReadOnly(path)
 
-    def move(self, src_path, dst_path, overwrite=False):
-        # type: (Text, Text, bool) -> None
+    def move(self, src_path, dst_path, overwrite=False, preserve_time=False):
+        # type: (Text, Text, bool, bool) -> None
         self.check()
         raise ResourceReadOnly(dst_path)
 
@@ -248,8 +248,8 @@ class WrapReadOnly(WrapFS[_F], typing.Generic[_F]):
         self.check()
         raise ResourceReadOnly(path)
 
-    def copy(self, src_path, dst_path, overwrite=False):
-        # type: (Text, Text, bool) -> None
+    def copy(self, src_path, dst_path, overwrite=False, preserve_time=False):
+        # type: (Text, Text, bool, bool) -> None
         self.check()
         raise ResourceReadOnly(dst_path)
 
diff --git a/fs/wrapfs.py b/fs/wrapfs.py
index 5944b11..00edd7a 100644
--- a/fs/wrapfs.py
+++ b/fs/wrapfs.py
@@ -167,18 +167,18 @@ class WrapFS(FS, typing.Generic[_F]):
         with unwrap_errors(path):
             return _fs.makedir(_path, permissions=permissions, recreate=recreate)
 
-    def move(self, src_path, dst_path, overwrite=False):
-        # type: (Text, Text, bool) -> None
+    def move(self, src_path, dst_path, overwrite=False, preserve_time=False):
+        # type: (Text, Text, bool, bool) -> None
         # A custom move permits a potentially optimized code path
         src_fs, _src_path = self.delegate_path(src_path)
         dst_fs, _dst_path = self.delegate_path(dst_path)
         with unwrap_errors({_src_path: src_path, _dst_path: dst_path}):
             if not overwrite and dst_fs.exists(_dst_path):
                 raise errors.DestinationExists(_dst_path)
-            move_file(src_fs, _src_path, dst_fs, _dst_path)
+            move_file(src_fs, _src_path, dst_fs, _dst_path, preserve_time=preserve_time)
 
-    def movedir(self, src_path, dst_path, create=False):
-        # type: (Text, Text, bool) -> None
+    def movedir(self, src_path, dst_path, create=False, preserve_time=False):
+        # type: (Text, Text, bool, bool) -> None
         src_fs, _src_path = self.delegate_path(src_path)
         dst_fs, _dst_path = self.delegate_path(dst_path)
         with unwrap_errors({_src_path: src_path, _dst_path: dst_path}):
@@ -186,7 +186,7 @@ class WrapFS(FS, typing.Generic[_F]):
                 raise errors.ResourceNotFound(dst_path)
             if not src_fs.getinfo(_src_path).is_dir:
                 raise errors.DirectoryExpected(src_path)
-            move_dir(src_fs, _src_path, dst_fs, _dst_path)
+            move_dir(src_fs, _src_path, dst_fs, _dst_path, preserve_time=preserve_time)
 
     def openbin(self, path, mode="r", buffering=-1, **options):
         # type: (Text, Text, int, **Any) -> BinaryIO
@@ -265,17 +265,17 @@ class WrapFS(FS, typing.Generic[_F]):
         with unwrap_errors(path):
             _fs.touch(_path)
 
-    def copy(self, src_path, dst_path, overwrite=False):
-        # type: (Text, Text, bool) -> None
+    def copy(self, src_path, dst_path, overwrite=False, preserve_time=False):
+        # type: (Text, Text, bool, bool) -> None
         src_fs, _src_path = self.delegate_path(src_path)
         dst_fs, _dst_path = self.delegate_path(dst_path)
         with unwrap_errors({_src_path: src_path, _dst_path: dst_path}):
             if not overwrite and dst_fs.exists(_dst_path):
                 raise errors.DestinationExists(_dst_path)
-            copy_file(src_fs, _src_path, dst_fs, _dst_path)
+            copy_file(src_fs, _src_path, dst_fs, _dst_path, preserve_time=preserve_time)
 
-    def copydir(self, src_path, dst_path, create=False):
-        # type: (Text, Text, bool) -> None
+    def copydir(self, src_path, dst_path, create=False, preserve_time=False):
+        # type: (Text, Text, bool, bool) -> None
         src_fs, _src_path = self.delegate_path(src_path)
         dst_fs, _dst_path = self.delegate_path(dst_path)
         with unwrap_errors({_src_path: src_path, _dst_path: dst_path}):
@@ -283,7 +283,7 @@ class WrapFS(FS, typing.Generic[_F]):
                 raise errors.ResourceNotFound(dst_path)
             if not src_fs.getinfo(_src_path).is_dir:
                 raise errors.DirectoryExpected(src_path)
-            copy_dir(src_fs, _src_path, dst_fs, _dst_path)
+            copy_dir(src_fs, _src_path, dst_fs, _dst_path, preserve_time=preserve_time)
 
     def create(self, path, wipe=False):
         # type: (Text, bool) -> bool


Test patch:
diff --git a/tests/requirements.txt b/tests/requirements.txt
index 9e7ece3..b7ff3ce 100644
--- a/tests/requirements.txt
+++ b/tests/requirements.txt
@@ -14,3 +14,5 @@ pysendfile ~=2.0 ; python_version <= "3.3"
 # mock v4+ doesn't support Python 2.7 anymore
 mock ~=3.0 ; python_version < "3.3"
 
+# parametrized to prevent code duplication in tests.
+parameterized ~=0.8
\ No newline at end of file
diff --git a/tests/test_copy.py b/tests/test_copy.py
index 92d77c3..6441e81 100644
--- a/tests/test_copy.py
+++ b/tests/test_copy.py
@@ -8,6 +8,8 @@ import tempfile
 import shutil
 import calendar
 
+from parameterized import parameterized
+
 import fs.copy
 from fs import open_fs
 
@@ -61,20 +63,29 @@ def mkdirp(path):
 
 
 class TestCopySimple(unittest.TestCase):
-    def test_copy_fs(self):
-        for workers in (0, 1, 2, 4):
-            src_fs = open_fs("mem://")
-            src_fs.makedirs("foo/bar")
-            src_fs.makedirs("foo/empty")
-            src_fs.touch("test.txt")
-            src_fs.touch("foo/bar/baz.txt")
+    @parameterized.expand([(0,), (1,), (2,), (4,)])
+    def test_copy_fs(self, workers):
+        namespaces = ("details", "modified")
+
+        src_fs = open_fs("mem://")
+        src_fs.makedirs("foo/bar")
+        src_fs.makedirs("foo/empty")
+        src_fs.touch("test.txt")
+        src_fs.touch("foo/bar/baz.txt")
+        src_file1_info = src_fs.getinfo("test.txt", namespaces)
+        src_file2_info = src_fs.getinfo("foo/bar/baz.txt", namespaces)
+
+        dst_fs = open_fs("mem://")
+        fs.copy.copy_fs(src_fs, dst_fs, workers=workers, preserve_time=True)
 
-            dst_fs = open_fs("mem://")
-            fs.copy.copy_fs(src_fs, dst_fs, workers=workers)
+        self.assertTrue(dst_fs.isdir("foo/empty"))
+        self.assertTrue(dst_fs.isdir("foo/bar"))
+        self.assertTrue(dst_fs.isfile("test.txt"))
 
-            self.assertTrue(dst_fs.isdir("foo/empty"))
-            self.assertTrue(dst_fs.isdir("foo/bar"))
-            self.assertTrue(dst_fs.isfile("test.txt"))
+        dst_file1_info = dst_fs.getinfo("test.txt", namespaces)
+        dst_file2_info = dst_fs.getinfo("foo/bar/baz.txt", namespaces)
+        self.assertEqual(dst_file1_info.modified, src_file1_info.modified)
+        self.assertEqual(dst_file2_info.modified, src_file2_info.modified)
 
     def test_copy_value_error(self):
         src_fs = open_fs("mem://")
@@ -82,18 +93,46 @@ class TestCopySimple(unittest.TestCase):
         with self.assertRaises(ValueError):
             fs.copy.copy_fs(src_fs, dst_fs, workers=-1)
 
-    def test_copy_dir(self):
+    def test_copy_dir0(self):
+        namespaces = ("details", "modified")
+
+        src_fs = open_fs("mem://")
+        src_fs.makedirs("foo/bar")
+        src_fs.makedirs("foo/empty")
+        src_fs.touch("test.txt")
+        src_fs.touch("foo/bar/baz.txt")
+        src_file2_info = src_fs.getinfo("foo/bar/baz.txt", namespaces)
+
+        with open_fs("mem://") as dst_fs:
+            fs.copy.copy_dir(src_fs, "/foo", dst_fs, "/", workers=0, preserve_time=True)
+            self.assertTrue(dst_fs.isdir("bar"))
+            self.assertTrue(dst_fs.isdir("empty"))
+            self.assertTrue(dst_fs.isfile("bar/baz.txt"))
+
+            dst_file2_info = dst_fs.getinfo("bar/baz.txt", namespaces)
+            self.assertEqual(dst_file2_info.modified, src_file2_info.modified)
+
+    @parameterized.expand([(0,), (1,), (2,), (4,)])
+    def test_copy_dir(self, workers):
+        namespaces = ("details", "modified")
+
         src_fs = open_fs("mem://")
         src_fs.makedirs("foo/bar")
         src_fs.makedirs("foo/empty")
         src_fs.touch("test.txt")
         src_fs.touch("foo/bar/baz.txt")
-        for workers in (0, 1, 2, 4):
-            with open_fs("mem://") as dst_fs:
-                fs.copy.copy_dir(src_fs, "/foo", dst_fs, "/", workers=workers)
-                self.assertTrue(dst_fs.isdir("bar"))
-                self.assertTrue(dst_fs.isdir("empty"))
-                self.assertTrue(dst_fs.isfile("bar/baz.txt"))
+        src_file2_info = src_fs.getinfo("foo/bar/baz.txt", namespaces)
+
+        with open_fs("mem://") as dst_fs:
+            fs.copy.copy_dir(
+                src_fs, "/foo", dst_fs, "/", workers=workers, preserve_time=True
+            )
+            self.assertTrue(dst_fs.isdir("bar"))
+            self.assertTrue(dst_fs.isdir("empty"))
+            self.assertTrue(dst_fs.isfile("bar/baz.txt"))
+
+            dst_file2_info = dst_fs.getinfo("bar/baz.txt", namespaces)
+            self.assertEqual(dst_file2_info.modified, src_file2_info.modified)
 
     def test_copy_large(self):
         data1 = b"foo" * 512 * 1024
diff --git a/tests/test_ftpfs.py b/tests/test_ftpfs.py
index b0e0c1f..43028da 100644
--- a/tests/test_ftpfs.py
+++ b/tests/test_ftpfs.py
@@ -3,6 +3,7 @@ from __future__ import absolute_import
 from __future__ import print_function
 from __future__ import unicode_literals
 
+import calendar
 import os
 import platform
 import shutil
@@ -11,6 +12,7 @@ import tempfile
 import time
 import unittest
 import uuid
+import datetime
 
 try:
     from unittest import mock
@@ -144,7 +146,6 @@ class TestFTPErrors(unittest.TestCase):
 @mark.slow
 @unittest.skipIf(platform.python_implementation() == "PyPy", "ftp unreliable with PyPy")
 class TestFTPFS(FSTestCases, unittest.TestCase):
-
     user = "user"
     pasw = "1234"
 
@@ -162,7 +163,7 @@ class TestFTPFS(FSTestCases, unittest.TestCase):
         cls.server.shutdown_after = -1
         cls.server.handler.authorizer = DummyAuthorizer()
         cls.server.handler.authorizer.add_user(
-            cls.user, cls.pasw, cls._temp_path, perm="elradfmw"
+            cls.user, cls.pasw, cls._temp_path, perm="elradfmwT"
         )
         cls.server.handler.authorizer.add_anonymous(cls._temp_path)
         cls.server.start()
@@ -223,6 +224,23 @@ class TestFTPFS(FSTestCases, unittest.TestCase):
             ),
         )
 
+    def test_setinfo(self):
+        # TODO: temporary test, since FSTestCases.test_setinfo is broken.
+        self.fs.create("bar")
+        original_modified = self.fs.getinfo("bar", ("details",)).modified
+        new_modified = original_modified - datetime.timedelta(hours=1)
+        new_modified_stamp = calendar.timegm(new_modified.timetuple())
+        self.fs.setinfo("bar", {"details": {"modified": new_modified_stamp}})
+        new_modified_get = self.fs.getinfo("bar", ("details",)).modified
+        if original_modified.microsecond == 0 or new_modified_get.microsecond == 0:
+            original_modified = original_modified.replace(microsecond=0)
+            new_modified_get = new_modified_get.replace(microsecond=0)
+        if original_modified.second == 0 or new_modified_get.second == 0:
+            original_modified = original_modified.replace(second=0)
+            new_modified_get = new_modified_get.replace(second=0)
+        new_modified_get = new_modified_get + datetime.timedelta(hours=1)
+        self.assertEqual(original_modified, new_modified_get)
+
     def test_host(self):
         self.assertEqual(self.fs.host, self.server.host)
 
@@ -301,7 +319,6 @@ class TestFTPFSNoMLSD(TestFTPFS):
 @mark.slow
 @unittest.skipIf(platform.python_implementation() == "PyPy", "ftp unreliable with PyPy")
 class TestAnonFTPFS(FSTestCases, unittest.TestCase):
-
     user = "anonymous"
     pasw = ""
 
diff --git a/tests/test_memoryfs.py b/tests/test_memoryfs.py
index 31909f0..bb5096f 100644
--- a/tests/test_memoryfs.py
+++ b/tests/test_memoryfs.py
@@ -67,9 +67,22 @@ class TestMemoryFS(FSTestCases, unittest.TestCase):
             % (diff_close.size_diff / 1024.0),
         )
 
+    def test_copy_preserve_time(self):
+        self.fs.makedir("foo")
+        self.fs.makedir("bar")
+        self.fs.touch("foo/file.txt")
 
-class TestMemoryFile(unittest.TestCase):
+        namespaces = ("details", "modified")
+        src_info = self.fs.getinfo("foo/file.txt", namespaces)
+
+        self.fs.copy("foo/file.txt", "bar/file.txt", preserve_time=True)
+        self.assertTrue(self.fs.exists("bar/file.txt"))
+
+        dst_info = self.fs.getinfo("bar/file.txt", namespaces)
+        self.assertEqual(dst_info.modified, src_info.modified)
 
+
+class TestMemoryFile(unittest.TestCase):
     def setUp(self):
         self.fs = memoryfs.MemoryFS()
 
diff --git a/tests/test_mirror.py b/tests/test_mirror.py
index a0e8ac5..1cce3d5 100644
--- a/tests/test_mirror.py
+++ b/tests/test_mirror.py
@@ -2,15 +2,17 @@ from __future__ import unicode_literals
 
 import unittest
 
+from parameterized import parameterized_class
+
 from fs.mirror import mirror
 from fs import open_fs
 
 
+@parameterized_class(("WORKERS",), [(0,), (1,), (2,), (4,)])
 class TestMirror(unittest.TestCase):
-    WORKERS = 0  # Single threaded
-
     def _contents(self, fs):
         """Extract an FS in to a simple data structure."""
+        namespaces = ("details", "metadata_changed", "modified")
         contents = []
         for path, dirs, files in fs.walk():
             for info in dirs:
@@ -18,7 +20,17 @@ class TestMirror(unittest.TestCase):
                 contents.append((_path, "dir", b""))
             for info in files:
                 _path = info.make_path(path)
-                contents.append((_path, "file", fs.readbytes(_path)))
+                _bytes = fs.readbytes(_path)
+                _info = fs.getinfo(_path, namespaces)
+                contents.append(
+                    (
+                        _path,
+                        "file",
+                        _bytes,
+                        _info.modified,
+                        _info.metadata_changed,
+                    )
+                )
         return sorted(contents)
 
     def assert_compare_fs(self, fs1, fs2):
@@ -28,14 +40,14 @@ class TestMirror(unittest.TestCase):
     def test_empty_mirror(self):
         m1 = open_fs("mem://")
         m2 = open_fs("mem://")
-        mirror(m1, m2, workers=self.WORKERS)
+        mirror(m1, m2, workers=self.WORKERS, preserve_time=True)
         self.assert_compare_fs(m1, m2)
 
     def test_mirror_one_file(self):
         m1 = open_fs("mem://")
         m1.writetext("foo", "hello")
         m2 = open_fs("mem://")
-        mirror(m1, m2, workers=self.WORKERS)
+        mirror(m1, m2, workers=self.WORKERS, preserve_time=True)
         self.assert_compare_fs(m1, m2)
 
     def test_mirror_one_file_one_dir(self):
@@ -43,7 +55,7 @@ class TestMirror(unittest.TestCase):
         m1.writetext("foo", "hello")
         m1.makedir("bar")
         m2 = open_fs("mem://")
-        mirror(m1, m2, workers=self.WORKERS)
+        mirror(m1, m2, workers=self.WORKERS, preserve_time=True)
         self.assert_compare_fs(m1, m2)
 
     def test_mirror_delete_replace(self):
@@ -51,13 +63,13 @@ class TestMirror(unittest.TestCase):
         m1.writetext("foo", "hello")
         m1.makedir("bar")
         m2 = open_fs("mem://")
-        mirror(m1, m2, workers=self.WORKERS)
+        mirror(m1, m2, workers=self.WORKERS, preserve_time=True)
         self.assert_compare_fs(m1, m2)
         m2.remove("foo")
-        mirror(m1, m2, workers=self.WORKERS)
+        mirror(m1, m2, workers=self.WORKERS, preserve_time=True)
         self.assert_compare_fs(m1, m2)
         m2.removedir("bar")
-        mirror(m1, m2, workers=self.WORKERS)
+        mirror(m1, m2, workers=self.WORKERS, preserve_time=True)
         self.assert_compare_fs(m1, m2)
 
     def test_mirror_extra_dir(self):
@@ -66,7 +78,7 @@ class TestMirror(unittest.TestCase):
         m1.makedir("bar")
         m2 = open_fs("mem://")
         m2.makedir("baz")
-        mirror(m1, m2, workers=self.WORKERS)
+        mirror(m1, m2, workers=self.WORKERS, preserve_time=True)
         self.assert_compare_fs(m1, m2)
 
     def test_mirror_extra_file(self):
@@ -76,7 +88,7 @@ class TestMirror(unittest.TestCase):
         m2 = open_fs("mem://")
         m2.makedir("baz")
         m2.touch("egg")
-        mirror(m1, m2, workers=self.WORKERS)
+        mirror(m1, m2, workers=self.WORKERS, preserve_time=True)
         self.assert_compare_fs(m1, m2)
 
     def test_mirror_wrong_type(self):
@@ -86,7 +98,7 @@ class TestMirror(unittest.TestCase):
         m2 = open_fs("mem://")
         m2.makedir("foo")
         m2.touch("bar")
-        mirror(m1, m2, workers=self.WORKERS)
+        mirror(m1, m2, workers=self.WORKERS, preserve_time=True)
         self.assert_compare_fs(m1, m2)
 
     def test_mirror_update(self):
@@ -94,20 +106,8 @@ class TestMirror(unittest.TestCase):
         m1.writetext("foo", "hello")
         m1.makedir("bar")
         m2 = open_fs("mem://")
-        mirror(m1, m2, workers=self.WORKERS)
+        mirror(m1, m2, workers=self.WORKERS, preserve_time=True)
         self.assert_compare_fs(m1, m2)
         m2.appendtext("foo", " world!")
-        mirror(m1, m2, workers=self.WORKERS)
+        mirror(m1, m2, workers=self.WORKERS, preserve_time=True)
         self.assert_compare_fs(m1, m2)
-
-
-class TestMirrorWorkers1(TestMirror):
-    WORKERS = 1
-
-
-class TestMirrorWorkers2(TestMirror):
-    WORKERS = 2
-
-
-class TestMirrorWorkers4(TestMirror):
-    WORKERS = 4
diff --git a/tests/test_move.py b/tests/test_move.py
index d87d2bd..6b12b2b 100644
--- a/tests/test_move.py
+++ b/tests/test_move.py
@@ -2,33 +2,61 @@ from __future__ import unicode_literals
 
 import unittest
 
+from parameterized import parameterized_class
+
 import fs.move
 from fs import open_fs
 
 
+@parameterized_class(("preserve_time",), [(True,), (False,)])
 class TestMove(unittest.TestCase):
     def test_move_fs(self):
+        namespaces = ("details", "modified")
+
         src_fs = open_fs("mem://")
         src_fs.makedirs("foo/bar")
         src_fs.touch("test.txt")
         src_fs.touch("foo/bar/baz.txt")
+        src_file1_info = src_fs.getinfo("test.txt", namespaces)
+        src_file2_info = src_fs.getinfo("foo/bar/baz.txt", namespaces)
 
         dst_fs = open_fs("mem://")
-        fs.move.move_fs(src_fs, dst_fs)
+        dst_fs.create("test.txt")
+        dst_fs.setinfo("test.txt", {"details": {"modified": 1000000}})
+
+        fs.move.move_fs(src_fs, dst_fs, preserve_time=self.preserve_time)
 
         self.assertTrue(dst_fs.isdir("foo/bar"))
         self.assertTrue(dst_fs.isfile("test.txt"))
+        self.assertTrue(dst_fs.isfile("foo/bar/baz.txt"))
         self.assertTrue(src_fs.isempty("/"))
 
-    def test_copy_dir(self):
+        if self.preserve_time:
+            dst_file1_info = dst_fs.getinfo("test.txt", namespaces)
+            dst_file2_info = dst_fs.getinfo("foo/bar/baz.txt", namespaces)
+            self.assertEqual(dst_file1_info.modified, src_file1_info.modified)
+            self.assertEqual(dst_file2_info.modified, src_file2_info.modified)
+
+    def test_move_dir(self):
+        namespaces = ("details", "modified")
+
         src_fs = open_fs("mem://")
         src_fs.makedirs("foo/bar")
         src_fs.touch("test.txt")
         src_fs.touch("foo/bar/baz.txt")
+        src_file2_info = src_fs.getinfo("foo/bar/baz.txt", namespaces)
 
         dst_fs = open_fs("mem://")
-        fs.move.move_dir(src_fs, "/foo", dst_fs, "/")
+        dst_fs.create("test.txt")
+        dst_fs.setinfo("test.txt", {"details": {"modified": 1000000}})
+
+        fs.move.move_dir(src_fs, "/foo", dst_fs, "/", preserve_time=self.preserve_time)
 
         self.assertTrue(dst_fs.isdir("bar"))
         self.assertTrue(dst_fs.isfile("bar/baz.txt"))
         self.assertFalse(src_fs.exists("foo"))
+        self.assertTrue(src_fs.isfile("test.txt"))
+
+        if self.preserve_time:
+            dst_file2_info = dst_fs.getinfo("bar/baz.txt", namespaces)
+            self.assertEqual(dst_file2_info.modified, src_file2_info.modified)
diff --git a/tests/test_opener.py b/tests/test_opener.py
index e7fae98..bc2f5cd 100644
--- a/tests/test_opener.py
+++ b/tests/test_opener.py
@@ -300,14 +300,26 @@ class TestOpeners(unittest.TestCase):
     def test_open_ftp(self, mock_FTPFS):
         open_fs("ftp://foo:bar@ftp.example.org")
         mock_FTPFS.assert_called_once_with(
-            "ftp.example.org", passwd="bar", port=21, user="foo", proxy=None, timeout=10, tls=False
+            "ftp.example.org",
+            passwd="bar",
+            port=21,
+            user="foo",
+            proxy=None,
+            timeout=10,
+            tls=False,
         )
 
     @mock.patch("fs.ftpfs.FTPFS")
     def test_open_ftps(self, mock_FTPFS):
         open_fs("ftps://foo:bar@ftp.example.org")
         mock_FTPFS.assert_called_once_with(
-            "ftp.example.org", passwd="bar", port=21, user="foo", proxy=None, timeout=10, tls=True
+            "ftp.example.org",
+            passwd="bar",
+            port=21,
+            user="foo",
+            proxy=None,
+            timeout=10,
+            tls=True,
         )
 
     @mock.patch("fs.ftpfs.FTPFS")
diff --git a/tests/test_osfs.py b/tests/test_osfs.py
index 88879ec..ff3af9e 100644
--- a/tests/test_osfs.py
+++ b/tests/test_osfs.py
@@ -7,6 +7,7 @@ import os
 import shutil
 import tempfile
 import sys
+import time
 import unittest
 import warnings
 
@@ -96,6 +97,22 @@ class TestOSFS(FSTestCases, unittest.TestCase):
         self.assertIn("TYRIONLANISTER", fs1.getsyspath("/"))
         self.assertNotIn("TYRIONLANISTER", fs2.getsyspath("/"))
 
+    def test_copy_preserve_time(self):
+        self.fs.makedir("foo")
+        self.fs.makedir("bar")
+        self.fs.create("foo/file.txt")
+        raw_info = {"details": {"modified": time.time() - 10000}}
+        self.fs.setinfo("foo/file.txt", raw_info)
+
+        namespaces = ("details", "modified")
+        src_info = self.fs.getinfo("foo/file.txt", namespaces)
+
+        self.fs.copy("foo/file.txt", "bar/file.txt", preserve_time=True)
+        self.assertTrue(self.fs.exists("bar/file.txt"))
+
+        dst_info = self.fs.getinfo("bar/file.txt", namespaces)
+        self.assertEqual(dst_info.modified, src_info.modified)
+
     @unittest.skipUnless(osfs.sendfile, "sendfile not supported")
     @unittest.skipIf(
         sys.version_info >= (3, 8),
diff --git a/tests/test_wrap.py b/tests/test_wrap.py
index 89a9118..e11ded4 100644
--- a/tests/test_wrap.py
+++ b/tests/test_wrap.py
@@ -177,7 +177,7 @@ class TestWrapCachedDir(unittest.TestCase):
         ]
         with mock.patch.object(self.fs, "scandir", wraps=self.fs.scandir) as scandir:
             self.assertEqual(sorted(self.cached.scandir("/"), key=key), expected)
-            scandir.assert_has_calls([mock.call('/', namespaces=None, page=None)])
+            scandir.assert_has_calls([mock.call("/", namespaces=None, page=None)])
         with mock.patch.object(self.fs, "scandir", wraps=self.fs.scandir) as scandir:
             self.assertEqual(sorted(self.cached.scandir("/"), key=key), expected)
             scandir.assert_not_called()
@@ -187,7 +187,7 @@ class TestWrapCachedDir(unittest.TestCase):
             self.assertTrue(self.cached.isdir("foo"))
             self.assertFalse(self.cached.isdir("egg"))  # is file
             self.assertFalse(self.cached.isdir("spam"))  # doesn't exist
-            scandir.assert_has_calls([mock.call('/', namespaces=None, page=None)])
+            scandir.assert_has_calls([mock.call("/", namespaces=None, page=None)])
         with mock.patch.object(self.fs, "scandir", wraps=self.fs.scandir) as scandir:
             self.assertTrue(self.cached.isdir("foo"))
             self.assertFalse(self.cached.isdir("egg"))
@@ -199,7 +199,7 @@ class TestWrapCachedDir(unittest.TestCase):
             self.assertTrue(self.cached.isfile("egg"))
             self.assertFalse(self.cached.isfile("foo"))  # is dir
             self.assertFalse(self.cached.isfile("spam"))  # doesn't exist
-            scandir.assert_has_calls([mock.call('/', namespaces=None, page=None)])
+            scandir.assert_has_calls([mock.call("/", namespaces=None, page=None)])
         with mock.patch.object(self.fs, "scandir", wraps=self.fs.scandir) as scandir:
             self.assertTrue(self.cached.isfile("egg"))
             self.assertFalse(self.cached.isfile("foo"))
@@ -211,7 +211,7 @@ class TestWrapCachedDir(unittest.TestCase):
             self.assertEqual(self.cached.getinfo("foo"), self.fs.getinfo("foo"))
             self.assertEqual(self.cached.getinfo("/"), self.fs.getinfo("/"))
             self.assertNotFound(self.cached.getinfo, "spam")
-            scandir.assert_has_calls([mock.call('/', namespaces=None, page=None)])
+            scandir.assert_has_calls([mock.call("/", namespaces=None, page=None)])
         with mock.patch.object(self.fs, "scandir", wraps=self.fs.scandir) as scandir:
             self.assertEqual(self.cached.getinfo("foo"), self.fs.getinfo("foo"))
             self.assertEqual(self.cached.getinfo("/"), self.fs.getinfo("/"))