Repository: nerdvegas/rez (python)

Issue:
add configurability of package orderers
Package orderers as found in package_order.py aren't yet configurable via rezconfig.py. Should support something like:

package_orderers:
- type: soft_timestamp
  package_families: ["maya*"]
  timestamp: 3456789
  rank: 0


Pull request:
Expose package orderers in rez config
This PR add a `package_orderers` entry in the rez config. Fixes #329.

This allow package orders to be configured.
We deployed this at RodeoFX 6 month ago and had no issues.
Our use case was to set a `per_facility` orderer so that rez "prefer" python 2 over python 3 by default. 

Here's what our configuration look like:
```
package_orderers:
- orderers:
  - first_version: 2.7.16-r999
    packages:
    - python
    type: version_split
  type: per_family
```

### Changes

- Added "package_orderers" entry in rez config
- Add a convenience class `PackageOrderList` (similar to `PackageFilterList`)
- Fix a serialization issue with `VersionSplitPackageOrder` (second point in  https://github.com/nerdvegas/rez/pull/413)
- Create a dedicate `test_package_orders.py` for package orderers tests (100% branch coverage on `package_orderers.py`. Note that `test_9` in `test_packages.py` was moved into individual per-family tests.
- Minor: Make the `TimestampPackageOrder` rank default to 0 if unset


Patch:
diff --git a/src/rez/config.py b/src/rez/config.py
index cdb7bcd4..32a42625 100644
--- a/src/rez/config.py
+++ b/src/rez/config.py
@@ -401,6 +401,7 @@ config_schema = Schema({
     "env_var_separators":                           Dict,
     "variant_select_mode":                          VariantSelectMode_,
     "package_filter":                               OptionalDictOrDictList,
+    "package_orderers":                             OptionalDictOrDictList,
     "new_session_popen_args":                       OptionalDict,
     "context_tracking_amqp":                        OptionalDict,
     "context_tracking_extra_fields":                OptionalDict,
diff --git a/src/rez/package_order.py b/src/rez/package_order.py
index 73ccd2ee..f481202e 100644
--- a/src/rez/package_order.py
+++ b/src/rez/package_order.py
@@ -1,6 +1,10 @@
 from inspect import isclass
 from hashlib import sha1
 
+from rez.config import config
+from rez.utils.data_utils import cached_class_property
+from rez.vendor.version.version import Version
+
 
 class PackageOrder(object):
     """Package reorderer base class."""
@@ -36,7 +40,16 @@ class PackageOrder(object):
 
     @property
     def sha1(self):
-        return sha1(repr(self)).hexdigest()
+        return sha1(repr(self).encode('utf-8')).hexdigest()
+
+    def __str__(self):
+        raise NotImplementedError
+
+    def __eq__(self, other):
+        raise NotImplementedError
+
+    def __ne__(self, other):
+        return not self == other
 
     def __repr__(self):
         return "%s(%s)" % (self.__class__.__name__, str(self))
@@ -57,6 +70,9 @@ class NullPackageOrder(PackageOrder):
     def __str__(self):
         return "{}"
 
+    def __eq__(self, other):
+        return type(self) == type(other)
+
     def to_pod(self):
         """
         Example (in yaml):
@@ -86,6 +102,12 @@ class SortedOrder(PackageOrder):
     def __str__(self):
         return str(self.descending)
 
+    def __eq__(self, other):
+        return (
+            type(self) == type(other) and
+            self.descending == other.descending
+        )
+
     def to_pod(self):
         """
         Example (in yaml):
@@ -138,6 +160,13 @@ class PerFamilyOrder(PackageOrder):
         items = sorted((x[0], str(x[1])) for x in self.order_dict.items())
         return str((items, str(self.default_order)))
 
+    def __eq__(self, other):
+        return (
+            type(other) == type(self) and
+            self.order_dict == other.order_dict and
+            self.default_order == other.default_order
+        )
+
     def to_pod(self):
         """
         Example (in yaml):
@@ -238,6 +267,12 @@ class VersionSplitPackageOrder(PackageOrder):
     def __str__(self):
         return str(self.first_version)
 
+    def __eq__(self, other):
+        return (
+            type(other) == type(self) and
+            self.first_version == other.first_version
+        )
+
     def to_pod(self):
         """
         Example (in yaml):
@@ -245,11 +280,11 @@ class VersionSplitPackageOrder(PackageOrder):
             type: version_split
             first_version: "3.0.0"
         """
-        return dict(first_version=self.first_version)
+        return dict(first_version=str(self.first_version))
 
     @classmethod
     def from_pod(cls, data):
-        return cls(data["first_version"])
+        return cls(Version(data["first_version"]))
 
 
 class TimestampPackageOrder(PackageOrder):
@@ -304,7 +339,6 @@ class TimestampPackageOrder(PackageOrder):
         self.rank = rank
 
     def reorder(self, iterable, key=None):
-        reordered = []
         first_after = None
         key = key or (lambda x: x)
 
@@ -372,6 +406,13 @@ class TimestampPackageOrder(PackageOrder):
     def __str__(self):
         return str((self.timestamp, self.rank))
 
+    def __eq__(self, other):
+        return (
+            type(other) == type(self) and
+            self.timestamp == other.timestamp and
+            self.rank == other.rank
+        )
+
     def to_pod(self):
         """
         Example (in yaml):
@@ -385,8 +426,30 @@ class TimestampPackageOrder(PackageOrder):
 
     @classmethod
     def from_pod(cls, data):
-        return cls(timestamp=data["timestamp"],
-                   rank=data["rank"])
+        return cls(timestamp=data["timestamp"], rank=data.get("rank", 0))
+
+
+class PackageOrderList(list):
+    """A list of package orderer.
+    """
+    def to_pod(self):
+        data = []
+        for f in self:
+            data.append(to_pod(f))
+        return data
+
+    @classmethod
+    def from_pod(cls, data):
+        flist = PackageOrderList()
+        for dict_ in data:
+            f = from_pod(dict_)
+            flist.append(f)
+        return flist
+
+    @cached_class_property
+    def singleton(cls):
+        """Filter list as configured by rezconfig.package_filter."""
+        return cls.from_pod(config.package_orderers)
 
 
 def to_pod(orderer):
diff --git a/src/rez/resolved_context.py b/src/rez/resolved_context.py
index cdac6262..0f90334b 100644
--- a/src/rez/resolved_context.py
+++ b/src/rez/resolved_context.py
@@ -21,6 +21,7 @@ from rez.rex_bindings import VersionBinding, VariantBinding, \
 from rez import package_order
 from rez.packages import get_variant, iter_packages
 from rez.package_filter import PackageFilterList
+from rez.package_order import PackageOrderList
 from rez.shells import create_shell
 from rez.exceptions import ResolvedContextError, PackageCommandError, \
     RezError, _NeverError
@@ -32,13 +33,10 @@ from rez.vendor import yaml
 from rez.utils import json
 from rez.utils.yaml import dump_yaml
 
-from tempfile import mkdtemp
 from functools import wraps
 import getpass
 import socket
 import threading
-import traceback
-import inspect
 import time
 import sys
 import os
@@ -172,6 +170,7 @@ class ResolvedContext(object):
                 packages. Defaults to settings from config.package_filter. Use
                 `package_filter.no_filter` to remove all filtering.
             package_orderers (list of `PackageOrder`): Custom package ordering.
+                Defaults to settings from config.package_orderers.
             add_implicit_packages: If True, the implicit package list defined
                 by config.implicit_packages is appended to the request.
             max_fails (int): Abort the resolve if the number of failed steps is
@@ -216,7 +215,10 @@ class ResolvedContext(object):
         self.package_filter = (PackageFilterList.singleton if package_filter is None
                                else package_filter)
 
-        self.package_orderers = package_orderers
+        self.package_orderers = (
+            PackageOrderList.singleton if package_orderers is None
+            else package_orders
+        )
 
         # patch settings
         self.default_patch_lock = PatchLock.no_lock
diff --git a/src/rez/rezconfig.py b/src/rez/rezconfig.py
index e0b3f5da..84576f1a 100644
--- a/src/rez/rezconfig.py
+++ b/src/rez/rezconfig.py
@@ -289,6 +289,70 @@ variant_select_mode = "version_priority"
 # foo-5+              | Same as range(foo-5+)
 package_filter = None
 
+# Package order. 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.
+#
+# A common use case is to ease migration from python-2 to python-3:
+#
+#     package_orderers = [
+#         {
+#            "type": "per_family",
+#            "orderers": [
+#                 {
+#                     "packages": ["python"],
+#                     "type": "version_split",
+#                     "first_version": "2.7.16"
+#                 }
+#             ]
+#         }
+#     ]
+#
+# This will ensure that for the "python" package, versions equals or lower than "2.7.16" will have priority.
+# Considering the following versions: "2.7.4", "2.7.16", "3.7.4":
+#
+# example             | result
+# --------------------|----------------------------------------------------
+# rez-env python      | python-2.7.16
+# rez-env python-3    | 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.
+#
+#     package_orderers = [
+#         {
+#             "type": "soft_timestamp",
+#             "timestamp": 1568001600,  # 2019-09-09
+#             "rank": 3
+#         }
+#     ]
+#
+# A timestamp can be generated with python:
+#
+#     $ python -c "import datetime, time; print(int(time.mktime(datetime.date(2019, 9, 9).timetuple())))"
+#     1568001600
+#
+# The rank can be used to allow some versions released after the timestamp to still be considered.
+# When using semantic versionnng, a value of 3 is the most common.
+# This will let version with a different patch number to be accepted.
+#
+# Considering a package "foo" with the following versions:
+# - "1.0.0" was released at 2019-09-07
+# - "2.0.0" was released at 2019-09-08
+# - "2.0.1" was released at 2019-09-10
+# - "2.1.0" was released at 2019-09-11
+# - "3.0.0" was released at 2019-09-12
+#
+# example             | timestamp  | rank | result
+# --------------------|------------|---------------------------------------
+# rez-env foo         | 2019-09-09 | 0    | foo-2.0.0
+# rez-env foo         | 2019-09-09 | 3    | foo-2.0.1
+# rez-env foo         | 2019-09-09 | 2    | foo-2.1.0
+# rez-env foo         | 2019-09-09 | 1    | foo-3.0.0
+package_orderers = None
+
 # If True, unversioned packages are allowed. Solve times are slightly better if
 # this value is False.
 allow_unversioned_packages = True


Test patch:
diff --git a/src/rez/tests/test_packages.py b/src/rez/tests/test_packages.py
index 342b2e5e..3405fdec 100644
--- a/src/rez/tests/test_packages.py
+++ b/src/rez/tests/test_packages.py
@@ -4,7 +4,6 @@ test package iteration, serialization etc
 from rez.packages import iter_package_families, iter_packages, get_package, \
     create_package, get_developer_package
 from rez.package_py_utils import expand_requirement
-from rez.package_repository import create_memory_package_repository
 from rez.package_resources import package_release_keys
 from rez.tests.util import TestBase, TempdirMixin
 from rez.utils.formatting import PackageRequest
@@ -423,54 +422,6 @@ class TestPackages(TestBase, TempdirMixin):
         for req in bad_tests:
             self.assertRaises(VersionError, expand_requirement, req)
 
-    def test_9(self):
-        """test package orderers."""
-        from rez.package_order import NullPackageOrder, PerFamilyOrder, \
-            VersionSplitPackageOrder, TimestampPackageOrder, SortedOrder, \
-            to_pod, from_pod
-
-        def _test(orderer, package_name, expected_order):
-            it = iter_packages(package_name)
-            descending = sorted(it, key=lambda x: x.version, reverse=True)
-
-            pod = to_pod(orderer)
-            orderer2 = from_pod(pod)
-
-            for orderer_ in (orderer, orderer2):
-                ordered = orderer_.reorder(descending)
-                result = [str(x.version) for x in ordered]
-                self.assertEqual(result, expected_order)
-
-        null_orderer = NullPackageOrder()
-        split_orderer = VersionSplitPackageOrder(Version("2.6.0"))
-        # after v1.1.0 and before v1.1.1
-        timestamp_orderer = TimestampPackageOrder(timestamp=3001, rank=3)
-        # after v2.1.0 and before v2.1.5
-        timestamp2_orderer = TimestampPackageOrder(timestamp=7001, rank=3)
-
-        expected_null_result = ["7", "6", "5"]
-        expected_split_result = ["2.6.0", "2.5.2", "2.7.0", "2.6.8"]
-        expected_timestamp_result = ["1.1.1", "1.1.0", "1.0.6", "1.0.5",
-                                     "1.2.0", "2.0.0", "2.1.5", "2.1.0"]
-        expected_timestamp2_result = ["2.1.5", "2.1.0", "2.0.0", "1.2.0",
-                                      "1.1.1", "1.1.0", "1.0.6", "1.0.5"]
-
-        _test(null_orderer, "pysplit", expected_null_result)
-        _test(split_orderer, "python", expected_split_result)
-        _test(timestamp_orderer, "timestamped", expected_timestamp_result)
-        _test(timestamp2_orderer, "timestamped", expected_timestamp2_result)
-
-        fam_orderer = PerFamilyOrder(
-            order_dict=dict(pysplit=null_orderer,
-                            python=split_orderer,
-                            timestamped=timestamp_orderer),
-            default_order=SortedOrder(descending=False))
-
-        _test(fam_orderer, "pysplit", expected_null_result)
-        _test(fam_orderer, "python", expected_split_result)
-        _test(fam_orderer, "timestamped", expected_timestamp_result)
-        _test(fam_orderer, "pymum", ["1", "2", "3"])
-
 
 class TestMemoryPackages(TestBase):
     def test_1_memory_variant_parent(self):
diff --git a/src/rez/tests/test_packages_order.py b/src/rez/tests/test_packages_order.py
new file mode 100644
index 00000000..35c00526
--- /dev/null
+++ b/src/rez/tests/test_packages_order.py
@@ -0,0 +1,368 @@
+"""
+Test cases for package_order.py (package ordering)
+"""
+import json
+import os
+
+from rez.config import config
+from rez.package_order import NullPackageOrder, PackageOrder, PerFamilyOrder, VersionSplitPackageOrder, \
+    TimestampPackageOrder, SortedOrder, PackageOrderList, from_pod
+from rez.packages_ import iter_packages
+from rez.tests.util import TestBase, TempdirMixin
+from rez.vendor.version.version import Version
+
+
+class _BaseTestPackagesOrder(TestBase, TempdirMixin):
+    """Base class for a package ordering test case"""
+    @classmethod
+    def setUpClass(cls):
+        TempdirMixin.setUpClass()
+
+        dirname = os.path.realpath(os.path.dirname(__file__))
+        cls.solver_packages_path = os.path.join(dirname, "data", "solver", "packages")
+        cls.py_packages_path = os.path.join(dirname, "data", "packages", "py_packages")
+
+        cls.settings = dict(
+            packages_path=[
+                cls.solver_packages_path,
+                cls.py_packages_path
+            ],
+            package_filter=None)
+
+    @classmethod
+    def tearDownClass(cls):
+        TempdirMixin.tearDownClass()
+
+    def _test_reorder(self, orderer, package_name, expected_order):
+        """Ensure ordered order package version as expected."""
+        it = iter_packages(package_name)
+        descending = sorted(it, key=lambda x: x.version, reverse=True)
+        ordered = orderer.reorder(descending) or descending
+        result = [str(x.version) for x in ordered]
+        self.assertEqual(expected_order, result)
+
+    def _test_pod(self, orderer):
+        """Ensure an orderer integrity when serialized to pod."""
+        pod = json.loads(json.dumps(orderer.to_pod()))  # roundtrip to JSON
+        actual = orderer.__class__.from_pod(pod)
+        self.assertEqual(orderer, actual)
+
+
+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)
+
+    def test_str(self):
+        """Validate __str__ is not implemented"""
+        with self.assertRaises(NotImplementedError):
+            str(PackageOrder())
+
+    def test_eq(self):
+        """Validate __eq__ is not implemented"""
+        with self.assertRaises(NotImplementedError):
+            PackageOrder() == PackageOrder()
+
+
+class TestNullPackageOrder(_BaseTestPackagesOrder):
+    """Test case for the NullPackageOrder class"""
+
+    def test_repr(self):
+        """Validate we can represent a VersionSplitPackageOrder as a string."""
+        self.assertEqual("NullPackageOrder({})", repr(NullPackageOrder()))
+
+    def test_comparison(self):
+        """Validate we can compare VersionSplitPackageOrder together."""
+        inst1 = NullPackageOrder()
+        inst2 = NullPackageOrder()
+        self.assertTrue(inst1 == inst2)  # __eq__ positive
+        self.assertFalse(inst1 == "wrong_type")  # __eq__ negative (wrong type)
+        self.assertTrue(inst1 != "wrong_type")  # __ne__ positive (wrong type)
+        self.assertFalse(inst1 != inst2)  # __ne__ negative
+
+    def test_pod(self):
+        """Validate we can save and load a VersionSplitPackageOrder to it's pod representation."""
+        self._test_pod(NullPackageOrder())
+
+    def test_sha1(self):
+        """Validate we can get a sha1 hash.
+        """
+        self.assertEqual(
+            'bf7c2fa4e6bd198c02adeea2c3a382cf57242051', NullPackageOrder().sha1
+        )
+
+
+class TestSortedOrder(_BaseTestPackagesOrder):
+    """Test case for the SortedOrder class"""
+
+    def test_reorder_ascending(self):
+        """Validate we can sort packages in ascending order."""
+        self._test_reorder(SortedOrder(descending=False), "pymum", ["1", "2", "3"])
+
+    def test_reorder_descending(self):
+        """Validate we can sort packages in descending order."""
+        self._test_reorder(SortedOrder(descending=True), "pymum", ["3", "2", "1"])
+
+    def test_comparison(self):
+        """Validate we can compare SortedOrder together."""
+        inst1 = SortedOrder(descending=False)
+        inst2 = SortedOrder(descending=False)
+        inst3 = SortedOrder(descending=True)
+        self.assertTrue(inst1 == inst2)  # __eq__ positive
+        self.assertFalse(inst1 == inst3)  # __eq__ negative
+        self.assertTrue(inst1 != inst3)  # __ne__ positive
+        self.assertFalse(inst1 != inst2)  # __ne__ negative
+        self.assertFalse(inst1 == "wrong_type")  # __eq__ negative (wrong type)
+        self.assertTrue(inst1 != "wrong_type")  # __eq__ negative (wrong type)
+
+    def test_repr(self):
+        """Validate we can represent a SortedOrder as a string."""
+        self.assertEqual("SortedOrder(True)", repr(SortedOrder(descending=True)))
+
+    def test_pod(self):
+        """Validate we can save and load a SortedOrder to it's pod representation."""
+        self._test_pod(SortedOrder(descending=True))
+
+
+class TestPerFamilyOrder(_BaseTestPackagesOrder):
+    """Test case for the PerFamilyOrder class"""
+
+    def test_reorder(self):
+        """Test ordering."""
+        expected_null_result = ["7", "6", "5"]
+        expected_split_result = ["2.6.0", "2.5.2", "2.7.0", "2.6.8"]
+        expected_timestamp_result = ["1.1.1", "1.1.0", "1.0.6", "1.0.5", "1.2.0", "2.0.0", "2.1.5", "2.1.0"]
+
+        orderer = PerFamilyOrder(
+            order_dict=dict(
+                pysplit=NullPackageOrder(),
+                python=VersionSplitPackageOrder(Version("2.6.0")),
+                timestamped=TimestampPackageOrder(timestamp=3001, rank=3)
+            ),
+            default_order=SortedOrder(descending=False)
+        )
+
+        self._test_reorder(orderer, "pysplit", expected_null_result)
+        self._test_reorder(orderer, "python", expected_split_result)
+        self._test_reorder(orderer, "timestamped", expected_timestamp_result)
+        self._test_reorder(orderer, "pymum", ["1", "2", "3"])
+
+    def test_reorder_no_packages(self):
+        """Validate ordering for a family with no packages."""
+        orderer = PerFamilyOrder(order_dict=dict(missing_package=NullPackageOrder()))
+        self._test_reorder(orderer, "missing_package", [])
+
+    def test_reorder_no_default_order(self):
+        """Test behavior when there's no secondary default_order."""
+        fam_orderer = PerFamilyOrder(order_dict={})
+        self._test_reorder(fam_orderer, "pymum", ["3", "2", "1"])
+
+    def test_comparison(self):
+        """Validate we can compare PerFamilyOrder."""
+        inst1 = PerFamilyOrder(order_dict={'foo': NullPackageOrder()}, default_order=NullPackageOrder())
+        inst2 = PerFamilyOrder(order_dict={'foo': NullPackageOrder()}, default_order=NullPackageOrder())
+        inst3 = PerFamilyOrder(order_dict={'bar': NullPackageOrder()}, default_order=NullPackageOrder())
+        inst4 = PerFamilyOrder(order_dict={'foo': NullPackageOrder()}, default_order=None)
+        self.assertTrue(inst1 == inst2)  # __eq__ positive
+        self.assertFalse(inst1 == inst3)  # __eq__ negative (different order dict)
+        self.assertFalse(inst1 == inst4)  # __eq__ negative (different default_order)
+        self.assertTrue(inst1 != inst3)  # __ne__ positive (different order dict)
+        self.assertTrue(inst1 != inst4)  # __ne__ positive (different default order)
+        self.assertFalse(inst1 != inst2)  # __ne__ negative
+
+    def test_repr(self):
+        """Validate we can represent a PerFamilyOrder as a string."""
+        inst = PerFamilyOrder(order_dict={"family1": VersionSplitPackageOrder(Version("2.6.0"))})
+        self.assertEqual("PerFamilyOrder(([('family1', '2.6.0')], 'None'))", repr(inst))
+
+    def test_pod(self):
+        """Validate we can save and load a PerFamilyOrder to it's pod representation."""
+        self._test_pod(
+            PerFamilyOrder(order_dict={'foo': NullPackageOrder()}, default_order=NullPackageOrder())
+        )
+
+        # No default_order
+        self._test_pod(
+            PerFamilyOrder(order_dict={'foo': NullPackageOrder()})
+        )
+
+
+class TestVersionSplitPackageOrder(_BaseTestPackagesOrder):
+    """Test case for the VersionSplitPackageOrder class"""
+
+    def test_reordere(self):
+        """Validate package ordering with a VersionSplitPackageOrder"""
+        orderer = VersionSplitPackageOrder(Version("2.6.0"))
+        expected = ["2.6.0", "2.5.2", "2.7.0", "2.6.8"]
+        self._test_reorder(orderer, "python", expected)
+
+    def test_comparison(self):
+        """Validate we can compare VersionSplitPackageOrder together."""
+        inst1 = VersionSplitPackageOrder(first_version=Version("1.2.3"))
+        inst2 = VersionSplitPackageOrder(first_version=Version("1.2.3"))
+        inst3 = VersionSplitPackageOrder(first_version=Version("1.2.4"))
+        self.assertTrue(inst1 == inst2)  # __eq__ positive
+        self.assertFalse(inst1 == inst3)  # __eq__ negative
+        self.assertTrue(inst1 != inst3)  # __ne__ positive
+        self.assertFalse(inst1 != inst2)  # __ne__ negative
+        self.assertFalse(inst1 == "wrong_type")  # __eq__ negative (wrong type)
+        self.assertTrue(inst1 != "wrong_type")  # __eq__ negative (wrong type)
+
+    def test_repr(self):
+        """Validate we can represent a VersionSplitPackageOrder as a string."""
+        inst = VersionSplitPackageOrder(first_version=Version("1,2,3"))
+        self.assertEqual("VersionSplitPackageOrder(1,2,3)", repr(inst))
+
+    def test_pod(self):
+        """Validate we can save and load a VersionSplitPackageOrder to it's pod representation."""
+        self._test_pod(VersionSplitPackageOrder(first_version=Version("1.2.3")))
+
+
+class TestTimestampPackageOrder(_BaseTestPackagesOrder):
+    """Test cases for the TimestampPackageOrder class"""
+
+    def test_reorder_no_rank(self):
+        """Validate reordering with a rank of 0."""
+        orderer = TimestampPackageOrder(timestamp=3001)
+        expected = ['1.1.0', '1.0.6', '1.0.5', '1.1.1', '1.2.0', '2.0.0', '2.1.0', '2.1.5']
+        self._test_reorder(orderer, "timestamped", expected)
+
+    def test_reorder_rank_3(self):
+        """Validate reordering with a rank of 3."""
+        # after v1.1.0 and before v1.1.1
+        orderer1 = TimestampPackageOrder(timestamp=3001, rank=3)
+        expected1 = ["1.1.1", "1.1.0", "1.0.6", "1.0.5", "1.2.0", "2.0.0", "2.1.5", "2.1.0"]
+        self._test_reorder(orderer1, "timestamped", expected1)
+
+        # after v2.1.0 and before v2.1.5
+        orderer2 = TimestampPackageOrder(timestamp=7001, rank=3)
+        expected2 = ["2.1.5", "2.1.0", "2.0.0", "1.2.0", "1.1.1", "1.1.0", "1.0.6", "1.0.5"]
+        self._test_reorder(orderer2, "timestamped", expected2)
+
+    def test_reorder_rank_2(self):
+        """Add coverage for a corner case where there's only one candidate without the rank."""
+        orderer = TimestampPackageOrder(timestamp=4001, rank=3)  # 1.1.1
+        expected = ['1.1.1', '1.1.0', '1.0.6', '1.0.5', '1.2.0', '2.0.0', '2.1.5', '2.1.0']
+        self._test_reorder(orderer, "timestamped", expected)
+
+    def test_reorder_packages_without_timestamps(self):
+        """Validate reordering of packages that have no timestamp data."""
+        orderer = TimestampPackageOrder(timestamp=3001)
+        self._test_reorder(orderer, "pymum", ["3", "2", "1"])
+
+    def test_reorder_all_packages_before_timestamp(self):
+        """Test behavior when all packages are before the timestamp."""
+        timestamp_orderer = TimestampPackageOrder(timestamp=9999999999, rank=3)
+        expected = ['2.1.5', '2.1.0', '2.0.0', '1.2.0', '1.1.1', '1.1.0', '1.0.6', '1.0.5']
+        self._test_reorder(timestamp_orderer, "timestamped", expected)
+
+    def test_reorder_all_packages_after_timestamp(self):
+        """Test behavior when all packages are after the timestamp."""
+        timestamp_orderer = TimestampPackageOrder(timestamp=0, rank=3)
+        expected = ['1.0.6', '1.0.5', '1.1.1', '1.1.0', '1.2.0', '2.0.0', '2.1.5', '2.1.0']
+        self._test_reorder(timestamp_orderer, "timestamped", expected)
+
+    def test_comparison(self):
+        """Validate we can compare TimestampPackageOrder."""
+        inst1 = TimestampPackageOrder(timestamp=1, rank=1)
+        inst2 = TimestampPackageOrder(timestamp=1, rank=1)
+        inst3 = TimestampPackageOrder(timestamp=2, rank=1)
+        inst4 = TimestampPackageOrder(timestamp=2, rank=2)
+        self.assertTrue(inst1 == inst2)  # __eq__ positive
+        self.assertFalse(inst1 == inst3)  # __eq__ negative (different timestamp)
+        self.assertFalse(inst1 == inst4)  # __eq__ negative (different rank)
+        self.assertTrue(inst1 != inst3)  # __ne__ positive (different timestamp)
+        self.assertTrue(inst1 != inst4)  # __ne__ positive (different rank)
+        self.assertFalse(inst1 != inst2)  # __ne__ negative
+
+    def test_repr(self):
+        """Validate we can represent a TimestampPackageOrder as a string."""
+        inst = TimestampPackageOrder(timestamp=1, rank=2)
+        self.assertEqual(repr(inst), "TimestampPackageOrder((1, 2))")
+
+    def test_pod(self):
+        """Validate we can save and load a TimestampPackageOrder to pod representation."""
+        self._test_pod(TimestampPackageOrder(timestamp=3001, rank=3))
+
+
+class TestPackageOrdererList(_BaseTestPackagesOrder):
+    """Test cases for the PackageOrderList class."""
+
+    def test_singleton(self):
+        """Validate we can build a PackageOrderList object from configuration values."""
+        config.override("package_orderers", [
+            {
+                "type": "per_family",
+                "orderers": [
+                    {
+                        "packages": ["python"],
+                        "type": "version_split",
+                        "first_version": "2.9.9"
+                    }
+                ]
+            }
+        ])
+        expected = PackageOrderList()
+        expected.append(PerFamilyOrder(order_dict={
+            "python": VersionSplitPackageOrder(Version("2.9.9"))
+        }))
+
+        # Clear @classproperty cache
+        try:
+            delattr(PackageOrderList, '_class_property_singleton')
+        except AttributeError:
+            pass
+        self.assertEqual(expected, PackageOrderList.singleton)
+
+    def test_singleton_novalue(self):
+        """Validate we can build a PackageOrderList object from empty configuration values."""
+        config.override("package_orderers", None)
+
+        # Clear @classproperty cache
+        try:
+            delattr(PackageOrderList, '_class_property_singleton')
+        except AttributeError:
+            pass
+
+        self.assertEqual(PackageOrderList(), PackageOrderList.singleton)
+
+    def test_pod(self):
+        """Validate we can save and load a PackageOrdererList to pod representation."""
+        inst = PackageOrderList((
+            VersionSplitPackageOrder(Version("2.6.0")),
+            PerFamilyOrder(order_dict={}, default_order=SortedOrder(descending=False))
+        ))
+        self._test_pod(inst)
+
+
+class TestPackageOrderPublic(TestBase):
+    """Additional tests for public symbols in package_order.py"""
+
+    def test_from_pod_old_style(self):
+        """Validate from_pod is still compatible with the older pod style."""
+        self.assertEqual(
+            VersionSplitPackageOrder(first_version=Version("1.2.3")),
+            from_pod(("version_split", {"first_version": "1.2.3"}))
+        )
+
+# Copyright 2013-2016 Allan Johns.
+#
+# This library is free software: you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation, either
+# version 3 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library.  If not, see <http://www.gnu.org/licenses/>.