Repository: AcademySoftwareFoundation/rez
Pull request #1689 (2024-03-17 17:00:46)

Add releasing packages documentation
Added a new page for releasing packages since there doesn't seem to be any for the `rez-release` workflow yet.

BryceGattis: I don't believe the `release_vcs` configuration setting is documented at all yet on the "configuring Rez" page. Can someone sanity check me here...?
BryceGattis: Once the `release_vcs` setting is documented, I think it'd be nice to split out package build and package release configuration settings into 2 different sections, rather than just the [single section as it is now](http://localhost:63342/rez/docs/_build/html/configuring_rez.html#package-build-release) for clarity.
JeanChristopheMorinPerso: >I don't believe the release_vcs configuration setting is documented at all yet on the "configuring Rez" page. Can someone sanity check me here...?

Plugins settings are not yet documented. Doing so would require some work since settings are right now documented in multiple places. For example, technically the canonical source should be in https://github.com/AcademySoftwareFoundation/rez/blob/main/src/rezplugins/release_vcs/rezconfig, but it only documents and sets one default value and the rest is in https://github.com/AcademySoftwareFoundation/rez/blob/main/src/rez/rezconfig.py#L1201-L1242.

At the very least, if you want to have `plugins` shown as a section in https://rez.readthedocs.io/en/stable/configuring_rez.html#configuration-settings, you would have to remove [this empty line](https://github.com/AcademySoftwareFoundation/rez/blob/main/src/rez/rezconfig.py#L1200). I didn't do it because the result is ugly and not accurate.

If you are curious, the code that generates https://rez.readthedocs.io/en/stable/configuring_rez.html#configuration-settings is at https://github.com/AcademySoftwareFoundation/rez/blob/1d18bc267fb6e237b56e0297952be95d8e325542/docs/rez_sphinxext.py#L19-L173
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/AcademySoftwareFoundation/rez/pull/1689?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AcademySoftwareFoundation) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 57.58%. Comparing base [(`dc2c777`)](https://app.codecov.io/gh/AcademySoftwareFoundation/rez/commit/dc2c77757748e87d2abf0967924ee0195aa0f11d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AcademySoftwareFoundation) to head [(`d456a6e`)](https://app.codecov.io/gh/AcademySoftwareFoundation/rez/pull/1689?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AcademySoftwareFoundation).
> Report is 4 commits behind head on main.


<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1689      +/-   ##
==========================================
- Coverage   58.05%   57.58%   -0.47%     
==========================================
  Files         126      126              
  Lines       17035    17035              
  Branches     3490     3490              
==========================================
- Hits         9889     9809      -80     
- Misses       6482     6556      +74     
- Partials      664      670       +6     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/AcademySoftwareFoundation/rez/pull/1689?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AcademySoftwareFoundation).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AcademySoftwareFoundation).


Patch:
diff --git a/ASWF/ONBOARDING.md b/ASWF/ONBOARDING.md
index f4338242..d9d5a7a5 100644
--- a/ASWF/ONBOARDING.md
+++ b/ASWF/ONBOARDING.md
@@ -7,11 +7,11 @@ https://github.com/AcademySoftwareFoundation/asfw-sample-project/blob/main/tsc/p
 
 Misc stuff not covered in the ASWF list
 
-- [ ] Move from master to main branch [#1203](/../../issues/1203)
-- [ ] Remove hardcoded references to `nerdvegas` where possible (some of these won't make sense to do until after gh migration) [#1205](/../../issues/1205)
+- [X] Move from master to main branch [#1203](/../../issues/1203)
+- [X] Remove hardcoded references to `nerdvegas` where possible (some of these won't make sense to do until after gh migration) [#1205](/../../issues/1205)
   - TODO: inline links in comments (will need a script to batch convert)
   - TODO: README.md
-- [ ] Update Github slack integration [#1204](/../../issues/1204)
+- [X] Update Github slack integration [#1204](/../../issues/1204)
 
 # ASWF Onboarding Checklist
 - Existing Project Governance
@@ -25,11 +25,10 @@ Misc stuff not covered in the ASWF list
   - [X] A [CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md) file that sets the ground rules for participants’ behavior associated and helps to facilitate a friendly, welcoming environment. By default, projects should leverage the Linux Foundation Code of Conduct unless an alternate Code of Conduct was previously approved. [#1227](/../../issues/1227)
   - [ ] A [RELEASE.md](process/release.md) file that provides documentation on the release methodology, cadence, criteria, etc.
     - (exists but requires review)
-  - [ ] A [GOVERNANCE.md](../GOVERNANCE.md) file that documents the project’s technical governance. [#1229](/../../issues/1229)
+  - [X] A [GOVERNANCE.md](../GOVERNANCE.md) file that documents the project’s technical governance. [#1229](/../../issues/1229)
   - [ ] A [SUPPORT.md](../SUPPORT.md) file to let users and developers know about ways to get help with your project. [#1230](/../../issues/1230)
 - Infrastructure/Assets
-  - [!] License scan completed and no issues found 
-    - **NOTE: we need to fix: https://github.com/AcademySoftwareFoundation/rez/issues/1349**
+  - [X] License scan completed and no issues found 
   - [X] Code repository imported to ASWF GitHub organization or ownership of current GitHub organization given to `thelinuxfoundation` user
     - [X] Developer Certificate of Origin past commit signoff done and DCO Probot enabled.
   - [X] Issue/feature tracker established (JIRA, GitHub issues)
@@ -48,16 +47,16 @@ Misc stuff not covered in the ASWF list
 - New Project Goverance
   - [X] TSC members identified
   - [X] First TSC meeting held
-  - [ ] TSC meeting cadence set and added to project calendar
-  - [ ] CLA Approved ( if used ) ( [CCLA](ccla.md) and [ICLA](icla.md) )
+  - [X] TSC meeting cadence set and added to project calendar
+  - [x] CLA Approved ( if used ) ( [CCLA](ccla.md) and [ICLA](icla.md) )
   - Project charter ( [charter.md](charter.md) )
-    - [ ] Approved by TSC
-    - [ ] Filed ( create pull request against [foundation repo](https://github.com/AcademySoftwareFoundation/foundation) )
+    - [x] Approved by TSC
+    - [x] Filed ( create pull request against [foundation repo](https://github.com/AcademySoftwareFoundation/foundation) )
   - [ ] [Core Infrastructure Initiative Best Practices Badge](https://bestpractices.coreinfrastructure.org/) achieved as the 'Passing' level.
-  - [ ] TAC representative appointed
+  - [X] TAC representative appointed
 - Outreach
   - [X] New project annoucement done ( create [issue on foundation repo](https://github.com/AcademySoftwareFoundation/foundation/issues/new) to trigger )
-  - [ ] Project added to ASWF website and ASWF landscape
+  - [X] Project added to ASWF website and ASWF landscape
 - Adopted Stage graduation requirements
   - [ ] CII Badge   achieved
   - [ ] Demonstrate a substantial ongoing flow of commits and merged contributions, authored by a healthy number of diverse contributors*.
diff --git a/docs/README.md b/docs/README.md
index 5c94e70a..aba4ad4d 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,6 +1,9 @@
 # Build instructions
 
-To build the docs you must use Python 3.11.
+To build the docs you must install the following requirements:
+
+- GNU Make (Linux + Mac only)
+- Python 3.11
 
 To create a build environment run the following commands:
 ```python
@@ -9,7 +12,7 @@ source .venv/bin/activate
 pip install -r docs/requirements.txt
 
 cd docs
-make html
+make html # .\make html on Windows
 ```
 
 # Example Headers
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 12196b60..676021b0 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -15,6 +15,7 @@ Welcome to rez's documentation!
    getting_started
    basic_concepts
    building_packages
+   releasing_packages
    context
    variants
    guides/index
diff --git a/docs/source/releasing_packages.rst b/docs/source/releasing_packages.rst
new file mode 100644
index 00000000..a75ad5f1
--- /dev/null
+++ b/docs/source/releasing_packages.rst
@@ -0,0 +1,27 @@
+==================
+Releasing packages
+==================
+
+Rez packages can be built and deployed to the :data:`release_packages_path`
+using the :ref:`rez-release` tool. This tool performs the following actions:
+
+* All :doc:`actions <building_packages>` that the ``rez-build`` tool performs.
+* Executes all configured :data:`release_hooks`.
+* Executes all configured :gh-rez:`src/rezplugins/release_vcs` plugins.
+
+When to release vs build
+============================
+Packages *can* be installed to the :data:`release_packages_path` manually
+by running :option:`rez-build -i --prefix /path/to/repo <rez-build --prefix>`, so why would you use
+``rez-release``?
+
+Well, here are a few benefits of ``rez-release``:
+
+* The package will automatically go to the configured :data:`release_packages_path`, whereas :ref:`rez-build` will go to the :data:`local_packages_path` by default.
+* Tests being run by with the :ref:`rez-test` tool can run specifically prior to release, ensuring that releases pass any configured tests first.
+* Automatic sanity checks to ensure local repo is ready for release.
+* Automatic VCS tagging.
+* Many :ref:`helpful package attributes <release-package-attributes>` are automatically added to the released package definition file.
+
+If you're working locally, it these additional steps and checks may slow you
+down, so it may be better to stick with :ref:`rez-build`.
diff --git a/install.py b/install.py
index 43b7c81c..70d630dc 100644
--- a/install.py
+++ b/install.py
@@ -147,19 +147,19 @@ def copy_completion_scripts(dest_dir):
     return None
 
 
-def install(dest_dir, print_welcome=False):
+def install(dest_dir, print_welcome=False, editable=False):
     """Install rez into the given directory.
 
     Args:
         dest_dir (str): Full path to the install directory.
     """
-    print("installing rez to %s..." % dest_dir)
+    print("installing rez%s to %s..." % (" (editable mode)" if editable else "", dest_dir))
 
     # create the virtualenv
     create_virtual_environment(dest_dir)
 
     # install rez from source
-    install_rez_from_source(dest_dir)
+    install_rez_from_source(dest_dir, editable=editable)
 
     # patch the rez binaries
     patch_rez_binaries(dest_dir)
@@ -222,11 +222,15 @@ def install(dest_dir, print_welcome=False):
         print('')
 
 
-def install_rez_from_source(dest_dir):
+def install_rez_from_source(dest_dir, editable):
     _, py_executable = get_virtualenv_py_executable(dest_dir)
 
     # install via pip
-    run_command([py_executable, "-m", "pip", "install", "."])
+    args = [py_executable, "-m", "pip", "install"]
+    if editable:
+        args.append("-e")
+    args.append(".")
+    run_command(args)
 
 
 def install_as_rez_package(repo_path):
@@ -280,6 +284,11 @@ if __name__ == "__main__":
         help="Install rez as a rez package. Note that this installs the API "
         "only (no cli tools), and DIR is expected to be the path to a rez "
         "package repository (and will default to ~/packages instead).")
+    parser.add_argument(
+        "-e", "--editable", action="store_true",
+        help="Make the install an editable install (pip install -e). This should "
+        "only be used for development purposes"
+    )
     parser.add_argument(
         "DIR", nargs='?',
         help="Destination directory. If '{version}' is present, it will be "
@@ -314,4 +323,4 @@ if __name__ == "__main__":
     if opts.as_rez_package:
         install_as_rez_package(dest_dir)
     else:
-        install(dest_dir, print_welcome=True)
+        install(dest_dir, print_welcome=True, editable=opts.editable)
diff --git a/src/rez/package_cache.py b/src/rez/package_cache.py
index 086000e2..7ab833bd 100644
--- a/src/rez/package_cache.py
+++ b/src/rez/package_cache.py
@@ -28,6 +28,7 @@ from rez.utils.colorize import ColorizedStreamHandler
 from rez.utils.logging_ import print_warning
 from rez.packages import get_variant
 from rez.system import system
+from rez.utils.filesystem import rename
 
 
 class PackageCache(object):
@@ -330,7 +331,7 @@ class PackageCache(object):
                     os.chmod(rootpath, st.st_mode | stat.S_IWUSR)
 
                 # actually a mv
-                os.rename(rootpath, dest_rootpath)
+                rename(rootpath, dest_rootpath)
 
             except OSError as e:
                 if e.errno == errno.ENOENT:
diff --git a/src/rez/package_order.py b/src/rez/package_order.py
index e00839fe..a71dbac0 100644
--- a/src/rez/package_order.py
+++ b/src/rez/package_order.py
@@ -4,10 +4,40 @@
 
 from inspect import isclass
 from hashlib import sha1
+from typing import Dict, Iterable, List, Optional, Union
 
 from rez.config import config
 from rez.utils.data_utils import cached_class_property
-from rez.version import Version
+from rez.version import Version, VersionRange
+from rez.version._version import _Comparable, _ReversedComparable, _LowerBound, _UpperBound, _Bound
+from rez.packages import iter_packages
+
+ALL_PACKAGES = "*"
+
+
+class FallbackComparable(_Comparable):
+    """First tries to compare objects using the main_comparable, but if that
+    fails, compares using the fallback_comparable object.
+    """
+
+    def __init__(self, main_comparable, fallback_comparable):
+        self.main_comparable = main_comparable
+        self.fallback_comparable = fallback_comparable
+
+    def __eq__(self, other):
+        try:
+            return self.main_comparable == other.main_comparable
+        except Exception:
+            return self.fallback_comparable == other.fallback_comparable
+
+    def __lt__(self, other):
+        try:
+            return self.main_comparable < other.main_comparable
+        except Exception:
+            return self.fallback_comparable < other.fallback_comparable
+
+    def __repr__(self):
+        return '%s(%r, %r)' % (type(self).__name__, self.main_comparable, self.fallback_comparable)
 
 
 class PackageOrder(object):
@@ -16,8 +46,32 @@ class PackageOrder(object):
     #: Orderer name
     name = None
 
-    def __init__(self):
-        pass
+    def __init__(self, packages: Optional[Iterable[str]] = None):
+        """
+        Args:
+            packages: If not provided, PackageOrder applies to all packages.
+        """
+        self.packages = packages
+
+    @property
+    def packages(self) -> List[str]:
+        """Returns an iterable over the list of package family names that this
+        order applies to
+
+        Returns:
+            (Iterable[str]) Package families that this orderer is used for
+        """
+        return self._packages
+
+    @packages.setter
+    def packages(self, packages: Union[str, Iterable[str]]):
+        if packages is None:
+            # Apply to all packages
+            self._packages = [ALL_PACKAGES]
+        elif isinstance(packages, str):
+            self._packages = [packages]
+        else:
+            self._packages = sorted(packages)
 
     def reorder(self, iterable, key=None):
         """Put packages into some order for consumption.
@@ -40,11 +94,75 @@ class PackageOrder(object):
         Returns:
             list: Reordered ``iterable``
         """
+        key = key or (lambda x: x)
+        package_name = self._get_package_name_from_iterable(iterable, key=key)
+        return sorted(iterable,
+                      key=lambda x: self.sort_key(package_name, key(x).version),
+                      reverse=True)
+
+    @staticmethod
+    def _get_package_name_from_iterable(iterable, key=None):
+        """Utility method for getting a package from an iterable"""
+        try:
+            item = next(iter(iterable))
+        except (TypeError, StopIteration):
+            return None
+
+        key = key or (lambda x: x)
+        return key(item).name
+
+    def sort_key(self, package_name, version_like):
+        """Returns a sort key usable for sorting packages within the same family
+
+        Args:
+            package_name: (str) The family name of the package we are sorting
+            version_like: (Version|_LowerBound|_UpperBound|_Bound|VersionRange)
+                the version-like object you wish to generate a key for
+
+        Returns:
+            Sortable object
+                The returned object must be sortable, which means that it must implement __lt__.
+                The specific return type is not important.
+        """
+        if isinstance(version_like, VersionRange):
+            return tuple(self.sort_key(package_name, bound) for bound in version_like.bounds)
+        elif isinstance(version_like, _Bound):
+            return (self.sort_key(package_name, version_like.lower),
+                    self.sort_key(package_name, version_like.upper))
+        elif isinstance(version_like, _LowerBound):
+            inclusion_key = -2 if version_like.inclusive else -1
+            return self.sort_key(package_name, version_like.version), inclusion_key
+        elif isinstance(version_like, _UpperBound):
+            inclusion_key = 2 if version_like.inclusive else 1
+            return self.sort_key(package_name, version_like.version), inclusion_key
+        elif isinstance(version_like, Version):
+            # finally, the bit that we actually use the sort_key_implementation for.
+            return FallbackComparable(
+                self.sort_key_implementation(package_name, version_like), version_like)
+        else:
+            raise TypeError(version_like)
+
+    def sort_key_implementation(self, package_name, version):
+        """Returns a sort key usable for sorting these packages within the
+        same family
+        Args:
+            package_name: (str) The family name of the package we are sorting
+            version: (Version) the version object you wish to generate a key for
+
+        Returns:
+            Sortable object
+                The returned object must be sortable, which means that it must implement __lt__.
+                The specific return type is not important.
+        """
         raise NotImplementedError
 
     def to_pod(self):
         raise NotImplementedError
 
+    @classmethod
+    def from_pod(cls, data):
+        raise NotImplementedError
+
     @property
     def sha1(self):
         return sha1(repr(self).encode('utf-8')).hexdigest()
@@ -53,7 +171,7 @@ class PackageOrder(object):
         raise NotImplementedError
 
     def __eq__(self, other):
-        raise NotImplementedError
+        return type(self) == type(other) and str(self) == str(other)
 
     def __ne__(self, other):
         return not self == other
@@ -71,8 +189,10 @@ class NullPackageOrder(PackageOrder):
     """
     name = "no_order"
 
-    def reorder(self, iterable, key=None):
-        return list(iterable)
+    def sort_key_implementation(self, package_name, version):
+        # python's sort will preserve the order of items that compare equal, so
+        # to not change anything, we just return the same object for all...
+        return 0
 
     def __str__(self):
         return "{}"
@@ -87,12 +207,15 @@ class NullPackageOrder(PackageOrder):
         .. code-block:: yaml
 
            type: no_order
+           packages: ["foo"]
         """
-        return {}
+        return {
+            "packages": self.packages,
+        }
 
     @classmethod
     def from_pod(cls, data):
-        return cls()
+        return cls(packages=data.get("packages"))
 
 
 class SortedOrder(PackageOrder):
@@ -100,13 +223,22 @@ class SortedOrder(PackageOrder):
     """
     name = "sorted"
 
-    def __init__(self, descending):
+    def __init__(self, descending, packages=None):
+        super().__init__(packages)
         self.descending = descending
 
-    def reorder(self, iterable, key=None):
-        key = key or (lambda x: x)
-        return sorted(iterable, key=lambda x: key(x).version,
-                      reverse=self.descending)
+    def sort_key_implementation(self, package_name, version):
+        # Note that the name "descending" can be slightly confusing - it
+        # indicates that the final ordering this Order gives should be
+        # version descending (ie, the default) - however, the sort_key itself
+        # returns its results in "normal" ascending order (because it needs to
+        # be used "alongside" normally-sorted objects like versions).
+        # when the key is passed to sort(), though, it is always invoked with
+        # reverse=True...
+        if self.descending:
+            return version
+        else:
+            return _ReversedComparable(version)
 
     def __str__(self):
         return str(self.descending)
@@ -125,12 +257,19 @@ class SortedOrder(PackageOrder):
 
            type: sorted
            descending: true
+           packages: ["foo"]
         """
-        return {"descending": self.descending}
+        return {
+            "descending": self.descending,
+            "packages": self.packages,
+        }
 
     @classmethod
     def from_pod(cls, data):
-        return cls(descending=data["descending"])
+        return cls(
+            data["descending"],
+            packages=data.get("packages"),
+        )
 
 
 class PerFamilyOrder(PackageOrder):
@@ -147,19 +286,16 @@ class PerFamilyOrder(PackageOrder):
             default_order (PackageOrder): Orderer to apply to any packages
                 not specified in ``order_dict``.
         """
+        super().__init__(list(order_dict))
         self.order_dict = order_dict.copy()
         self.default_order = default_order
 
     def reorder(self, iterable, key=None):
-        try:
-            item = next(iter(iterable))
-        except:
+        package_name = self._get_package_name_from_iterable(iterable, key)
+        if package_name is None:
             return None
 
-        key = key or (lambda x: x)
-        package = key(item)
-
-        orderer = self.order_dict.get(package.name)
+        orderer = self.order_dict.get(package_name)
         if orderer is None:
             orderer = self.default_order
         if orderer is None:
@@ -167,6 +303,19 @@ class PerFamilyOrder(PackageOrder):
 
         return orderer.reorder(iterable, key)
 
+    def sort_key_implementation(self, package_name, version):
+        orderer = self.order_dict.get(package_name)
+        if orderer is None:
+            if self.default_order is None:
+                # shouldn't get here, because applies_to should protect us...
+                raise RuntimeError(
+                    "package family orderer %r does not apply to package family %r",
+                    (self, package_name))
+
+            orderer = self.default_order
+
+        return orderer.sort_key_implementation(package_name, version)
+
     def __str__(self):
         items = sorted((x[0], str(x[1])) for x in self.order_dict.items())
         return str((items, str(self.default_order)))
@@ -247,35 +396,18 @@ class VersionSplitPackageOrder(PackageOrder):
     """
     name = "version_split"
 
-    def __init__(self, first_version):
+    def __init__(self, first_version, packages=None):
         """Create a reorderer.
 
         Args:
             first_version (Version): Start with versions <= this value.
         """
+        super().__init__(packages)
         self.first_version = first_version
 
-    def reorder(self, iterable, key=None):
-        key = key or (lambda x: x)
-
-        # sort by version descending
-        descending = sorted(iterable, key=lambda x: key(x).version, reverse=True)
-
-        above = []
-        below = []
-        is_above = True
-
-        for item in descending:
-            if is_above:
-                package = key(item)
-                is_above = (package.version > self.first_version)
-
-            if is_above:
-                above.append(item)
-            else:
-                below.append(item)
-
-        return below + above
+    def sort_key_implementation(self, package_name, version):
+        priority_key = 1 if version <= self.first_version else 0
+        return priority_key, version
 
     def __str__(self):
         return str(self.first_version)
@@ -294,12 +426,19 @@ class VersionSplitPackageOrder(PackageOrder):
 
            type: version_split
            first_version: "3.0.0"
+           packages: ["foo"]
         """
-        return dict(first_version=str(self.first_version))
+        return dict(
+            first_version=str(self.first_version),
+            packages=self.packages,
+        )
 
     @classmethod
     def from_pod(cls, data):
-        return cls(Version(data["first_version"]))
+        return cls(
+            Version(data["first_version"]),
+            packages=data.get("packages"),
+        )
 
 
 class TimestampPackageOrder(PackageOrder):
@@ -345,7 +484,7 @@ class TimestampPackageOrder(PackageOrder):
     """
     name = "soft_timestamp"
 
-    def __init__(self, timestamp, rank=0):
+    def __init__(self, timestamp, rank=0, packages=None):
         """Create a reorderer.
 
         Args:
@@ -354,73 +493,84 @@ class TimestampPackageOrder(PackageOrder):
             rank (int): If non-zero, allow version changes at this rank or above
                 past the timestamp.
         """
+        super().__init__(packages)
         self.timestamp = timestamp
         self.rank = rank
 
-    def reorder(self, iterable, key=None):
+        # dictionary mapping from package family to the first-version-after
+        # the given timestamp
+        self._cached_first_after = {}
+        self._cached_sort_key = {}
+
+    def _get_first_after(self, package_family):
+        """Get the first package version that is after the timestamp"""
+        try:
+            first_after = self._cached_first_after[package_family]
+        except KeyError:
+            first_after = self._calc_first_after(package_family)
+            self._cached_first_after[package_family] = first_after
+        return first_after
+
+    def _calc_first_after(self, package_family):
+        descending = sorted(iter_packages(package_family),
+                            key=lambda p: p.version,
+                            reverse=True)
         first_after = None
-        key = key or (lambda x: x)
+        for i, package in enumerate(descending):
+            if not package.timestamp:
+                continue
+            if package.timestamp > self.timestamp:
+                first_after = package.version
+            else:
+                break
+
+        if not self.rank:
+            return first_after
+
+        # if we have rank, then we need to then go back UP the
+        # versions, until we find one whose trimmed version doesn't
+        # match.
+        # Note that we COULD do this by simply iterating through
+        # an ascending sequence, in which case we wouldn't have to
+        # "switch direction" after finding the first result after
+        # by timestamp... but we're making the assumption that the
+        # timestamp break will be closer to the higher end of the
+        # version, and that we'll therefore have to check fewer
+        # timestamps this way...
+        trimmed_version = package.version.trim(self.rank - 1)
+        first_after = None
+        for after_package in reversed(descending[:i]):
+            if after_package.version.trim(self.rank - 1) != trimmed_version:
+                return after_package.version
 
-        # sort by version descending
-        descending = sorted(iterable, key=lambda x: key(x).version, reverse=True)
+        return first_after
 
-        for i, o in enumerate(descending):
-            package = key(o)
-            if package.timestamp:
-                if package.timestamp > self.timestamp:
-                    first_after = i
-                else:
-                    break
+    def _calc_sort_key(self, package_name, version):
+        first_after = self._get_first_after(package_name)
+        if first_after is None:
+            # all packages are before T
+            is_before = True
+        else:
+            is_before = int(version < first_after)
 
-        if first_after is None:  # all packages are before T
-            return None
+        if is_before:
+            return is_before, version
 
-        before = descending[first_after + 1:]
-        after = list(reversed(descending[:first_after + 1]))
-
-        if not self.rank:  # simple case
-            return before + after
-
-        # include packages after timestamp but within rank
-        if before and after:
-            package = key(before[0])
-            first_prerank = package.version.trim(self.rank - 1)
-            found = False
-
-            for i, o in enumerate(after):
-                package = key(o)
-                prerank = package.version.trim(self.rank - 1)
-                if prerank != first_prerank:
-                    found = True
-                    break
-
-            if not found:
-                # highest version is also within rank, so result is just
-                # simple descending list
-                return descending
-
-            if i:
-                before = list(reversed(after[:i])) + before
-                after = after[i:]
-
-        # ascend below rank, but descend within
-        after_ = []
-        postrank = []
-        prerank = None
-
-        for o in after:
-            package = key(o)
-            prerank_ = package.version.trim(self.rank - 1)
-
-            if prerank_ == prerank:
-                postrank.append(o)
-            else:
-                after_.extend(reversed(postrank))
-                postrank = [o]
-                prerank = prerank_
+        if self.rank:
+            return (is_before,
+                    _ReversedComparable(version.trim(self.rank - 1)),
+                    version.tokens[self.rank - 1:])
+
+        return is_before, _ReversedComparable(version)
 
-        after_.extend(reversed(postrank))
-        return before + after_
+    def sort_key_implementation(self, package_name, version):
+        cache_key = (package_name, str(version))
+        result = self._cached_sort_key.get(cache_key)
+        if result is None:
+            result = self._calc_sort_key(package_name, version)
+            self._cached_sort_key[cache_key] = result
+
+        return result
 
     def __str__(self):
         return str((self.timestamp, self.rank))
@@ -441,23 +591,34 @@ class TimestampPackageOrder(PackageOrder):
            type: soft_timestamp
            timestamp: 1234567
            rank: 3
+           packages: ["foo"]
         """
-        return dict(timestamp=self.timestamp,
-                    rank=self.rank)
+        return dict(
+            timestamp=self.timestamp,
+            rank=self.rank,
+            packages=self.packages,
+        )
 
     @classmethod
     def from_pod(cls, data):
-        return cls(timestamp=data["timestamp"], rank=data.get("rank", 0))
+        return cls(
+            data["timestamp"],
+            rank=data.get("rank", 0),
+            packages=data.get("packages"),
+        )
 
 
 class PackageOrderList(list):
     """A list of package orderer.
     """
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.by_package: Dict[str, PackageOrder] = {}
+        self.dirty = True
+
     def to_pod(self):
-        data = []
-        for f in self:
-            data.append(to_pod(f))
-        return data
+        return [to_pod(f) for f in self]
 
     @classmethod
     def from_pod(cls, data):
@@ -472,6 +633,58 @@ class PackageOrderList(list):
         """Filter list as configured by rezconfig.package_filter."""
         return cls.from_pod(config.package_orderers)
 
+    @staticmethod
+    def _to_orderer(orderer: Union[dict, PackageOrder]) -> PackageOrder:
+        if isinstance(orderer, dict):
+            orderer = from_pod(orderer)
+        return orderer
+
+    def refresh(self) -> None:
+        """Update the internal order-by-package mapping"""
+        self.by_package = {}
+        for orderer in self:
+            orderer = self._to_orderer(orderer)
+            for package in orderer.packages:
+                # We allow duplicates (so we can have hierarchical configs,
+                # which can override each other) - earlier orderers win
+                if package in self.by_package:
+                    continue
+                self.by_package[package] = orderer
+
+    def append(self, *args, **kwargs):
+        self.dirty = True
+        return super().append(*args, **kwargs)
+
+    def extend(self, *args, **kwargs):
+        self.dirty = True
+        return super().extend(*args, **kwargs)
+
+    def pop(self, *args, **kwargs):
+        self.dirty = True
+        return super().pop(*args, **kwargs)
+
+    def remove(self, *args, **kwargs):
+        self.dirty = True
+        return super().remove(*args, **kwargs)
+
+    def clear(self, *args, **kwargs):
+        self.dirty = True
+        return super().clear(*args, **kwargs)
+
+    def insert(self, *args, **kwargs):
+        self.dirty = True
+        return super().insert(*args, **kwargs)
+
+    def get(self, key: str, default: Optional[PackageOrder] = None) -> PackageOrder:
+        """
+        Get an orderer that sorts a package by name.
+        """
+        if self.dirty:
+            self.refresh()
+            self.dirty = False
+        result = self.by_package.get(key, default)
+        return result
+
 
 def to_pod(orderer):
     data = {"type": orderer.name}
@@ -494,6 +707,18 @@ def from_pod(data):
         return cls.from_pod(data_)
 
 
+def get_orderer(package_name, orderers=None):
+    if orderers is None:
+        orderers = PackageOrderList.singleton
+    orderer = orderers.get(package_name)
+    if orderer is None:
+        orderer = orderers.get(ALL_PACKAGES)
+    if orderer is None:
+        # default ordering is version descending
+        orderer = SortedOrder(descending=True)
+    return orderer
+
+
 def register_orderer(cls):
     """Register an orderer
 
diff --git a/src/rez/resolved_context.py b/src/rez/resolved_context.py
index bbf68fef..9ef3ae09 100644
--- a/src/rez/resolved_context.py
+++ b/src/rez/resolved_context.py
@@ -233,7 +233,7 @@ class ResolvedContext(object):
         self.package_filter = (PackageFilterList.singleton if package_filter is None
                                else package_filter)
 
-        self.package_orderers = (
+        self.package_orderers = PackageOrderList(
             PackageOrderList.singleton if package_orderers is None
             else package_orderers
         )
@@ -1527,9 +1527,10 @@ class ResolvedContext(object):
             data["patch_locks"] = dict((k, v.name) for k, v in self.patch_locks)
 
         if _add("package_orderers"):
-            package_orderers = [package_order.to_pod(x)
-                                for x in (self.package_orderers or [])]
-            data["package_orderers"] = package_orderers or None
+            if self.package_orderers:
+                data["package_orderers"] = self.package_orderers.to_pod()
+            else:
+                data["package_orderers"] = None
 
         if _add("package_filter"):
             data["package_filter"] = self.package_filter.to_pod()
diff --git a/src/rez/rezconfig.py b/src/rez/rezconfig.py
index ca1f14b1..d5366e97 100644
--- a/src/rez/rezconfig.py
+++ b/src/rez/rezconfig.py
@@ -423,7 +423,7 @@ package_filter = None
 # One or more "orderers" can be listed.
 # This will affect the order of version resolution.
 # This can be used to ensure that specific version have priority over others.
-# Higher versions can still be accessed if required.
+# Higher versions can still be accessed if explicitly requested.
 #
 # A common use case is to ease migration from python-2 to python-3:
 #
@@ -452,6 +452,19 @@ package_filter = None
 # rez-env python-3     python-3.7.4
 # ==================== =============
 #
+#
+# Package orderers will also apply to variants of packages.
+# Consider a package "pipeline-1.0" which has the following variants:
+# ``[["python-2.7.4", "python-2.7.16", "python-3.7.4"]]``
+#
+# ============================= ==========================
+# Example                       Result
+# ============================= ==========================
+# rez-env pipeline              pipeline-1.0 python-2.7.16
+# rez-env pipeline python-3     pipeline-1.0 python-3.7.4
+# ============================= ==========================
+#
+#
 # Here's another example, using another orderer: "soft_timestamp".
 # This orderer will prefer packages released before a provided timestamp.
 # The following example will prefer package released before 2019-09-09.
@@ -824,7 +837,7 @@ build_thread_count = "physical_cores"
 # a plugin listed here is not present, a warning message is printed. Note that a
 # release hook plugin being loaded does not mean it will run. It needs to be
 # listed here as well. Several built-in release hooks are available, see
-# rezplugins/release_hook.
+# :gh-rez:`src/rezplugins/release_hook`.
 release_hooks = []
 
 # Prompt for release message using an editor. If set to False, there will be
diff --git a/src/rez/solver.py b/src/rez/solver.py
index 7fc8ee59..b128dbc8 100644
--- a/src/rez/solver.py
+++ b/src/rez/solver.py
@@ -415,6 +415,8 @@ class _PackageEntry(object):
             here as a safety measure so that sorting is guaranteed repeatable
             regardless.
         """
+        from rez.package_order import get_orderer
+
         if self.sorted:
             return
 
@@ -426,13 +428,17 @@ class _PackageEntry(object):
                 if not request.conflict:
                     req = variant.requires_list.get(request.name)
                     if req is not None:
-                        requested_key.append((-i, req.range))
+                        orderer = get_orderer(req.name, orderers=self.solver.package_orderers or {})
+                        range_key = orderer.sort_key(req.name, req.range)
+                        requested_key.append((-i, range_key))
                         names.add(req.name)
 
             additional_key = []
             for request in variant.requires_list:
                 if not request.conflict and request.name not in names:
-                    additional_key.append((request.range, request.name))
+                    orderer = get_orderer(request.name, orderers=self.solver.package_orderers)
+                    range_key = orderer.sort_key(request.name, request.range)
+                    additional_key.append((range_key, request.name))
 
             if (VariantSelectMode[config.variant_select_mode] == VariantSelectMode.version_priority):
                 k = (requested_key,
@@ -802,6 +808,7 @@ class _PackageVariantSlice(_Common):
 
         if not fams:
             # trivial case, split on first variant
+            self.entries[0].sort()
             return _split(0, 1)
 
         # find split point - first variant with no dependency shared with previous
@@ -831,25 +838,21 @@ class _PackageVariantSlice(_Common):
         The order is typically descending, but package order functions can
         change this.
         """
+        from rez.package_order import get_orderer
+
         if self.sorted:
             return
 
-        for orderer in (self.solver.package_orderers or []):
-            entries = orderer.reorder(self.entries, key=lambda x: x.package)
-            if entries is not None:
-                self.entries = entries
-                self.sorted = True
+        orderer = get_orderer(self.package_name, orderers=self.solver.package_orderers or {})
 
-                if self.pr:
-                    self.pr("sorted: %s packages: %s", self.package_name, repr(orderer))
-                return
+        def sort_key(entry):
+            return orderer.sort_key(entry.package.name, entry.version)
 
-        # default ordering is version descending
-        self.entries = sorted(self.entries, key=lambda x: x.version, reverse=True)
+        self.entries = sorted(self.entries, key=sort_key, reverse=True)
         self.sorted = True
 
         if self.pr:
-            self.pr("sorted: %s packages: version descending", self.package_name)
+            self.pr("sorted: %s packages: %s", self.package_name, repr(orderer))
 
     def dump(self):
         print(self.package_name)
diff --git a/src/rez/utils/filesystem.py b/src/rez/utils/filesystem.py
index eb825f97..fcf0db4f 100644
--- a/src/rez/utils/filesystem.py
+++ b/src/rez/utils/filesystem.py
@@ -23,7 +23,8 @@ import platform
 import uuid
 
 from rez.utils.platform_ import platform_
-
+from rez.util import which
+from rez.utils.execution import Popen
 
 is_windows = platform.system() == "Windows"
 
@@ -213,6 +214,7 @@ def forceful_rmtree(path):
         * path length over 259 char (on Windows)
         * unicode path
     """
+
     def _on_error(func, path, exc_info):
         try:
             if is_windows:
@@ -281,7 +283,7 @@ def replace_file_or_dir(dest, source):
 
     if not os.path.exists(dest):
         try:
-            os.rename(source, dest)
+            rename(source, dest)
             return
         except:
             if not os.path.exists(dest):
@@ -294,8 +296,8 @@ def replace_file_or_dir(dest, source):
         pass
 
     with make_tmp_name(dest) as tmp_dest:
-        os.rename(dest, tmp_dest)
-        os.rename(source, dest)
+        rename(dest, tmp_dest)
+        rename(source, dest)
 
 
 def additive_copytree(src, dst, symlinks=False, ignore=None):
@@ -690,3 +692,55 @@ def windows_long_path(dos_path):
         path = "\\\\?\\" + path
 
     return path
+
+
+def rename(src, dst):
+    """Utility function to rename a file or folder src to dst with retrying.
+
+    This function uses the built-in `os.rename()` function and falls back to `robocopy` tool
+    if `os.rename` raises a `PermissionError` exception.
+
+    Args:
+        src (str): The original name (path) of the file or folder.
+        dst (str): The new name (path) for the file or folder.
+
+    Raises:
+        OSError: If renaming fails after all attempts.
+
+    """
+    # Inspired by https://github.com/conan-io/conan/blob/2.1.0/conan/tools/files/files.py#L207
+    try:
+        os.rename(src, dst)
+    except PermissionError as err:
+        if is_windows and which("robocopy") and os.path.isdir(src):
+            # https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/robocopy
+            args = [
+                "robocopy",
+                # /move Moves files and directories, and deletes them from the source after they are copied.
+                "/move",
+                # /e Copies subdirectories. Note that this option includes empty directories.
+                "/e",
+                # /ndl Specifies that directory names are not to be logged.
+                "/ndl",
+                # /nfl Specifies that file names are not to be logged.
+                "/nfl",
+                # /njs Specifies that there's no job summary.
+                "/njs",
+                # /njh Specifies that there's no job header.
+                "/njh",
+                # /np Specifies that the progress of the copying operation
+                # (the number of files or directories copied so far) won't be displayed.
+                "/np",
+                # /ns Specifies that file sizes aren't to be logged.
+                "/ns",
+                # /nc Specifies that file classes aren't to be logged.
+                "/nc",
+                src,
+                dst,
+            ]
+            process = Popen(args)
+            process.communicate()
+            if process.returncode > 7:  # https://ss64.com/nt/robocopy-exit.html
+                raise OSError("Rename {} to {} failed.".format(src, dst))
+        else:
+            raise err
diff --git a/src/rez/version/_version.py b/src/rez/version/_version.py
index 2114f5bf..42c2caa4 100644
--- a/src/rez/version/_version.py
+++ b/src/rez/version/_version.py
@@ -28,8 +28,11 @@ class _ReversedComparable(_Common):
     def __init__(self, value):
         self.value = value
 
+    def __eq__(self, other):
+        return self.value == other.value
+
     def __lt__(self, other):
-        return not (self.value < other.value)
+        return self.value > other.value
 
     def __gt__(self, other):
         return not (self < other or self == other)
@@ -41,7 +44,7 @@ class _ReversedComparable(_Common):
         return not self < other
 
     def __str__(self):
-        return "reverse(%s)" % str(self.value)
+        return f"reverse({self.value!r})"
 
     def __repr__(self):
         return "reverse(%r)" % self.value


Test patch:
diff --git a/src/rez/data/tests/solver/packages/pyvariants/2/package.py b/src/rez/data/tests/solver/packages/pyvariants/2/package.py
index ed06df9d..0374627d 100644
--- a/src/rez/data/tests/solver/packages/pyvariants/2/package.py
+++ b/src/rez/data/tests/solver/packages/pyvariants/2/package.py
@@ -1,4 +1,9 @@
 name = "pyvariants"
 version = "2"
 
-variants = [["python-2.7.0"], ["python-2.6.8", "nada"]]
+variants = [
+    ["python-2.7.0"],
+    ["python-2.6.8", "nada"],
+    ["python-2.6.8"],
+    ["nada"],
+]
diff --git a/src/rez/tests/test_context.py b/src/rez/tests/test_context.py
index ee742a8d..65cefffb 100644
--- a/src/rez/tests/test_context.py
+++ b/src/rez/tests/test_context.py
@@ -186,6 +186,70 @@ class TestContext(TestBase, TempdirMixin):
 
             _test_bundle(bundle_path3)
 
+    def test_orderer_variants(self):
+        """Test that package orderers apply to the variants of packages, not just requires."""
+        from rez.package_order import PerFamilyOrder, VersionSplitPackageOrder
+        from rez.version import Version
+
+        packages_path = self.data_path("solver", "packages")
+
+        # Verify that we get the latest python without an orderer
+        r = ResolvedContext(
+            ["pyvariants", "!nada"],
+            package_paths=[packages_path],
+        )
+        resolved = [x.qualified_package_name for x in r.resolved_packages]
+        self.assertEqual(resolved, ['python-2.7.0', 'pyvariants-2'])
+
+        # Now verify that we correctly get python-2.6.8 when the orderer demands it.
+        orderers = [PerFamilyOrder({
+            "python": VersionSplitPackageOrder(Version("2.6.8")),
+        })]
+        r = ResolvedContext(
+            ["pyvariants", "!nada"],
+            package_orderers=orderers,
+            package_paths=[packages_path],
+        )
+        resolved = [x.qualified_package_name for x in r.resolved_packages]
+        self.assertEqual(resolved, ['python-2.6.8', 'pyvariants-2'])
+
+    def test_orderer_package_argument(self):
+        """Test that the packages argument to an orderer gives expected results."""
+        from rez.package_order import VersionSplitPackageOrder
+        from rez.version import Version
+
+        packages_path = self.data_path("solver", "packages")
+
+        # Verify that the orderer applies to all packages.
+        orderers = [VersionSplitPackageOrder(Version("2.6.8"))]
+        r = ResolvedContext(
+            ["python"],
+            package_orderers=orderers,
+            package_paths=[packages_path],
+        )
+        resolved = [x.qualified_package_name for x in r.resolved_packages]
+        self.assertEqual(resolved, ['python-2.6.8'])
+
+        # Verify that the orderer applies to a specified package.
+        orderers = [VersionSplitPackageOrder(Version("2.6.8"), packages="python")]
+        r = ResolvedContext(
+            ["python"],
+            package_orderers=orderers,
+            package_paths=[packages_path],
+        )
+        resolved = [x.qualified_package_name for x in r.resolved_packages]
+        self.assertEqual(resolved, ['python-2.6.8'])
+
+        # Verify that the orderer does not apply to a packages that are not requested.
+        orderers = [VersionSplitPackageOrder(Version("2.6.8"), packages="pyfoo")]
+        r = ResolvedContext(
+            ["python"],
+            package_orderers=orderers,
+            package_paths=[packages_path],
+        )
+        resolved = [x.qualified_package_name for x in r.resolved_packages]
+        self.assertEqual(resolved, ['python-2.7.0'])
+
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/src/rez/tests/test_packages_order.py b/src/rez/tests/test_packages_order.py
index 702963f5..1138b86f 100644
--- a/src/rez/tests/test_packages_order.py
+++ b/src/rez/tests/test_packages_order.py
@@ -53,11 +53,6 @@ class _BaseTestPackagesOrder(TestBase, TempdirMixin):
 class TestAbstractPackageOrder(TestBase):
     """Test case for the abstract PackageOrder class"""
 
-    def test_reorder(self):
-        """Validate reorder is not implemented"""
-        with self.assertRaises(NotImplementedError):
-            PackageOrder().reorder([])
-
     def test_to_pod(self):
         """Validate to_pod is not implemented"""
         self.assertRaises(NotImplementedError, PackageOrder().to_pod)
diff --git a/src/rez/tests/test_utils_filesystem.py b/src/rez/tests/test_utils_filesystem.py
new file mode 100644
index 00000000..cccbfe49
--- /dev/null
+++ b/src/rez/tests/test_utils_filesystem.py
@@ -0,0 +1,88 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright Contributors to the Rez Project
+
+
+"""
+unit tests for 'rez.utils.filesystem' module
+"""
+import os.path
+import tempfile
+
+from rez.tests.util import TestBase
+from rez.tests.util import TempdirMixin
+from rez.utils import filesystem
+from rez.utils.platform_ import platform_
+import unittest
+
+
+class TestFileSystem(TestBase, TempdirMixin):
+
+    def __init__(self, *nargs, **kwargs):
+        super().__init__(*nargs, **kwargs)
+
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+        TempdirMixin.setUpClass()
+
+    @classmethod
+    def tearDownClass(cls):
+        super().tearDownClass()
+        TempdirMixin.tearDownClass()
+
+    def test_windows_rename_fallback_to_robocopy(self):
+        if platform_.name != 'windows':
+            self.skipTest('Robocopy is only available on windows.')
+        src = tempfile.mkdtemp(dir=self.root)
+        dst = tempfile.mkdtemp(dir=self.root)
+        with unittest.mock.patch("os.rename") as mock_rename:
+            mock_rename.side_effect = PermissionError("Permission denied")
+            filesystem.rename(src, dst)
+            self.assertTrue(os.path.exists(dst))
+            self.assertFalse(os.path.exists(src))
+
+    def test_windows_robocopy_failed(self):
+        if platform_.name != 'windows':
+            self.skipTest('Robocopy is only available on windows.')
+        src = tempfile.mkdtemp(dir=self.root)
+        dst = tempfile.mkdtemp(dir=self.root)
+        with unittest.mock.patch("os.rename") as mock_rename:
+            mock_rename.side_effect = PermissionError("Permission denied")
+            with unittest.mock.patch("rez.utils.filesystem.Popen") as mock_subprocess:
+                mock_subprocess.return_value = unittest.mock.Mock(returncode=9)
+                with self.assertRaises(OSError) as err:
+                    filesystem.rename(src, dst)
+                self.assertEqual(str(err.exception), "Rename {} to {} failed.".format(src, dst))
+
+    def test_rename_folder_with_permission_error_and_no_robocopy(self):
+        src = tempfile.mkdtemp(dir=self.root)
+        dst = tempfile.mkdtemp(dir=self.root)
+        with unittest.mock.patch("os.rename") as mock_rename:
+            mock_rename.side_effect = PermissionError("Permission denied")
+            with unittest.mock.patch("rez.utils.filesystem.which") as mock_which:
+                mock_which.return_value = False
+                with self.assertRaises(PermissionError) as err:
+                    filesystem.rename(src, dst)
+                self.assertEqual(str(err.exception), "Permission denied")
+
+    def test_rename_folder_with_permission_error_and_src_is_file(self):
+        src = tempfile.mktemp(dir=self.root)
+        dst = tempfile.mktemp(dir=self.root)
+        with open(src, "w") as file_:
+            file_.write("content.")
+        with unittest.mock.patch("os.rename") as mock_rename:
+            mock_rename.side_effect = PermissionError("Permission denied")
+            with self.assertRaises(PermissionError) as err:
+                filesystem.rename(src, dst)
+            self.assertEqual(str(err.exception), "Permission denied")
+        self.assertFalse(os.path.exists(dst))
+        self.assertTrue(os.path.exists(src))
+
+    def test_rename_file(self):
+        src = tempfile.mktemp(dir=self.root)
+        dst = tempfile.mktemp(dir=self.root)
+        with open(src, "w") as file_:
+            file_.write("content.")
+        filesystem.rename(src, dst)
+        self.assertTrue(os.path.exists(dst))
+        self.assertFalse(os.path.exists(src))