Add json helper function json_replace

Function can replace content for a json path string in a dictionary
This commit is contained in:
Clemens Schwaighofer
2025-10-23 13:20:40 +09:00
parent e579ef5834
commit 2544fad9ce
6 changed files with 821 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
"""
tests for json_handling module
"""

View File

@@ -0,0 +1,698 @@
"""
tests for corelibs.json_handling.json_helper
"""
import json
from datetime import datetime, date
from typing import Any
from corelibs.json_handling.json_helper import (
DateTimeEncoder,
default,
json_dumps,
modify_with_jsonpath
)
# MARK: DateTimeEncoder tests
class TestDateTimeEncoder:
"""Test cases for DateTimeEncoder class"""
def test_datetime_encoding(self):
"""Test encoding datetime objects"""
dt = datetime(2025, 10, 23, 15, 30, 45, 123456)
data = {"timestamp": dt}
result = json.dumps(data, cls=DateTimeEncoder)
decoded = json.loads(result)
assert decoded["timestamp"] == "2025-10-23T15:30:45.123456"
def test_date_encoding(self):
"""Test encoding date objects"""
d = date(2025, 10, 23)
data = {"date": d}
result = json.dumps(data, cls=DateTimeEncoder)
decoded = json.loads(result)
assert decoded["date"] == "2025-10-23"
def test_mixed_datetime_date_encoding(self):
"""Test encoding mixed datetime and date objects"""
dt = datetime(2025, 10, 23, 15, 30, 45)
d = date(2025, 10, 23)
data = {
"timestamp": dt,
"date": d,
"name": "test"
}
result = json.dumps(data, cls=DateTimeEncoder)
decoded = json.loads(result)
assert decoded["timestamp"] == "2025-10-23T15:30:45"
assert decoded["date"] == "2025-10-23"
assert decoded["name"] == "test"
def test_nested_datetime_encoding(self):
"""Test encoding nested structures with datetime objects"""
data = {
"event": {
"name": "Meeting",
"start": datetime(2025, 10, 23, 10, 0, 0),
"end": datetime(2025, 10, 23, 11, 0, 0),
"participants": [
{"name": "Alice", "joined": datetime(2025, 10, 23, 10, 5, 0)},
{"name": "Bob", "joined": datetime(2025, 10, 23, 10, 10, 0)}
]
}
}
result = json.dumps(data, cls=DateTimeEncoder)
decoded = json.loads(result)
assert decoded["event"]["start"] == "2025-10-23T10:00:00"
assert decoded["event"]["end"] == "2025-10-23T11:00:00"
assert decoded["event"]["participants"][0]["joined"] == "2025-10-23T10:05:00"
assert decoded["event"]["participants"][1]["joined"] == "2025-10-23T10:10:00"
def test_list_of_datetimes(self):
"""Test encoding list of datetime objects"""
data = {
"timestamps": [
datetime(2025, 10, 23, 10, 0, 0),
datetime(2025, 10, 23, 11, 0, 0),
datetime(2025, 10, 23, 12, 0, 0)
]
}
result = json.dumps(data, cls=DateTimeEncoder)
decoded = json.loads(result)
assert decoded["timestamps"][0] == "2025-10-23T10:00:00"
assert decoded["timestamps"][1] == "2025-10-23T11:00:00"
assert decoded["timestamps"][2] == "2025-10-23T12:00:00"
def test_encoder_with_normal_types(self):
"""Test that encoder works with standard JSON types"""
data = {
"string": "test",
"number": 42,
"float": 3.14,
"boolean": True,
"null": None,
"list": [1, 2, 3],
"dict": {"key": "value"}
}
result = json.dumps(data, cls=DateTimeEncoder)
decoded = json.loads(result)
assert decoded == data
def test_encoder_returns_none_for_unsupported_types(self):
"""Test that encoder default method returns None for unsupported types"""
encoder = DateTimeEncoder()
# The default method should return None for non-date/datetime objects
result = encoder.default("string")
assert result is None
result = encoder.default(42)
assert result is None
result = encoder.default([1, 2, 3])
assert result is None
# MARK: default function tests
class TestDefaultFunction:
"""Test cases for the default function"""
def test_default_datetime(self):
"""Test default function with datetime"""
dt = datetime(2025, 10, 23, 15, 30, 45)
result = default(dt)
assert result == "2025-10-23T15:30:45"
def test_default_date(self):
"""Test default function with date"""
d = date(2025, 10, 23)
result = default(d)
assert result == "2025-10-23"
def test_default_with_microseconds(self):
"""Test default function with datetime including microseconds"""
dt = datetime(2025, 10, 23, 15, 30, 45, 123456)
result = default(dt)
assert result == "2025-10-23T15:30:45.123456"
def test_default_returns_none_for_other_types(self):
"""Test that default returns None for non-date/datetime objects"""
assert default("string") is None
assert default(42) is None
assert default(3.14) is None
assert default(True) is None
assert default(None) is None
assert default([1, 2, 3]) is None
assert default({"key": "value"}) is None
def test_default_as_json_default_parameter(self):
"""Test using default function as default parameter in json.dumps"""
data = {
"timestamp": datetime(2025, 10, 23, 15, 30, 45),
"date": date(2025, 10, 23),
"name": "test"
}
result = json.dumps(data, default=default)
decoded = json.loads(result)
assert decoded["timestamp"] == "2025-10-23T15:30:45"
assert decoded["date"] == "2025-10-23"
assert decoded["name"] == "test"
# MARK: json_dumps tests
class TestJsonDumps:
"""Test cases for json_dumps function"""
def test_basic_dict(self):
"""Test json_dumps with basic dictionary"""
data = {"name": "test", "value": 42}
result = json_dumps(data)
decoded = json.loads(result)
assert decoded == data
def test_unicode_characters(self):
"""Test json_dumps preserves unicode characters (ensure_ascii=False)"""
data = {"name": "テスト", "emoji": "🎉", "chinese": "测试"}
result = json_dumps(data)
# ensure_ascii=False means unicode characters should be preserved
assert "テスト" in result
assert "🎉" in result
assert "测试" in result
decoded = json.loads(result)
assert decoded == data
def test_datetime_objects_as_string(self):
"""Test json_dumps converts datetime to string (default=str)"""
dt = datetime(2025, 10, 23, 15, 30, 45)
data = {"timestamp": dt}
result = json_dumps(data)
decoded = json.loads(result)
# default=str will convert datetime to its string representation
assert isinstance(decoded["timestamp"], str)
assert "2025-10-23" in decoded["timestamp"]
def test_date_objects_as_string(self):
"""Test json_dumps converts date to string"""
d = date(2025, 10, 23)
data = {"date": d}
result = json_dumps(data)
decoded = json.loads(result)
assert isinstance(decoded["date"], str)
assert "2025-10-23" in decoded["date"]
def test_complex_nested_structure(self):
"""Test json_dumps with complex nested structures"""
data = {
"user": {
"name": "John",
"age": 30,
"active": True,
"balance": 100.50,
"tags": ["admin", "user"],
"metadata": {
"created": datetime(2025, 1, 1, 0, 0, 0),
"updated": date(2025, 10, 23)
}
},
"items": [
{"id": 1, "name": "Item 1"},
{"id": 2, "name": "Item 2"}
]
}
result = json_dumps(data)
decoded = json.loads(result)
assert decoded["user"]["name"] == "John"
assert decoded["user"]["age"] == 30
assert decoded["user"]["active"] is True
assert decoded["user"]["balance"] == 100.50
assert decoded["user"]["tags"] == ["admin", "user"]
assert decoded["items"][0]["id"] == 1
def test_empty_dict(self):
"""Test json_dumps with empty dictionary"""
data: dict[str, Any] = {}
result = json_dumps(data)
assert result == "{}"
def test_empty_list(self):
"""Test json_dumps with empty list"""
data: list[Any] = []
result = json_dumps(data)
assert result == "[]"
def test_list_data(self):
"""Test json_dumps with list as root element"""
data = [1, 2, 3, "test", True, None]
result = json_dumps(data)
decoded = json.loads(result)
assert decoded == data
def test_none_value(self):
"""Test json_dumps with None"""
data = None
result = json_dumps(data)
assert result == "null"
def test_boolean_values(self):
"""Test json_dumps with boolean values"""
data = {"true_val": True, "false_val": False}
result = json_dumps(data)
decoded = json.loads(result)
assert decoded["true_val"] is True
assert decoded["false_val"] is False
def test_numeric_values(self):
"""Test json_dumps with various numeric values"""
data = {
"int": 42,
"float": 3.14,
"negative": -10,
"zero": 0,
"scientific": 1e10
}
result = json_dumps(data)
decoded = json.loads(result)
assert decoded == data
def test_custom_object_conversion(self):
"""Test json_dumps with custom objects (converted via str)"""
class CustomObject:
"""test class"""
def __str__(self):
return "custom_value"
data = {"custom": CustomObject()}
result = json_dumps(data)
decoded = json.loads(result)
assert decoded["custom"] == "custom_value"
def test_special_float_values(self):
"""Test json_dumps handles special float values"""
data = {
"infinity": float('inf'),
"neg_infinity": float('-inf'),
"nan": float('nan')
}
result = json_dumps(data)
# These should be converted to strings via default=str
assert "Infinity" in result or "inf" in result.lower()
# MARK: modify_with_jsonpath tests
class TestModifyWithJsonpath:
"""Test cases for modify_with_jsonpath function"""
def test_simple_path_modification(self):
"""Test modifying a simple path"""
data = {"name": "old_name", "age": 30}
result = modify_with_jsonpath(data, "$.name", "new_name")
assert result["name"] == "new_name"
assert result["age"] == 30
# Original data should not be modified
assert data["name"] == "old_name"
def test_nested_path_modification(self):
"""Test modifying nested path"""
data = {
"user": {
"profile": {
"name": "John",
"age": 30
}
}
}
result = modify_with_jsonpath(data, "$.user.profile.name", "Jane")
assert result["user"]["profile"]["name"] == "Jane"
assert result["user"]["profile"]["age"] == 30
# Original should be unchanged
assert data["user"]["profile"]["name"] == "John"
def test_array_index_modification(self):
"""Test modifying array element by index"""
data = {
"items": [
{"id": 1, "name": "Item 1"},
{"id": 2, "name": "Item 2"},
{"id": 3, "name": "Item 3"}
]
}
result = modify_with_jsonpath(data, "$.items[1].name", "Updated Item 2")
assert result["items"][1]["name"] == "Updated Item 2"
assert result["items"][0]["name"] == "Item 1"
assert result["items"][2]["name"] == "Item 3"
# Original unchanged
assert data["items"][1]["name"] == "Item 2"
def test_wildcard_modification(self):
"""Test modifying multiple elements with wildcard"""
data = {
"users": [
{"name": "Alice", "active": True},
{"name": "Bob", "active": True},
{"name": "Charlie", "active": True}
]
}
result = modify_with_jsonpath(data, "$.users[*].active", False)
# All active fields should be updated
for user in result["users"]:
assert user["active"] is False
# Original unchanged
for user in data["users"]:
assert user["active"] is True
def test_deep_copy_behavior(self):
"""Test that modifications don't affect the original data"""
original = {
"level1": {
"level2": {
"level3": {
"value": "original"
}
}
}
}
result = modify_with_jsonpath(original, "$.level1.level2.level3.value", "modified")
assert result["level1"]["level2"]["level3"]["value"] == "modified"
assert original["level1"]["level2"]["level3"]["value"] == "original"
# Verify deep copy by modifying nested dict in result
result["level1"]["level2"]["new_key"] = "new_value"
assert "new_key" not in original["level1"]["level2"]
def test_modify_to_different_type(self):
"""Test changing value to different type"""
data = {"count": "10"}
result = modify_with_jsonpath(data, "$.count", 10)
assert result["count"] == 10
assert isinstance(result["count"], int)
assert data["count"] == "10"
def test_modify_to_complex_object(self):
"""Test replacing value with complex object"""
data = {"simple": "value"}
new_value = {"complex": {"nested": "structure"}}
result = modify_with_jsonpath(data, "$.simple", new_value)
assert result["simple"] == new_value
assert result["simple"]["complex"]["nested"] == "structure"
def test_modify_to_list(self):
"""Test replacing value with list"""
data = {"items": None}
result = modify_with_jsonpath(data, "$.items", [1, 2, 3])
assert result["items"] == [1, 2, 3]
assert data["items"] is None
def test_modify_to_none(self):
"""Test setting value to None"""
data = {"value": "something"}
result = modify_with_jsonpath(data, "$.value", None)
assert result["value"] is None
assert data["value"] == "something"
def test_recursive_descent(self):
"""Test using recursive descent operator"""
data: dict[str, Any] = {
"store": {
"book": [
{"title": "Book 1", "price": 10},
{"title": "Book 2", "price": 20}
],
"bicycle": {
"price": 100
}
}
}
# Update all prices
result = modify_with_jsonpath(data, "$..price", 0)
assert result["store"]["book"][0]["price"] == 0
assert result["store"]["book"][1]["price"] == 0
assert result["store"]["bicycle"]["price"] == 0
# Original unchanged
assert data["store"]["book"][0]["price"] == 10
def test_specific_array_elements(self):
"""Test updating specific array elements by index"""
data = {
"products": [
{"name": "Product 1", "price": 100, "stock": 5},
{"name": "Product 2", "price": 200, "stock": 0},
{"name": "Product 3", "price": 150, "stock": 10}
]
}
# Update first product's price
result = modify_with_jsonpath(data, "$.products[0].price", 0)
assert result["products"][0]["price"] == 0
assert result["products"][1]["price"] == 200 # not modified
assert result["products"][2]["price"] == 150 # not modified
def test_empty_dict(self):
"""Test modifying empty dictionary"""
data: dict[str, Any] = {}
result = modify_with_jsonpath(data, "$.nonexistent", "value")
# Should return the original empty dict since path doesn't exist
assert result == {}
def test_complex_real_world_scenario(self):
"""Test complex real-world modification scenario"""
data: dict[str, Any] = {
"api_version": "1.0",
"config": {
"database": {
"host": "localhost",
"port": 5432,
"credentials": {
"username": "admin",
"password": "secret"
}
},
"services": [
{"name": "auth", "enabled": True, "port": 8001},
{"name": "api", "enabled": True, "port": 8002},
{"name": "cache", "enabled": False, "port": 8003}
]
}
}
# Update database port
result = modify_with_jsonpath(data, "$.config.database.port", 5433)
assert result["config"]["database"]["port"] == 5433
# Update all service ports
result2 = modify_with_jsonpath(result, "$.config.services[*].enabled", True)
assert all(service["enabled"] for service in result2["config"]["services"])
# Original unchanged
assert data["config"]["database"]["port"] == 5432
assert data["config"]["services"][2]["enabled"] is False
def test_list_slice_modification(self):
"""Test modifying list slice"""
data = {"numbers": [1, 2, 3, 4, 5]}
# Modify first three elements
result = modify_with_jsonpath(data, "$.numbers[0:3]", 0)
assert result["numbers"][0] == 0
assert result["numbers"][1] == 0
assert result["numbers"][2] == 0
assert result["numbers"][3] == 4
assert result["numbers"][4] == 5
def test_modify_with_datetime_value(self):
"""Test modifying with datetime value"""
data = {"timestamp": "2025-01-01T00:00:00"}
new_datetime = datetime(2025, 10, 23, 15, 30, 45)
result = modify_with_jsonpath(data, "$.timestamp", new_datetime)
assert result["timestamp"] == new_datetime
assert isinstance(result["timestamp"], datetime)
# MARK: Integration tests
class TestIntegration:
"""Integration tests combining multiple functions"""
def test_encoder_and_json_dumps_comparison(self):
"""Test that DateTimeEncoder and json_dumps handle datetimes differently"""
dt = datetime(2025, 10, 23, 15, 30, 45)
data = {"timestamp": dt}
# Using DateTimeEncoder produces ISO format
with_encoder = json.dumps(data, cls=DateTimeEncoder)
decoded_encoder = json.loads(with_encoder)
assert decoded_encoder["timestamp"] == "2025-10-23T15:30:45"
# Using json_dumps (default=str) produces string representation
with_dumps = json_dumps(data)
decoded_dumps = json.loads(with_dumps)
assert isinstance(decoded_dumps["timestamp"], str)
assert "2025-10-23" in decoded_dumps["timestamp"]
def test_modify_and_serialize(self):
"""Test modifying data and then serializing it"""
data = {
"event": {
"name": "Meeting",
"date": date(2025, 10, 23),
"attendees": [
{"name": "Alice", "confirmed": False},
{"name": "Bob", "confirmed": False}
]
}
}
# Modify confirmation status
modified = modify_with_jsonpath(data, "$.event.attendees[*].confirmed", True)
# Serialize with datetime handling
serialized = json.dumps(modified, cls=DateTimeEncoder)
decoded = json.loads(serialized)
assert decoded["event"]["date"] == "2025-10-23"
assert decoded["event"]["attendees"][0]["confirmed"] is True
assert decoded["event"]["attendees"][1]["confirmed"] is True
def test_round_trip_with_modification(self):
"""Test full round trip: serialize -> modify -> serialize"""
original = {
"config": {
"updated": datetime(2025, 10, 23, 15, 30, 45),
"version": "1.0"
}
}
# Serialize
json_str = json.dumps(original, cls=DateTimeEncoder)
# Deserialize
deserialized = json.loads(json_str)
# Modify
modified = modify_with_jsonpath(deserialized, "$.config.version", "2.0")
# Serialize again
final_json = json_dumps(modified)
final_data = json.loads(final_json)
assert final_data["config"]["version"] == "2.0"
assert final_data["config"]["updated"] == "2025-10-23T15:30:45"
# MARK: Edge cases
class TestEdgeCases:
"""Test edge cases and error scenarios"""
def test_circular_reference_in_modify(self):
"""Test that modify_with_jsonpath handles data without circular references"""
# Note: JSON doesn't support circular references, so we test normal nested data
data = {
"a": {
"b": {
"c": "value"
}
}
}
result = modify_with_jsonpath(data, "$.a.b.c", "new_value")
assert result["a"]["b"]["c"] == "new_value"
def test_unicode_in_keys_and_values(self):
"""Test handling unicode in both keys and values"""
data = {
"日本語": "テスト",
"emoji_🎉": "🚀",
"normal": "value"
}
result = json_dumps(data)
decoded = json.loads(result)
assert decoded["日本語"] == "テスト"
assert decoded["emoji_🎉"] == "🚀"
assert decoded["normal"] == "value"
def test_very_nested_structure(self):
"""Test deeply nested structure"""
# Create a 10-level deep nested structure
data: dict[str, Any] = {"level0": {}}
current = data["level0"]
for i in range(1, 10):
current[f"level{i}"] = {}
current = current[f"level{i}"]
current["value"] = "deep_value"
result = modify_with_jsonpath(data, "$..value", "modified_deep_value")
# Navigate to the deep value
current = result["level0"]
for i in range(1, 10):
current = current[f"level{i}"]
assert current["value"] == "modified_deep_value"
def test_large_list_modification(self):
"""Test modifying large list"""
data = {"items": [{"id": i, "value": i * 10} for i in range(100)]}
result = modify_with_jsonpath(data, "$.items[*].value", 0)
assert all(item["value"] == 0 for item in result["items"])
assert len(result["items"]) == 100
def test_mixed_date_types_encoding(self):
"""Test encoding with both date and datetime in same structure"""
data = {
"created_date": date(2025, 10, 23),
"created_datetime": datetime(2025, 10, 23, 15, 30, 45),
"updated_date": date(2025, 10, 24),
"updated_datetime": datetime(2025, 10, 24, 16, 45, 30)
}
result = json.dumps(data, cls=DateTimeEncoder)
decoded = json.loads(result)
assert decoded["created_date"] == "2025-10-23"
assert decoded["created_datetime"] == "2025-10-23T15:30:45"
assert decoded["updated_date"] == "2025-10-24"
assert decoded["updated_datetime"] == "2025-10-24T16:45:30"