Repository: hpcflow/valida

Issue:
Add `to_json_like`, `from_json_like` method to `ConditionLike`
This is so we can use `ConditionLike` objects in hpcflow `ActionCondition`s.

Discussion:

Patch:
diff --git a/valida/conditions.py b/valida/conditions.py
index 47da55b..934a882 100644
--- a/valida/conditions.py
+++ b/valida/conditions.py
@@ -526,6 +526,9 @@ class ConditionLike:
 
         return condition_like
 
+    @classmethod
+    def from_json_like(cls, json_like, *args, **kwargs):
+        return cls.from_spec(json_like)
 
 class Condition(ConditionLike):
 
@@ -621,6 +624,34 @@ class Condition(ConditionLike):
             data_has_paths=data_has_paths,
         )
 
+    def to_json_like(self, *args, **kwargs):
+        # need to return a single-item dict that can be passed to `from_spec` for
+        # round-tripping.
+
+        INV_DTYPE_LOOKUP = {
+            int: "int",
+            float: "float",
+            str: "str",
+            list: "list",
+            dict: "dict",
+            bool: "bool",
+            pathlib.Path: "path",
+        }
+
+        # TODO: doesn't work for BinaryOps?
+        key = f"{self.js_like_label}.{self.callable.func.__name__}"
+
+        if len(self.callable.kwargs) == 1:
+            _, val = next(iter(self.callable.kwargs.items()))
+            if "dtype" in key:
+                val = INV_DTYPE_LOOKUP[val]
+
+        out = {key: val}
+        if "shared_data" in kwargs:
+            return out, kwargs["shared_data"]
+        else:
+            return out
+
 
 class FilterDatumType(enum.Enum):
 
@@ -639,6 +670,12 @@ class NullCondition(Condition):
     def __repr__(self):
         return f"{self.__class__.__name__}()"
 
+    def to_json_like(self, *args, **kwargs):
+        if "shared_data" in kwargs:
+            return {}, kwargs["shared_data"]
+        else:
+            return {}
+
 
 class ConditionBinaryOp(ConditionLike):
     def __new__(cls, *conditions):
@@ -771,6 +808,8 @@ class IndexLike(Condition):
 
 
 class Value(ValueLike, AllCallables):
+    js_like_label = "value"
+
     @classproperty
     def length(cls):
         return ValueLength
@@ -781,14 +820,16 @@ class Value(ValueLike, AllCallables):
 
 
 class ValueLength(LengthPreProcessor, ValueLike, GeneralCallables):
-    pass
+    js_like_label = "value.length"
 
 
 class ValueDataType(DataTypePreProcessor, ValueLike, GeneralCallables):
-    pass
+    js_like_label = "value.dtype"
 
 
 class Key(KeyLike, AllCallables):
+    js_like_label = "key"
+
     @classproperty
     def length(cls):
         return KeyLength
@@ -799,12 +840,12 @@ class Key(KeyLike, AllCallables):
 
 
 class KeyLength(LengthPreProcessor, KeyLike, GeneralCallables):
-    pass
+    js_like_label = "key.length"
 
 
 class KeyDataType(DataTypePreProcessor, KeyLike, GeneralCallables):
-    pass
+    js_like_label = "key.dtype"
 
 
 class Index(IndexLike, GeneralCallables):
-    pass
+    js_like_label = "index"
diff --git a/valida/datapath.py b/valida/datapath.py
index 4105388..4c32d78 100644
--- a/valida/datapath.py
+++ b/valida/datapath.py
@@ -204,6 +204,17 @@ class DataPath:
 
         return obj
 
+    @classmethod
+    def from_json_like(cls, json_like, *args, **kwargs):
+        return cls.from_spec(json_like)
+
+    def to_json_like(self, *args, **kwargs):
+        out = self.to_part_specs()
+        if "shared_data" in kwargs:
+            return out, kwargs["shared_data"]
+        else:
+            return out
+
     @classmethod
     def from_part_specs(cls, *parts):
         """Construct a DataPath from parts that might include dicts, where each dict
@@ -217,6 +228,17 @@ class DataPath:
 
         return cls(*spec_resolved_parts)
 
+    def to_part_specs(self):
+        parts = []
+        for i in self.parts:
+            try:
+                part_spec = i.condition.callable.kwargs["value"]
+            except KeyError:
+                if isinstance(i, MapOrListValue):
+                    part_spec = i.list_condition.callable.kwargs["value"]
+            parts.append(part_spec)
+        return parts
+
     @classmethod
     def from_str(cls, path_str, delimiter="/"):
 
diff --git a/valida/rules.py b/valida/rules.py
index 9e10cd6..c008446 100644
--- a/valida/rules.py
+++ b/valida/rules.py
@@ -60,6 +60,21 @@ class Rule:
 
         return cls(path=path, condition=cond, cast=cast)
 
+    @classmethod
+    def from_json_like(cls, json_like, *args, **kwargs):
+        return cls.from_spec(json_like)
+
+    def to_json_like(self, *args, **kwargs):
+        out = {
+            "condition": self.condition.to_json_like(),
+            "cast": self.cast,
+            "path": self.path.to_json_like(),
+        }
+        if "shared_data" in kwargs:
+            return out, kwargs["shared_data"]
+        else:
+            return out
+
     def test(self, data, _data_copy=None):
 
         if not isinstance(data, Data):


Test patch:
diff --git a/tests/test_conditions.py b/tests/test_conditions.py
index 598805f..e918ac6 100644
--- a/tests/test_conditions.py
+++ b/tests/test_conditions.py
@@ -1,3 +1,4 @@
+import pathlib
 import pytest
 
 from valida.conditions import (
@@ -540,3 +541,51 @@ def test_ConditionLike_from_spec_equivalence_is_instance_native_type():
     assert ConditionLike.from_spec(
         {"value.is_instance": ["str", "int"]}
     ) == ConditionLike.from_spec({"value.is_instance": [str, int]})
+
+
+def test_to_json_like_round_trip_null():
+    c1 = NullCondition()
+    c1_js = c1.to_json_like()
+    assert ConditionLike.from_spec(c1_js) == c1
+
+
+def test_to_json_like_round_trip_value_equal_to():
+    c1 = Value.equal_to(1)
+    c1_js = c1.to_json_like()
+    assert ConditionLike.from_spec(c1_js) == c1
+
+
+def test_to_json_like_round_trip_value_dtype_int_equal_to():
+    c1 = Value.dtype.equal_to(int)
+    c1_js = c1.to_json_like()
+    assert ConditionLike.from_spec(c1_js) == c1
+
+
+def test_to_json_like_round_trip_value_dtype_str_equal_to():
+    c1 = Value.dtype.equal_to(str)
+    c1_js = c1.to_json_like()
+    assert ConditionLike.from_spec(c1_js) == c1
+
+
+def test_to_json_like_round_trip_value_dtype_path_equal_to():
+    c1 = Value.dtype.equal_to(pathlib.Path)
+    c1_js = c1.to_json_like()
+    assert ConditionLike.from_spec(c1_js) == c1
+
+
+def test_to_json_like_round_trip_value_length_equal_to():
+    c1 = Value.length.equal_to(2)
+    c1_js = c1.to_json_like()
+    assert ConditionLike.from_spec(c1_js) == c1
+
+
+def test_to_json_like_round_trip_key_equal_to():
+    c1 = Key.equal_to(2)
+    c1_js = c1.to_json_like()
+    assert ConditionLike.from_spec(c1_js) == c1
+
+
+def test_to_json_like_round_trip_index_equal_to():
+    c1 = Index.equal_to(2)
+    c1_js = c1.to_json_like()
+    assert ConditionLike.from_spec(c1_js) == c1
diff --git a/tests/test_datapath.py b/tests/test_datapath.py
index 9bf7bf7..8ad330a 100644
--- a/tests/test_datapath.py
+++ b/tests/test_datapath.py
@@ -873,3 +873,15 @@ def test_no_data_found_MapValue_list_data():
 
 def test_no_data_found_ListValue_map_data():
     assert Data({"a": 1, "b": 2}).get(ListValue()) == []
+
+
+def test_part_spec_round_trip_map_keys():
+    parts = ["inputs", "p1", "b", "0"]
+    dp1 = DataPath.from_part_specs(*parts)
+    assert dp1.to_part_specs() == parts
+
+
+def test_part_spec_round_trip_map_keys_or_list_index():
+    parts = ["inputs", "p1", "b", 0]
+    dp1 = DataPath.from_part_specs(*parts)
+    assert dp1.to_part_specs() == parts