Repository: drivendataorg/cloudpathlib
Pull request #129 (2021-03-06 00:53:42)

Rich comparison operators, hashing fix
- Adds rich comparison operators, which enables use with `sorted`
- Fixes a bug with `__hash__`. This was previously hashing the bound method `__repr__` (and not the string output of `__repr__`) and so gave a unique hash for every instance of a cloud path. 

github-actions[bot]: <!-- NETLIFY DEPLOY COMMENT GENERATED BY ACTIONS_NETLIFY -->
🚀 Deployed on https://deploy-preview-129--gallant-agnesi-5f7bb3.netlify.app
codecov[bot]: # [Codecov](https://codecov.io/gh/drivendataorg/cloudpathlib/pull/129?src=pr&el=h1) Report
> Merging [#129](https://codecov.io/gh/drivendataorg/cloudpathlib/pull/129?src=pr&el=desc) (91fec52) into [master](https://codecov.io/gh/drivendataorg/cloudpathlib/commit/a19902ecc2afc03ede3a59e1b5e62a51b6ca1cf6?el=desc) (a19902e) will **increase** coverage by `0.2%`.
> The diff coverage is `100.0%`.

```diff
@@           Coverage Diff            @@
##           master    #129     +/-   ##
========================================
+ Coverage    91.8%   92.0%   +0.2%     
========================================
  Files          19      19             
  Lines        1050    1066     +16     
========================================
+ Hits          964     981     +17     
+ Misses         86      85      -1     
```


| [Impacted Files](https://codecov.io/gh/drivendataorg/cloudpathlib/pull/129?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [cloudpathlib/cloudpath.py](https://codecov.io/gh/drivendataorg/cloudpathlib/pull/129/diff?src=pr&el=tree#diff-Y2xvdWRwYXRobGliL2Nsb3VkcGF0aC5weQ==) | `90.9% <100.0%> (+0.6%)` | :arrow_up: |


Patch:
diff --git a/HISTORY.md b/HISTORY.md
index 216285b..8369a08 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -1,5 +1,10 @@
 # History
 
+## v0.3.1 (Unreleased)
+
+- Added rich comparison operator support to cloud paths, which means you can now use them with `sorted`.
+- Fixed bug where `hash(...)` of a cloud path was not consistent with the equality operator.
+
 ## v0.3.0 (2021-01-29)
 
 - Added a new module `cloudpathlib.local` with utilities for mocking cloud paths in tests. The module has "Local" substitute classes that use the local filesystem in place of cloud storage. See the new documentation article ["Testing code that uses cloudpathlib"](https://cloudpathlib.drivendata.org/testing_mocked_cloudpathlib/) to learn more about how to use them. ([#107](https://github.com/drivendataorg/cloudpathlib/pull/107))
diff --git a/cloudpathlib/cloudpath.py b/cloudpathlib/cloudpath.py
index 4fff608..4dbed11 100644
--- a/cloudpathlib/cloudpath.py
+++ b/cloudpathlib/cloudpath.py
@@ -205,24 +205,43 @@ class CloudPath(metaclass=CloudPathMeta):
 
         return valid
 
-    def __repr__(self):
+    def __repr__(self) -> str:
         return f"{self.__class__.__name__}('{self}')"
 
-    def __str__(self):
+    def __str__(self) -> str:
         return self._str
 
-    def __hash__(self):
-        # use repr for type and resolved path to assess if paths are the same
-        return hash(self.__repr__)
+    def __hash__(self) -> int:
+        return hash((type(self).__name__, str(self)))
 
-    def __eq__(self, other: Any):
-        return repr(self) == repr(other)
+    def __eq__(self, other: Any) -> bool:
+        return isinstance(other, type(self)) and str(self) == str(other)
 
     def __fspath__(self):
         if self.is_file():
             self._refresh_cache(force_overwrite_from_cloud=False)
         return str(self._local)
 
+    def __lt__(self, other: Any) -> bool:
+        if not isinstance(other, type(self)):
+            return NotImplemented
+        return self.parts < other.parts
+
+    def __le__(self, other: Any) -> bool:
+        if not isinstance(other, type(self)):
+            return NotImplemented
+        return self.parts <= other.parts
+
+    def __gt__(self, other: Any) -> bool:
+        if not isinstance(other, type(self)):
+            return NotImplemented
+        return self.parts > other.parts
+
+    def __ge__(self, other: Any) -> bool:
+        if not isinstance(other, type(self)):
+            return NotImplemented
+        return self.parts >= other.parts
+
     # ====================== NOT IMPLEMENTED ======================
     # absolute - no cloud equivalent; all cloud paths are absolute already
     # as_posix - no cloud equivalent; not needed since we assume url separator


Test patch:
diff --git a/tests/test_cloudpath_manipulation.py b/tests/test_cloudpath_manipulation.py
index bfec276..ee6d9c6 100644
--- a/tests/test_cloudpath_manipulation.py
+++ b/tests/test_cloudpath_manipulation.py
@@ -1,3 +1,6 @@
+import pytest
+
+
 def test_joins(rig):
     assert rig.create_cloud_path("a/b/c/d").name == "d"
     assert rig.create_cloud_path("a/b/c/d.file").name == "d.file"
@@ -45,3 +48,55 @@ def test_joins(rig):
         "c",
         "d",
     )
+
+
+def test_equality(rig):
+    assert rig.create_cloud_path("a/b/foo") == rig.create_cloud_path("a/b/foo")
+    assert hash(rig.create_cloud_path("a/b/foo")) == hash(rig.create_cloud_path("a/b/foo"))
+
+    assert rig.create_cloud_path("a/b/foo") != rig.create_cloud_path("a/b/bar")
+    assert hash(rig.create_cloud_path("a/b/foo")) != hash(rig.create_cloud_path("a/b/bar"))
+
+    cp = rig.create_cloud_path("a/b/foo")
+    assert cp != str(cp)
+    assert cp != repr(cp)
+    assert hash(cp) != hash(str(cp))
+    assert hash(cp) != hash(repr(cp))
+
+
+def test_sorting(rig):
+    cp1 = rig.create_cloud_path("a/b/c")
+    cp2 = rig.create_cloud_path("a/c/b")
+    assert cp1 < cp2
+    assert cp1 <= cp2
+    assert not cp1 > cp2
+    assert not cp1 >= cp2
+
+    assert cp2 > cp1
+    assert cp2 >= cp1
+    assert not cp2 < cp1
+    assert not cp2 <= cp1
+
+    assert rig.create_cloud_path("a/b/c") <= rig.create_cloud_path("a/b/c")
+    assert rig.create_cloud_path("a/b/c") >= rig.create_cloud_path("a/b/c")
+
+    assert sorted(
+        [
+            rig.create_cloud_path("a/c/b"),
+            rig.create_cloud_path("a/b/c"),
+            rig.create_cloud_path("d/e/f"),
+        ]
+    ) == [
+        rig.create_cloud_path("a/b/c"),
+        rig.create_cloud_path("a/c/b"),
+        rig.create_cloud_path("d/e/f"),
+    ]
+
+    with pytest.raises(TypeError):
+        assert cp1 < str(cp1)
+    with pytest.raises(TypeError):
+        assert cp1 <= str(cp1)
+    with pytest.raises(TypeError):
+        assert cp1 > str(cp1)
+    with pytest.raises(TypeError):
+        assert cp1 >= str(cp1)