From 4ad9e93b6306d2a9938e8b144a25c566926e0941 Mon Sep 17 00:00:00 2001 From: Chris Dumas Date: Tue, 21 Jul 2026 11:03:17 +0000 Subject: [PATCH] Add Dumas JSON utility batch --- README.md | 47 ++++++ dumas_json_nodes.py | 260 +++++++++++++++++++++++++++++++++ tests/test_dumas_json_nodes.py | 59 ++++++++ 3 files changed, 366 insertions(+) diff --git a/README.md b/README.md index 005c97b..8b1bd05 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,21 @@ - Output: updated `JSON` - Writes a parsed JSON value into a nested path without mutating the original object. +- `Dumas JSON Has Key` + - Inputs: `json_object`, `path` + - Output: `BOOLEAN` + - Checks whether a nested path exists. + +- `Dumas JSON Remove Key` + - Inputs: `json_object`, `path` + - Output: updated `JSON` + - Removes a nested key or array item by path. + +- `Dumas JSON Pick Fields` + - Inputs: `json_object`, `paths` + - Output: filtered `JSON` + - Builds a new object from newline-separated paths that exist in the input. + - `Dumas JSON Merge Objects` - Inputs: `base_object`, `overlay_object` - Output: merged `JSON` @@ -39,6 +54,16 @@ - Output: `INT` - Returns the length of a JSON array. +- `Dumas JSON Array Append` + - Inputs: `json_array`, `value_json` + - Output: updated `JSON` + - Appends a parsed JSON value to the end of an array. + +- `Dumas JSON Array Slice` + - Inputs: `json_array`, `start`, `end`, `step` + - Output: sliced `JSON` + - Returns an array slice. `end=0` means "slice to the end". + - `Dumas JSON Array Iterator` - Inputs: `json_input`, `index`, `mode` - Outputs: `item`, `current_index`, `total_items` @@ -49,6 +74,16 @@ - Outputs: `key`, `value`, `current_index`, `total_items` - Returns the current object entry in insertion order, with `mode` set to `fixed`, `incr`, or `decr`. +- `Dumas JSON Flatten` + - Input: `json_input` + - Output: flat `JSON` + - Flattens nested objects and arrays into dot-path keys. + +- `Dumas JSON Unflatten` + - Input: `flat_json_object` + - Output: nested `JSON` + - Rebuilds nested JSON from a flat dot-path object. + All nodes live in the `Dumas/JSON` category. ## Installation @@ -87,6 +122,16 @@ settings.render.width {"enabled": true, "retries": 3} ``` +`Dumas JSON Pick Fields` expects one path per line: + +```text +user.name +meta.active +items.0.id +``` + +`Dumas JSON Array Slice` treats `end=0` as "use the rest of the array". + `Dumas JSON Array Iterator` clamps to valid bounds: ```text @@ -97,6 +142,8 @@ decr -> use index - 1 `Dumas JSON Object Iterator` uses the same index rules and iterates object entries in their existing key order. +`Dumas JSON Flatten` and `Dumas JSON Unflatten` use the same dot-path format as the other path-based nodes. + ## Roadmap This repo is intended to grow into a broader set of Dumas-branded generic utility nodes, including JSON helpers and adjacent data-manipulation tools. diff --git a/dumas_json_nodes.py b/dumas_json_nodes.py index be184dd..a6c26fc 100644 --- a/dumas_json_nodes.py +++ b/dumas_json_nodes.py @@ -43,6 +43,21 @@ def _get_json_path_value(data, path): return current +def _has_json_path(data, path): + current = data + for segment in _parse_json_path(path): + if isinstance(segment, int): + if not isinstance(current, list) or segment >= len(current): + return False + current = current[segment] + continue + + if not isinstance(current, dict) or segment not in current: + return False + current = current[segment] + return True + + def _build_missing_container(next_segment): return [] if isinstance(next_segment, int) else {} @@ -104,6 +119,50 @@ def _set_json_path_value(data, path, value): return _set_json_path_value_recursive(data, segments, value) +def _remove_json_path_value_recursive(current, segments): + if not segments: + return _clone_container(current) + + segment = segments[0] + is_last = len(segments) == 1 + + if isinstance(segment, int): + if not isinstance(current, list) or segment >= len(current): + return None + + result = list(current) + if is_last: + del result[segment] + return result + + updated = _remove_json_path_value_recursive(result[segment], segments[1:]) + if updated is None: + return None + result[segment] = updated + return result + + if not isinstance(current, dict) or segment not in current: + return None + + result = dict(current) + if is_last: + del result[segment] + return result + + updated = _remove_json_path_value_recursive(result[segment], segments[1:]) + if updated is None: + return None + result[segment] = updated + return result + + +def _remove_json_path_value(data, path): + segments = _parse_json_path(path) + if not segments: + return _clone_container(data) + return _remove_json_path_value_recursive(data, segments) + + def _merge_json_objects(base_object, overlay_object): if not isinstance(base_object, dict) or not isinstance(overlay_object, dict): return None @@ -118,6 +177,46 @@ def _merge_json_objects(base_object, overlay_object): return merged +def _flatten_json_value(value, prefix="", result=None): + if result is None: + result = {} + + if isinstance(value, dict): + if not value and prefix: + result[prefix] = {} + return result + for key, nested_value in value.items(): + next_prefix = f"{prefix}.{key}" if prefix else str(key) + _flatten_json_value(nested_value, next_prefix, result) + return result + + if isinstance(value, list): + if not value and prefix: + result[prefix] = [] + return result + for index, nested_value in enumerate(value): + next_prefix = f"{prefix}.{index}" if prefix else str(index) + _flatten_json_value(nested_value, next_prefix, result) + return result + + result[prefix] = value + return result + + +def _unflatten_json_object(flat_object): + if not isinstance(flat_object, dict): + return None + + result = {} + for path, value in flat_object.items(): + if not isinstance(path, str): + return None + result = _set_json_path_value(result, path, value) + if result is None: + return None + return result + + def _resolve_iteration_index(index, mode, total_items): if total_items <= 0: return -1 @@ -216,6 +315,69 @@ class DumasJSONSetValueNode: return (_set_json_path_value(json_object, path, value),) +class DumasJSONHasKeyNode: + CATEGORY = "Dumas/JSON" + RETURN_TYPES = ("BOOLEAN",) + FUNCTION = "has_key" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "json_object": ("JSON",), + "path": ("STRING", {"default": "", "multiline": False}), + } + } + + def has_key(self, json_object, path): + return (_has_json_path(json_object, path),) + + +class DumasJSONRemoveKeyNode: + CATEGORY = "Dumas/JSON" + RETURN_TYPES = ("JSON",) + FUNCTION = "remove_key" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "json_object": ("JSON",), + "path": ("STRING", {"default": "", "multiline": False}), + } + } + + def remove_key(self, json_object, path): + return (_remove_json_path_value(json_object, path),) + + +class DumasJSONPickFieldsNode: + CATEGORY = "Dumas/JSON" + RETURN_TYPES = ("JSON",) + FUNCTION = "pick_fields" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "json_object": ("JSON",), + "paths": ("STRING", {"multiline": True, "default": ""}), + } + } + + def pick_fields(self, json_object, paths): + result = {} + for raw_path in paths.splitlines(): + path = raw_path.strip() + if not path or not _has_json_path(json_object, path): + continue + value = _get_json_path_value(json_object, path) + result = _set_json_path_value(result, path, value) + if result is None: + return (None,) + return (result,) + + class DumasJSONMergeObjectsNode: CATEGORY = "Dumas/JSON" RETURN_TYPES = ("JSON",) @@ -274,6 +436,56 @@ class DumasJSONArrayLengthNode: return (len(json_array),) +class DumasJSONArrayAppendNode: + CATEGORY = "Dumas/JSON" + RETURN_TYPES = ("JSON",) + FUNCTION = "append_item" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "json_array": ("JSON",), + "value_json": ("STRING", {"multiline": True, "default": "null"}), + } + } + + def append_item(self, json_array, value_json): + if not isinstance(json_array, list): + return (None,) + try: + value = json.loads(value_json) + except json.JSONDecodeError as exc: + print(f"[DumasNodes] Error decoding JSON value: {exc}") + return (None,) + result = list(json_array) + result.append(value) + return (result,) + + +class DumasJSONArraySliceNode: + CATEGORY = "Dumas/JSON" + RETURN_TYPES = ("JSON",) + FUNCTION = "slice_array" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "json_array": ("JSON",), + "start": ("INT", {"default": 0, "step": 1}), + "end": ("INT", {"default": 0, "step": 1}), + "step": ("INT", {"default": 1, "step": 1, "min": 1}), + } + } + + def slice_array(self, json_array, start, end, step): + if not isinstance(json_array, list): + return (None,) + actual_end = None if end == 0 else end + return (json_array[start:actual_end:step],) + + class DumasJSONArrayIteratorNode: CATEGORY = "Dumas/JSON" RETURN_TYPES = ("JSON", "INT", "INT") @@ -331,16 +543,57 @@ class DumasJSONObjectIteratorNode: return (key, value, current_index, total_items) +class DumasJSONFlattenNode: + CATEGORY = "Dumas/JSON" + RETURN_TYPES = ("JSON",) + FUNCTION = "flatten_json" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "json_input": ("JSON",), + } + } + + def flatten_json(self, json_input): + return (_flatten_json_value(json_input),) + + +class DumasJSONUnflattenNode: + CATEGORY = "Dumas/JSON" + RETURN_TYPES = ("JSON",) + FUNCTION = "unflatten_json" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "flat_json_object": ("JSON",), + } + } + + def unflatten_json(self, flat_json_object): + return (_unflatten_json_object(flat_json_object),) + + NODE_CLASS_MAPPINGS = { "DumasJSONStringToObject": DumasJSONStringToObjectNode, "DumasJSONObjectToString": DumasJSONObjectToStringNode, "DumasJSONGetValue": DumasJSONGetValueNode, "DumasJSONSetValue": DumasJSONSetValueNode, + "DumasJSONHasKey": DumasJSONHasKeyNode, + "DumasJSONRemoveKey": DumasJSONRemoveKeyNode, + "DumasJSONPickFields": DumasJSONPickFieldsNode, "DumasJSONMergeObjects": DumasJSONMergeObjectsNode, "DumasJSONKeys": DumasJSONKeysNode, "DumasJSONArrayLength": DumasJSONArrayLengthNode, + "DumasJSONArrayAppend": DumasJSONArrayAppendNode, + "DumasJSONArraySlice": DumasJSONArraySliceNode, "DumasJSONArrayIterator": DumasJSONArrayIteratorNode, "DumasJSONObjectIterator": DumasJSONObjectIteratorNode, + "DumasJSONFlatten": DumasJSONFlattenNode, + "DumasJSONUnflatten": DumasJSONUnflattenNode, } NODE_DISPLAY_NAME_MAPPINGS = { @@ -348,9 +601,16 @@ NODE_DISPLAY_NAME_MAPPINGS = { "DumasJSONObjectToString": "Dumas JSON Object to String", "DumasJSONGetValue": "Dumas JSON Get Value", "DumasJSONSetValue": "Dumas JSON Set Value", + "DumasJSONHasKey": "Dumas JSON Has Key", + "DumasJSONRemoveKey": "Dumas JSON Remove Key", + "DumasJSONPickFields": "Dumas JSON Pick Fields", "DumasJSONMergeObjects": "Dumas JSON Merge Objects", "DumasJSONKeys": "Dumas JSON Keys", "DumasJSONArrayLength": "Dumas JSON Array Length", + "DumasJSONArrayAppend": "Dumas JSON Array Append", + "DumasJSONArraySlice": "Dumas JSON Array Slice", "DumasJSONArrayIterator": "Dumas JSON Array Iterator", "DumasJSONObjectIterator": "Dumas JSON Object Iterator", + "DumasJSONFlatten": "Dumas JSON Flatten", + "DumasJSONUnflatten": "Dumas JSON Unflatten", } diff --git a/tests/test_dumas_json_nodes.py b/tests/test_dumas_json_nodes.py index 55fdbf5..de83c63 100644 --- a/tests/test_dumas_json_nodes.py +++ b/tests/test_dumas_json_nodes.py @@ -1,11 +1,18 @@ import unittest from dumas_json_nodes import ( + DumasJSONArrayAppendNode, DumasJSONArrayLengthNode, DumasJSONArrayIteratorNode, + DumasJSONArraySliceNode, + DumasJSONFlattenNode, DumasJSONGetValueNode, + DumasJSONHasKeyNode, DumasJSONKeysNode, DumasJSONMergeObjectsNode, + DumasJSONPickFieldsNode, + DumasJSONRemoveKeyNode, + DumasJSONUnflattenNode, DumasJSONObjectIteratorNode, DumasJSONObjectToStringNode, DumasJSONStringToObjectNode, @@ -48,6 +55,30 @@ class DumasJSONNodeTests(unittest.TestCase): self.assertEqual(result, ({"user": {"name": "Dumas"}},)) + def test_has_key_checks_nested_paths(self): + node = DumasJSONHasKeyNode() + + result = node.has_key({"user": {"name": "Chris"}}, "user.name") + + self.assertEqual(result, (True,)) + + def test_remove_key_removes_nested_values(self): + node = DumasJSONRemoveKeyNode() + + result = node.remove_key({"user": {"name": "Chris", "role": "owner"}}, "user.role") + + self.assertEqual(result, ({"user": {"name": "Chris"}},)) + + def test_pick_fields_builds_filtered_object(self): + node = DumasJSONPickFieldsNode() + + result = node.pick_fields( + {"user": {"name": "Chris", "role": "owner"}, "meta": {"active": True}}, + "user.name\nmeta.active", + ) + + self.assertEqual(result, ({"user": {"name": "Chris"}, "meta": {"active": True}},)) + def test_merge_objects_deep_merges_dictionaries(self): node = DumasJSONMergeObjectsNode() @@ -75,6 +106,20 @@ class DumasJSONNodeTests(unittest.TestCase): self.assertEqual(result, (4,)) + def test_array_append_appends_parsed_value(self): + node = DumasJSONArrayAppendNode() + + result = node.append_item([1, 2], '{"name":"Dumas"}') + + self.assertEqual(result, ([1, 2, {"name": "Dumas"}],)) + + def test_array_slice_returns_subarray(self): + node = DumasJSONArraySliceNode() + + result = node.slice_array([0, 1, 2, 3, 4], 1, 4, 2) + + self.assertEqual(result, ([1, 3],)) + def test_array_iterator_fixed_mode_uses_given_index(self): node = DumasJSONArrayIteratorNode() @@ -145,6 +190,20 @@ class DumasJSONNodeTests(unittest.TestCase): self.assertEqual(result, (None, None, -1, 0)) + def test_flatten_converts_nested_data_to_path_map(self): + node = DumasJSONFlattenNode() + + result = node.flatten_json({"user": {"name": "Chris"}, "items": [{"id": 1}]}) + + self.assertEqual(result, ({"user.name": "Chris", "items.0.id": 1},)) + + def test_unflatten_rebuilds_nested_data(self): + node = DumasJSONUnflattenNode() + + result = node.unflatten_json({"user.name": "Chris", "items.0.id": 1}) + + self.assertEqual(result, ({"user": {"name": "Chris"}, "items": [{"id": 1}]},)) + if __name__ == "__main__": unittest.main()