Repository: glotzerlab/signac
Pull request #869 (2022-12-06 04:37:41)

Move syncutil code into sync.py.
<!-- Provide a general summary of your changes in the title above. -->

## Description
<!-- Describe your changes in detail. -->
<!-- Please indicate if the changes may break existing functionality. -->
This PR merges the syncutil and sync modules.

## Motivation and Context
<!-- Why is this change required? What problem does it solve? -->
<!-- If it fixes an open issue, please link to the issue here. -->
The code in syncutil is only every used by sync, so removing this indirection makes the code more discoverable by removing an unnecessary layer of indirection.

## Checklist:
<!-- This checklist must be complete before merging the pull request. -->
<!-- If you are unsure about any of these items, do not hesitate to ask! -->
- [x] I am familiar with the [Contributing Guidelines](https://github.com/glotzerlab/signac/blob/master/CONTRIBUTING.md).
- [x] I agree with the terms of the [Contributor Agreement](https://github.com/glotzerlab/signac/blob/master/ContributorAgreement.md).
- [x] My name is on the [list of contributors](https://github.com/glotzerlab/signac/blob/master/contributors.yaml).
- [x] The changes introduced by this pull request are covered by existing or newly introduced tests.
- [ ] The [package documentation](https://github.com/glotzerlab/signac/tree/master/doc) and [framework documentation](https://docs.signac.io/) in [signac-docs](https://github.com/glotzerlab/signac-docs) are up to date with these changes.
- [ ] I have updated the [changelog](https://github.com/glotzerlab/signac/blob/master/changelog.txt) and added any related issue and pull request numbers for future reference.


codecov[bot]: # [Codecov](https://codecov.io/gh/glotzerlab/signac/pull/869?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab) Report
> Merging [#869](https://codecov.io/gh/glotzerlab/signac/pull/869?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab) (a82d1fa) into [master](https://codecov.io/gh/glotzerlab/signac/commit/3de5f485af1d96a420d3cfca0d3e97f9e3e8f25b?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab) (3de5f48) will **decrease** coverage by `7.99%`.
> The diff coverage is `80.14%`.

```diff
@@            Coverage Diff             @@
##           master     #869      +/-   ##
==========================================
- Coverage   86.53%   78.54%   -8.00%     
==========================================
  Files          41       40       -1     
  Lines        4531     4526       -5     
  Branches      901      901              
==========================================
- Hits         3921     3555     -366     
- Misses        429      800     +371     
+ Partials      181      171      -10     
```


| [Impacted Files](https://codecov.io/gh/glotzerlab/signac/pull/869?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab) | Coverage Δ | |
|---|---|---|
| [signac/sync.py](https://codecov.io/gh/glotzerlab/signac/pull/869/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab#diff-c2lnbmFjL3N5bmMucHk=) | `81.43% <80.14%> (-1.07%)` | :arrow_down: |
| [signac/h5store.py](https://codecov.io/gh/glotzerlab/signac/pull/869/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab#diff-c2lnbmFjL2g1c3RvcmUucHk=) | `26.55% <0.00%> (-64.49%)` | :arrow_down: |
| [signac/\_dict\_manager.py](https://codecov.io/gh/glotzerlab/signac/pull/869/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab#diff-c2lnbmFjL19kaWN0X21hbmFnZXIucHk=) | `33.72% <0.00%> (-54.66%)` | :arrow_down: |
| [...\_synced\_collections/backends/collection\_mongodb.py](https://codecov.io/gh/glotzerlab/signac/pull/869/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab#diff-c2lnbmFjL19zeW5jZWRfY29sbGVjdGlvbnMvYmFja2VuZHMvY29sbGVjdGlvbl9tb25nb2RiLnB5) | `52.27% <0.00%> (-38.64%)` | :arrow_down: |
| [signac/\_synced\_collections/numpy\_utils.py](https://codecov.io/gh/glotzerlab/signac/pull/869/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab#diff-c2lnbmFjL19zeW5jZWRfY29sbGVjdGlvbnMvbnVtcHlfdXRpbHMucHk=) | `64.00% <0.00%> (-36.00%)` | :arrow_down: |
| [...ac/\_synced\_collections/backends/collection\_zarr.py](https://codecov.io/gh/glotzerlab/signac/pull/869/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab#diff-c2lnbmFjL19zeW5jZWRfY29sbGVjdGlvbnMvYmFja2VuZHMvY29sbGVjdGlvbl96YXJyLnB5) | `54.00% <0.00%> (-34.00%)` | :arrow_down: |
| [signac/\_synced\_collections/utils.py](https://codecov.io/gh/glotzerlab/signac/pull/869/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab#diff-c2lnbmFjL19zeW5jZWRfY29sbGVjdGlvbnMvdXRpbHMucHk=) | `84.37% <0.00%> (-10.94%)` | :arrow_down: |
| [signac/\_synced\_collections/validators.py](https://codecov.io/gh/glotzerlab/signac/pull/869/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab#diff-c2lnbmFjL19zeW5jZWRfY29sbGVjdGlvbnMvdmFsaWRhdG9ycy5weQ==) | `80.85% <0.00%> (-10.64%)` | :arrow_down: |
| [signac/project.py](https://codecov.io/gh/glotzerlab/signac/pull/869/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab#diff-c2lnbmFjL3Byb2plY3QucHk=) | `82.90% <0.00%> (-7.09%)` | :arrow_down: |
| [signac/job.py](https://codecov.io/gh/glotzerlab/signac/pull/869/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab#diff-c2lnbmFjL2pvYi5weQ==) | `82.78% <0.00%> (-6.96%)` | :arrow_down: |
| ... and [6 more](https://codecov.io/gh/glotzerlab/signac/pull/869/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab) | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab)


Patch:
diff --git a/changelog.txt b/changelog.txt
index 0e678b68..9baa08f3 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -35,6 +35,7 @@ Changed
  - The package namespace has been flattened so that most functionality is directly available in the ``signac`` namespace (#756, #868).
  - The ``calc_id`` function has been moved from the ``hashing`` module to the ``job`` module (#873).
  - Tests are run with all warnings treated as errors (#871).
+ - The contents of the ``syncutil`` module have been moved into ``sync`` (#869).
 
 Removed
 +++++++
diff --git a/signac/sync.py b/signac/sync.py
index f0ad7f82..4d5e98e1 100644
--- a/signac/sync.py
+++ b/signac/sync.py
@@ -73,21 +73,25 @@ a regular expression, so the synchronization above could also be achieved with:
 
     dst_job.sync(src_job, doc_sync=sync.DocSync.ByKey('foo'))
 """
+import logging
 import os
 import re
+import shutil
 from collections import defaultdict as ddict
 from collections import namedtuple
 from collections.abc import Mapping
+from contextlib import contextmanager
+from copy import deepcopy
+from filecmp import cmpfiles, dircmp
 from multiprocessing.pool import ThreadPool
 
-from ._utility import _query_yes_no
+from ._utility import _query_yes_no, _safe_relpath
 from .errors import (
     DestinationExistsError,
     DocumentSyncConflict,
     FileSyncConflict,
     SchemaSyncConflict,
 )
-from .syncutil import _FileModifyProxy, dircmp, dircmp_deep, logger
 
 __all__ = [
     "FileSync",
@@ -96,6 +100,269 @@ __all__ = [
     "sync_projects",
 ]
 
+# Definition of helpers for syncing
+
+
+_LEVEL_MORE = logging.INFO - 5
+
+logger = logging.getLogger("sync")
+logging.addLevelName(_LEVEL_MORE, "MORE")
+logging.MORE = _LEVEL_MORE  # type: ignore
+
+
+def _log_more(msg, *args, **kwargs):
+    """Log using _LEVEL_MORE."""
+    logger.log(_LEVEL_MORE, msg, *args, **kwargs)
+
+
+logger.more = _log_more  # type: ignore
+
+
+class _dircmp_deep(dircmp):
+    """Deep directory comparator."""
+
+    def phase3(self):
+        """Find out differences between common files."""
+        xx = cmpfiles(self.left, self.right, self.common_files, shallow=False)
+        self.same_files, self.diff_files, self.funny_files = xx
+
+    methodmap = dict(dircmp.methodmap)
+    # The type check for the following line must be ignored.
+    # See: https://github.com/python/mypy/issues/708
+    methodmap["same_files"] = methodmap["diff_files"] = phase3  # type: ignore
+
+
+class _DocProxy:
+    """Proxy object for document (mapping) modifications.
+
+    This proxy is used to keep track of changes and ensure that
+    dry runs do not actually modify any data.
+
+    Parameters
+    ----------
+    doc : dict
+        Document data.
+    dry_run : bool, optional
+        Do not actually perform any data modification operation, but still log
+        the action (Default value = False).
+
+    """
+
+    def __init__(self, doc, dry_run=False):
+        self.doc = doc
+        self.dry_run = dry_run
+
+    def __str__(self):
+        return f"_DocProxy({str(self.doc)})"
+
+    def __repr__(self):
+        return f"_DocProxy({repr(self.doc)})"
+
+    def __getitem__(self, key):
+        return self.doc[key]
+
+    def __setitem__(self, key, value):
+        logger.more(f"Set '{key}'='{value}'.")
+        if not self.dry_run:
+            self.doc[key] = value
+
+    def keys(self):
+        """Return keys of proxy data."""
+        return self.doc.keys()
+
+    def clear(self):
+        """Clear proxy data."""
+        self.doc.clear()
+
+    def update(self, other):
+        """Update proxy data with other."""
+        for key in other.keys():
+            self[key] = other[key]
+
+    def __iter__(self):
+        return iter(self.doc)
+
+    def __contains__(self, key):
+        return key in self.doc
+
+    def __eq__(self, other):
+        return self.doc.__eq__(other)
+
+    def __len__(self):
+        return len(self.doc)
+
+
+class _FileModifyProxy:
+    """Proxy used for data modification.
+
+    This proxy is used for all file data modification to keep
+    track of changes and to ensure that dry runs do not actually
+    modify any data.
+
+    Parameters
+    ----------
+    root : str, optional
+        Root path.
+    follow_symlinks : bool, optional
+        Whether to follow symlinks (Default value = True).
+    permissions : bool, optional
+        Whether to preserve permissions (Default value = False).
+    times : bool, optional
+        Whether to preserve timestamps (Default value = False).
+    owner : bool, optional
+        Whether to preserve owner (Default value = False).
+    group : bool, optional
+        Whether to preserve group (Default value = False).
+    dry_run : bool, optional
+        If True, do not actually perform any data modification operation, but still log
+        the action (Default value = False).
+    collect_stats : bool, optional
+        Whether to collect stats (Default value = False).
+
+    """
+
+    def __init__(
+        self,
+        root=None,
+        follow_symlinks=True,
+        permissions=False,
+        times=False,
+        owner=False,
+        group=False,
+        dry_run=False,
+        collect_stats=False,
+    ):
+        self.root = root
+        self.follow_symlinks = follow_symlinks
+        self.permissions = permissions
+        self.times = times
+        self.owner = owner
+        self.group = group
+        self.dry_run = dry_run
+        self.stats = dict(num_files=0, volume=0) if collect_stats else None
+
+    # Internal proxy functions
+
+    def _copy(self, src, dst):
+        """Copy src to dst."""
+        if not self.dry_run:
+            shutil.copy(src, dst)
+
+    def _copy_p(self, src, dst):
+        """Copy src to dst with permissions."""
+        if not self.dry_run:
+            shutil.copy(src, dst)
+            shutil.copymode(src, dst)
+
+    def _copy2(self, src, dst):
+        """Copy src to dst with preserved metadata."""
+        if not self.dry_run:
+            shutil.copy2(src, dst)
+
+    def _remove(self, path):
+        """Remove path."""
+        if not self.dry_run:
+            os.remove(path)
+
+    # Public functions
+
+    def remove(self, path):
+        """Remove path."""
+        logger.more(f"Remove path '{_safe_relpath(path)}'.")
+        self._remove(path)
+
+    def copy(self, src, dst):
+        """Copy src to dst."""
+        if self.dry_run and self.root is not None:
+            print(_safe_relpath(src, self.root))
+        if os.path.islink(src) and not self.follow_symlinks:
+            link_target = os.readlink(src)
+            logger.more(
+                "Creating link '{}' -> '{}'.".format(
+                    _safe_relpath(dst), _safe_relpath(link_target)
+                )
+            )
+            if os.path.isfile(dst):
+                self.remove(dst)
+            if not self.dry_run:
+                os.symlink(link_target, dst)
+        else:
+            msg = "Copy file '{}' -> '{}'.".format(
+                _safe_relpath(src), _safe_relpath(dst)
+            )
+            if self.permissions and self.times:
+                logger.more(msg.format(" (preserving: permissions, times)"))
+                self._copy2(src, dst)
+            elif self.permissions:
+                logger.more(msg.format(" (preserving: permissions)"))
+                self._copy_p(src, dst)
+            elif self.times:
+                raise ValueError("Cannot copy timestamps without permissions.")
+            else:
+                logger.more(msg.format(""))
+                self._copy(src, dst)
+            if self.owner or self.group or self.stats is not None:
+                stat = os.stat(src)
+                if self.stats is not None:
+                    self.stats["num_files"] += 1
+                    self.stats["volume"] += stat.st_size
+                if self.owner or self.group:
+                    logger.more(
+                        "Copy owner/group '{}' -> '{}'".format(
+                            _safe_relpath(src), _safe_relpath(dst)
+                        )
+                    )
+                    if not self.dry_run:
+                        os.chown(
+                            dst,
+                            uid=stat.st_uid if self.owner else -1,
+                            gid=stat.st_gid if self.group else -1,
+                        )
+
+    def copytree(self, src, dst, **kwargs):
+        """Copy tree src to dst."""
+        logger.more(f"Copy tree '{_safe_relpath(src)}' -> '{_safe_relpath(dst)}'.")
+        shutil.copytree(src, dst, copy_function=self.copy, **kwargs)
+
+    @contextmanager
+    def create_backup(self, path):
+        """Create a backup of path."""
+        logger.debug(f"Create backup of '{_safe_relpath(path)}'.")
+        path_backup = path + "~"
+        if os.path.isfile(path_backup):
+            raise RuntimeError(
+                "Failed to create backup, file already exists: '{}'.".format(
+                    _safe_relpath(path_backup)
+                )
+            )
+        try:
+            self._copy2(path, path_backup)
+            yield path_backup
+        except:  # noqa roll-back
+            logger.more("Error occurred, restoring backup...")
+            self._copy2(path_backup, path)
+            raise
+        finally:
+            logger.debug(f"Remove backup of '{_safe_relpath(path)}'.")
+            self._remove(path_backup)
+
+    @contextmanager
+    def create_doc_backup(self, doc):
+        """Create a backup of doc."""
+        proxy = _DocProxy(doc, dry_run=self.dry_run)
+        fn = getattr(doc, "filename", getattr(doc, "_filename", None))
+        if not len(proxy) or fn is None or not os.path.isfile(fn):
+            backup = deepcopy(doc)  # use in-memory backup
+            try:
+                yield proxy
+            except:  # noqa roll-back
+                proxy.clear()
+                proxy.update(backup)
+                raise
+        else:
+            with self.create_backup(fn):
+                yield proxy
+
 
 # Definition of default sync strategies
 
@@ -209,7 +476,7 @@ def _sync_job_workspaces(
 ):
     """Synchronize two job workspaces file by file, following the provided strategy."""
     if deep:
-        diff = dircmp_deep(src.fn(subdir), dst.fn(subdir))
+        diff = _dircmp_deep(src.fn(subdir), dst.fn(subdir))
     else:
         diff = dircmp(src.fn(subdir), dst.fn(subdir))
 
diff --git a/signac/syncutil.py b/signac/syncutil.py
deleted file mode 100644
index 874c8f1d..00000000
--- a/signac/syncutil.py
+++ /dev/null
@@ -1,274 +0,0 @@
-# Copyright (c) 2020 The Regents of the University of Michigan
-# All rights reserved.
-# This software is licensed under the BSD 3-Clause License.
-"""Utilities for synchronization."""
-
-import filecmp
-import logging
-import os
-import shutil
-from contextlib import contextmanager
-from copy import deepcopy
-from filecmp import dircmp
-
-from ._utility import _safe_relpath
-
-LEVEL_MORE = logging.INFO - 5
-
-logger = logging.getLogger("sync")
-logging.addLevelName(LEVEL_MORE, "MORE")
-logging.MORE = LEVEL_MORE  # type: ignore
-
-
-def log_more(msg, *args, **kwargs):
-    """Log using LEVEL_MORE."""
-    logger.log(LEVEL_MORE, msg, *args, **kwargs)
-
-
-logger.more = log_more  # type: ignore
-
-
-class dircmp_deep(dircmp):
-    """Deep directory comparator."""
-
-    def phase3(self):
-        """Find out differences between common files."""
-        xx = filecmp.cmpfiles(self.left, self.right, self.common_files, shallow=False)
-        self.same_files, self.diff_files, self.funny_files = xx
-
-    methodmap = dict(dircmp.methodmap)
-    # The type check for the following line must be ignored.
-    # See: https://github.com/python/mypy/issues/708
-    methodmap["same_files"] = methodmap["diff_files"] = phase3  # type: ignore
-
-
-class _DocProxy:
-    """Proxy object for document (mapping) modifications.
-
-    This proxy is used to keep track of changes and ensure that
-    dry runs do not actually modify any data.
-
-    Parameters
-    ----------
-    doc : dict
-        Document data.
-    dry_run : bool, optional
-        Do not actually perform any data modification operation, but still log
-        the action (Default value = False).
-
-    """
-
-    def __init__(self, doc, dry_run=False):
-        self.doc = doc
-        self.dry_run = dry_run
-
-    def __str__(self):
-        return f"_DocProxy({str(self.doc)})"
-
-    def __repr__(self):
-        return f"_DocProxy({repr(self.doc)})"
-
-    def __getitem__(self, key):
-        return self.doc[key]
-
-    def __setitem__(self, key, value):
-        logger.more(f"Set '{key}'='{value}'.")
-        if not self.dry_run:
-            self.doc[key] = value
-
-    def keys(self):
-        """Return keys of proxy data."""
-        return self.doc.keys()
-
-    def clear(self):
-        """Clear proxy data."""
-        self.doc.clear()
-
-    def update(self, other):
-        """Update proxy data with other."""
-        for key in other.keys():
-            self[key] = other[key]
-
-    def __iter__(self):
-        return iter(self.doc)
-
-    def __contains__(self, key):
-        return key in self.doc
-
-    def __eq__(self, other):
-        return self.doc.__eq__(other)
-
-    def __len__(self):
-        return len(self.doc)
-
-
-class _FileModifyProxy:
-    """Proxy used for data modification.
-
-    This proxy is used for all file data modification to keep
-    track of changes and to ensure that dry runs do not actually
-    modify any data.
-
-    Parameters
-    ----------
-    root : str, optional
-        Root path.
-    follow_symlinks : bool, optional
-        Whether to follow symlinks (Default value = True).
-    permissions : bool, optional
-        Whether to preserve permissions (Default value = False).
-    times : bool, optional
-        Whether to preserve timestamps (Default value = False).
-    owner : bool, optional
-        Whether to preserve owner (Default value = False).
-    group : bool, optional
-        Whether to preserve group (Default value = False).
-    dry_run : bool, optional
-        If True, do not actually perform any data modification operation, but still log
-        the action (Default value = False).
-    collect_stats : bool, optional
-        Whether to collect stats (Default value = False).
-
-    """
-
-    def __init__(
-        self,
-        root=None,
-        follow_symlinks=True,
-        permissions=False,
-        times=False,
-        owner=False,
-        group=False,
-        dry_run=False,
-        collect_stats=False,
-    ):
-        self.root = root
-        self.follow_symlinks = follow_symlinks
-        self.permissions = permissions
-        self.times = times
-        self.owner = owner
-        self.group = group
-        self.dry_run = dry_run
-        self.stats = dict(num_files=0, volume=0) if collect_stats else None
-
-    # Internal proxy functions
-
-    def _copy(self, src, dst):
-        """Copy src to dst."""
-        if not self.dry_run:
-            shutil.copy(src, dst)
-
-    def _copy_p(self, src, dst):
-        """Copy src to dst with permissions."""
-        if not self.dry_run:
-            shutil.copy(src, dst)
-            shutil.copymode(src, dst)
-
-    def _copy2(self, src, dst):
-        """Copy src to dst with preserved metadata."""
-        if not self.dry_run:
-            shutil.copy2(src, dst)
-
-    def _remove(self, path):
-        """Remove path."""
-        if not self.dry_run:
-            os.remove(path)
-
-    # Public functions
-
-    def remove(self, path):
-        """Remove path."""
-        logger.more(f"Remove path '{_safe_relpath(path)}'.")
-        self._remove(path)
-
-    def copy(self, src, dst):
-        """Copy src to dst."""
-        if self.dry_run and self.root is not None:
-            print(_safe_relpath(src, self.root))
-        if os.path.islink(src) and not self.follow_symlinks:
-            link_target = os.readlink(src)
-            logger.more(
-                "Creating link '{}' -> '{}'.".format(
-                    _safe_relpath(dst), _safe_relpath(link_target)
-                )
-            )
-            if os.path.isfile(dst):
-                self.remove(dst)
-            if not self.dry_run:
-                os.symlink(link_target, dst)
-        else:
-            msg = "Copy file '{}' -> '{}'.".format(
-                _safe_relpath(src), _safe_relpath(dst)
-            )
-            if self.permissions and self.times:
-                logger.more(msg.format(" (preserving: permissions, times)"))
-                self._copy2(src, dst)
-            elif self.permissions:
-                logger.more(msg.format(" (preserving: permissions)"))
-                self._copy_p(src, dst)
-            elif self.times:
-                raise ValueError("Cannot copy timestamps without permissions.")
-            else:
-                logger.more(msg.format(""))
-                self._copy(src, dst)
-            if self.owner or self.group or self.stats is not None:
-                stat = os.stat(src)
-                if self.stats is not None:
-                    self.stats["num_files"] += 1
-                    self.stats["volume"] += stat.st_size
-                if self.owner or self.group:
-                    logger.more(
-                        "Copy owner/group '{}' -> '{}'".format(
-                            _safe_relpath(src), _safe_relpath(dst)
-                        )
-                    )
-                    if not self.dry_run:
-                        os.chown(
-                            dst,
-                            uid=stat.st_uid if self.owner else -1,
-                            gid=stat.st_gid if self.group else -1,
-                        )
-
-    def copytree(self, src, dst, **kwargs):
-        """Copy tree src to dst."""
-        logger.more(f"Copy tree '{_safe_relpath(src)}' -> '{_safe_relpath(dst)}'.")
-        shutil.copytree(src, dst, copy_function=self.copy, **kwargs)
-
-    @contextmanager
-    def create_backup(self, path):
-        """Create a backup of path."""
-        logger.debug(f"Create backup of '{_safe_relpath(path)}'.")
-        path_backup = path + "~"
-        if os.path.isfile(path_backup):
-            raise RuntimeError(
-                "Failed to create backup, file already exists: '{}'.".format(
-                    _safe_relpath(path_backup)
-                )
-            )
-        try:
-            self._copy2(path, path_backup)
-            yield path_backup
-        except:  # noqa roll-back
-            logger.more("Error occurred, restoring backup...")
-            self._copy2(path_backup, path)
-            raise
-        finally:
-            logger.debug(f"Remove backup of '{_safe_relpath(path)}'.")
-            self._remove(path_backup)
-
-    @contextmanager
-    def create_doc_backup(self, doc):
-        """Create a backup of doc."""
-        proxy = _DocProxy(doc, dry_run=self.dry_run)
-        fn = getattr(doc, "filename", getattr(doc, "_filename", None))
-        if not len(proxy) or fn is None or not os.path.isfile(fn):
-            backup = deepcopy(doc)  # use in-memory backup
-            try:
-                yield proxy
-            except:  # noqa roll-back
-                proxy.clear()
-                proxy.update(backup)
-                raise
-        else:
-            with self.create_backup(fn):
-                yield proxy


Test patch:
diff --git a/tests/test_sync.py b/tests/test_sync.py
index 733fda8f..758fc709 100644
--- a/tests/test_sync.py
+++ b/tests/test_sync.py
@@ -13,8 +13,7 @@ import signac
 from signac import JSONDict, sync
 from signac._utility import _mkdir_p
 from signac.errors import DocumentSyncConflict, FileSyncConflict, SchemaSyncConflict
-from signac.sync import _FileModifyProxy
-from signac.syncutil import _DocProxy
+from signac.sync import _DocProxy, _FileModifyProxy
 
 
 def touch(fname, mode=0o666, dir_fd=None, **kwargs):