Repository: developmentseed/hydraters (language: python)

Issue:
Implement Dehydration
As a stand alone library, we should probably add the inverse dehydration as well.


Discussion / hints:

Fix patch:
diff --git a/hydraters.pyi b/hydraters.pyi
index 773b149..08839db 100644
--- a/hydraters.pyi
+++ b/hydraters.pyi
@@ -1,4 +1,10 @@
 from typing import Any
 
-def hydrate(base: Any, item: Any) -> Any:
+DO_NOT_MERGE_MARKER: str
+"""The magic marker that is used to indicate that a field should not be merged."""
+
+def hydrate(base: dict[str, Any], item: dict[str, Any]) -> dict[str, Any]:
     """Hydrates an item using a base."""
+
+def dehydrate(base: dict[str, Any], item: dict[str, Any]) -> dict[str, Any]:
+    """Dehydrates an item using a base."""
diff --git a/src/lib.rs b/src/lib.rs
index 9a10538..06e8d42 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -31,59 +31,44 @@ const MAGIC_MARKER: &str = "𒍟※";
 
 #[pyfunction]
 fn hydrate<'py>(
-    base: &'py Bound<'py, PyAny>,
-    item: &'py Bound<'py, PyAny>,
-) -> PyResult<&'py Bound<'py, PyAny>> {
-    hydrate_any(base, item)
+    base: &'py Bound<'py, PyDict>,
+    item: &'py Bound<'py, PyDict>,
+) -> PyResult<&'py Bound<'py, PyDict>> {
+    hydrate_dict(base, item)?;
+    Ok(item)
 }
 
-fn hydrate_any<'py>(
-    base: &'py Bound<'py, PyAny>,
-    item: &'py Bound<'py, PyAny>,
-) -> PyResult<&'py Bound<'py, PyAny>> {
+fn hydrate_any<'py>(base: &'py Bound<'py, PyAny>, item: &'py Bound<'py, PyAny>) -> PyResult<()> {
     if let Ok(item) = item.downcast::<PyDict>() {
         if let Ok(base) = base.downcast::<PyDict>() {
-            Ok(hydrate_dict(base, item)?.as_any())
-        } else if base.is_none() {
-            Ok(item)
+            hydrate_dict(base, item)?;
         } else {
-            Err(PyValueError::new_err(
+            return Err(PyValueError::new_err(
                 "type mismatch: item is a dict, but the base was not",
-            ))
+            ));
         }
     } else if let Ok(item) = item.downcast::<PyList>() {
         if let Ok(base) = base.downcast::<PyList>() {
-            Ok(hydrate_list(base, item)?.as_any())
-        } else if base.is_none() {
-            Ok(item)
+            hydrate_list(base, item)?;
         } else {
-            Err(PyValueError::new_err(
+            return Err(PyValueError::new_err(
                 "type mismatch: item is a list, but base is not",
-            ))
+            ));
         }
-    } else {
-        Ok(item)
     }
+    Ok(())
 }
 
-fn hydrate_list<'py>(
-    base: &'py Bound<'py, PyList>,
-    item: &'py Bound<'py, PyList>,
-) -> PyResult<&'py Bound<'py, PyList>> {
-    for i in 0..item.len() {
-        if i >= base.len() {
-            return Ok(item);
-        } else {
-            item.set_item(i, hydrate(&base.get_item(i)?, &item.get_item(i)?)?)?;
+fn hydrate_list<'py>(base: &'py Bound<'py, PyList>, item: &'py Bound<'py, PyList>) -> PyResult<()> {
+    if base.len() == item.len() {
+        for (base_value, item_value) in base.iter().zip(item.iter()) {
+            hydrate_any(&base_value, &item_value)?;
         }
     }
-    Ok(item)
+    Ok(())
 }
 
-fn hydrate_dict<'py>(
-    base: &'py Bound<'py, PyDict>,
-    item: &'py Bound<'py, PyDict>,
-) -> PyResult<&'py Bound<'py, PyDict>> {
+fn hydrate_dict<'py>(base: &'py Bound<'py, PyDict>, item: &'py Bound<'py, PyDict>) -> PyResult<()> {
     for (key, base_value) in base {
         if let Some(item_value) = item.get_item(&key)? {
             if item_value
@@ -94,17 +79,68 @@ fn hydrate_dict<'py>(
             {
                 item.del_item(key)?;
             } else {
-                item.set_item(&key, hydrate(&base_value, &item_value)?)?;
+                hydrate_any(&base_value, &item_value)?;
             }
         } else {
             item.set_item(key, base_value)?;
         }
     }
+    Ok(())
+}
+
+#[pyfunction]
+fn dehydrate<'py>(
+    base: &'py Bound<'py, PyDict>,
+    item: &'py Bound<'py, PyDict>,
+) -> PyResult<&'py Bound<'py, PyDict>> {
+    dehydrate_dict(base, item)?;
     Ok(item)
 }
 
+fn dehydrate_dict<'py>(
+    base: &'py Bound<'py, PyDict>,
+    item: &'py Bound<'py, PyDict>,
+) -> PyResult<()> {
+    for (key, base_value) in base {
+        if let Some(item_value) = item.get_item(&key)? {
+            if base_value.eq(&item_value)? {
+                item.del_item(key)?;
+            } else if let Ok(item_value) = item_value.downcast::<PyList>() {
+                if let Ok(base_value) = base_value.downcast::<PyList>() {
+                    dehydrate_list(base_value, item_value)?;
+                }
+            } else if let Ok(item_value) = item_value.downcast::<PyDict>() {
+                if let Ok(base_value) = base_value.downcast::<PyDict>() {
+                    dehydrate_dict(base_value, item_value)?;
+                }
+            }
+        } else {
+            item.set_item(key, MAGIC_MARKER)?;
+        }
+    }
+    Ok(())
+}
+
+fn dehydrate_list<'py>(
+    base: &'py Bound<'py, PyList>,
+    item: &'py Bound<'py, PyList>,
+) -> PyResult<()> {
+    if base.len() == item.len() {
+        for (base_value, item_value) in base.iter().zip(item.iter()) {
+            if let Ok(base_value) = base_value.downcast::<PyDict>() {
+                if let Ok(item_value) = item_value.downcast::<PyDict>() {
+                    dehydrate_dict(base_value, item_value)?;
+                }
+            }
+        }
+    }
+    Ok(())
+}
+
 #[pymodule]
 fn hydraters(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
+    m.add("DO_NOT_MERGE_MARKER", MAGIC_MARKER)?;
     m.add_function(wrap_pyfunction!(crate::hydrate, m)?)?;
+    m.add_function(wrap_pyfunction!(crate::dehydrate, m)?)?;
     Ok(())
 }


Test patch:
diff --git a/tests/test_dehydrate.py b/tests/test_dehydrate.py
new file mode 100644
index 0000000..fad2f94
--- /dev/null
+++ b/tests/test_dehydrate.py
@@ -0,0 +1,148 @@
+import hydraters
+from hydraters import DO_NOT_MERGE_MARKER
+
+
+def test_single_depth_equals() -> None:
+    base_item = {"a": "first", "b": "second", "c": "third"}
+    item = {"a": "first", "b": "second", "c": "third"}
+    dehydrated = hydraters.dehydrate(base_item, item)
+    assert dehydrated == {}
+
+
+def test_nested_equals() -> None:
+    base_item = {"a": "first", "b": "second", "c": {"d": "third"}}
+    item = {"a": "first", "b": "second", "c": {"d": "third"}}
+    dehydrated = hydraters.dehydrate(base_item, item)
+    assert dehydrated == {}
+
+
+def test_nested_extra_keys() -> None:
+    base_item = {"a": "first", "b": "second", "c": {"d": "third"}}
+    item = {
+        "a": "first",
+        "b": "second",
+        "c": {"d": "third", "e": "fourth", "f": "fifth"},
+    }
+    dehydrated = hydraters.dehydrate(base_item, item)
+    assert dehydrated == {"c": {"e": "fourth", "f": "fifth"}}
+
+
+def test_list_of_dicts_extra_keys() -> None:
+    base_item = {"a": [{"b1": 1, "b2": 2}, {"c1": 1, "c2": 2}]}
+    item = {"a": [{"b1": 1, "b2": 2, "b3": 3}, {"c1": 1, "c2": 2, "c3": 3}]}
+
+    dehydrated = hydraters.dehydrate(base_item, item)
+    assert "a" in dehydrated
+    assert dehydrated["a"] == [{"b3": 3}, {"c3": 3}]
+
+
+def test_equal_len_list_of_mixed_types() -> None:
+    base_item = {"a": [{"b1": 1, "b2": 2}, "foo", {"c1": 1, "c2": 2}, "bar"]}
+    item = {
+        "a": [
+            {"b1": 1, "b2": 2, "b3": 3},
+            "far",
+            {"c1": 1, "c2": 2, "c3": 3},
+            "boo",
+        ],
+    }
+
+    dehydrated = hydraters.dehydrate(base_item, item)
+    assert "a" in dehydrated
+    assert dehydrated["a"] == [{"b3": 3}, "far", {"c3": 3}, "boo"]
+
+
+def test_unequal_len_list() -> None:
+    """Test that unequal length lists preserve the item value exactly."""
+    base_item = {"a": [{"b1": 1}, {"c1": 1}, {"d1": 1}]}
+    item = {"a": [{"b1": 1, "b2": 2}, {"c1": 1, "c2": 2}]}
+
+    dehydrated = hydraters.dehydrate(base_item, item)
+    assert "a" in dehydrated
+    assert dehydrated["a"] == item["a"]
+
+
+def test_marked_non_merged_fields() -> None:
+    base_item = {"a": "first", "b": "second", "c": {"d": "third", "e": "fourth"}}
+    item = {
+        "a": "first",
+        "b": "second",
+        "c": {"d": "third", "f": "fifth"},
+    }
+    dehydrated = hydraters.dehydrate(base_item, item)
+    assert dehydrated == {"c": {"e": DO_NOT_MERGE_MARKER, "f": "fifth"}}
+
+
+def test_marked_non_merged_fields_in_list() -> None:
+    base_item = {
+        "a": [{"b": "first", "d": "third"}, {"c": "second", "e": "fourth"}],
+    }
+    item = {"a": [{"b": "first"}, {"c": "second", "f": "fifth"}]}
+
+    dehydrated = hydraters.dehydrate(base_item, item)
+    assert dehydrated == {
+        "a": [
+            {"d": DO_NOT_MERGE_MARKER},
+            {"e": DO_NOT_MERGE_MARKER, "f": "fifth"},
+        ],
+    }
+
+
+def test_deeply_nested_dict() -> None:
+    base_item = {"a": {"b": {"c": {"d": "first", "d1": "second"}}}}
+    item = {"a": {"b": {"c": {"d": "first", "d1": "second", "d2": "third"}}}}
+
+    dehydrated = hydraters.dehydrate(base_item, item)
+    assert dehydrated == {"a": {"b": {"c": {"d2": "third"}}}}
+
+
+def test_equal_list_of_non_dicts() -> None:
+    base_item = {"assets": {"thumbnail": {"roles": ["thumbnail"]}}}
+    item = {
+        "assets": {"thumbnail": {"roles": ["thumbnail"], "href": "http://foo.com"}},
+    }
+
+    dehydrated = hydraters.dehydrate(base_item, item)
+    assert dehydrated == {"assets": {"thumbnail": {"href": "http://foo.com"}}}
+
+
+def test_invalid_assets_marked() -> None:
+    base_item = {
+        "type": "Feature",
+        "assets": {
+            "asset1": {"name": "Asset one"},
+            "asset2": {"name": "Asset two"},
+        },
+    }
+    hydrated = {
+        "assets": {"asset1": {"name": "Asset one", "href": "http://foo.com"}},
+    }
+
+    dehydrated = hydraters.dehydrate(base_item, hydrated)
+
+    assert dehydrated == {
+        "type": DO_NOT_MERGE_MARKER,
+        "assets": {
+            "asset1": {"href": "http://foo.com"},
+            "asset2": DO_NOT_MERGE_MARKER,
+        },
+    }
+
+
+def test_top_level_base_keys_marked() -> None:
+    base_item = {
+        "single": "Feature",
+        "double": {"nested": "value"},
+        "triple": {"nested": {"deep": "value"}},
+        "included": "value",
+    }
+    hydrated = {"included": "value", "unique": "value"}
+
+    dehydrated = hydraters.dehydrate(base_item, hydrated)
+
+    assert dehydrated == {
+        "single": DO_NOT_MERGE_MARKER,
+        "double": DO_NOT_MERGE_MARKER,
+        "triple": DO_NOT_MERGE_MARKER,
+        "unique": "value",
+    }
diff --git a/tests/test_hydrate.py b/tests/test_hydrate.py
index 0fe544e..3ee61ce 100644
--- a/tests/test_hydrate.py
+++ b/tests/test_hydrate.py
@@ -1,6 +1,4 @@
-import json
-from pathlib import Path
-from typing import Any, cast
+from typing import Any
 
 import hydraters
 import pytest
@@ -11,11 +9,6 @@ def base() -> dict[str, Any]:
     return {"a": "first", "b": "second", "c": "third"}
 
 
-def load_json(path: Path) -> dict[str, Any]:
-    with open(Path(__file__).parent / "data" / path) as f:
-        return cast(dict[str, Any], json.load(f))
-
-
 def test_equal_hydrate(base: dict[str, Any]) -> None:
     result = hydraters.hydrate(base, base)
     assert result == base