Coverage for tests / unit / json_serialize / test_json_serialize.py: 98%
346 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-18 02:51 -0700
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-18 02:51 -0700
1"""Tests for muutils.json_serialize.json_serialize module.
3IMPORTANT: This tests the core json_serialize functionality. Array-specific tests are in test_array.py,
4and utility function tests are in test_util.py. We focus on JsonSerializer class and handler system here.
5"""
7from __future__ import annotations
9import warnings
10from collections import namedtuple
11from dataclasses import dataclass
12from pathlib import Path
13from typing import Any
15import pytest
17from muutils.errormode import ErrorMode
18from muutils.json_serialize.json_serialize import (
19 BASE_HANDLERS,
20 DEFAULT_HANDLERS,
21 JsonSerializer,
22 SerializerHandler,
23 json_serialize,
24)
25from muutils.json_serialize.types import _FORMAT_KEY
26from muutils.json_serialize.util import SerializationException
29# ============================================================================
30# Test classes and fixtures
31# ============================================================================
34@dataclass
35class SimpleDataclass:
36 """Simple dataclass for testing."""
38 x: int
39 y: str
40 z: bool = True
43@dataclass
44class NestedDataclass:
45 """Nested dataclass for testing."""
47 simple: SimpleDataclass
48 data: dict[str, Any]
51class ClassWithSerialize:
52 """Class with custom serialize method."""
54 def __init__(self, value: int):
55 self.value = value
56 self.name = "test"
58 def serialize(self) -> dict:
59 """Custom serialization."""
60 return {"custom_value": self.value * 2, "custom_name": self.name.upper()}
63class UnserializableClass:
64 """Class that can't be easily serialized."""
66 def __init__(self):
67 self.data = "test"
70# ============================================================================
71# Tests for basic type serialization
72# ============================================================================
75def test_json_serialize_basic_types():
76 """Test serialization of basic Python types: int, float, str, bool, None, list, dict."""
77 serializer = JsonSerializer()
79 # Test primitives
80 assert serializer.json_serialize(42) == 42
81 assert serializer.json_serialize(3.14) == 3.14
82 assert serializer.json_serialize("hello") == "hello"
83 assert serializer.json_serialize(True) is True
84 assert serializer.json_serialize(False) is False
85 assert serializer.json_serialize(None) is None
87 # Test list
88 list_result = serializer.json_serialize([1, 2, 3])
89 assert list_result == [1, 2, 3]
90 assert isinstance(list_result, list)
92 # Test dict
93 dict_result = serializer.json_serialize({"a": 1, "b": 2})
94 assert dict_result == {"a": 1, "b": 2}
95 assert isinstance(dict_result, dict)
97 # Test empty containers
98 assert serializer.json_serialize([]) == []
99 assert serializer.json_serialize({}) == {}
102def test_json_serialize_function():
103 """Test the module-level json_serialize function with default config."""
104 # Test that it works with basic types
105 assert json_serialize(42) == 42
106 assert json_serialize("test") == "test"
107 assert json_serialize([1, 2, 3]) == [1, 2, 3]
108 assert json_serialize({"key": "value"}) == {"key": "value"}
110 # Test with more complex types
111 obj = SimpleDataclass(x=10, y="hello", z=False)
112 result = json_serialize(obj)
113 assert result == {"x": 10, "y": "hello", "z": False}
116# ============================================================================
117# Tests for .serialize() method override
118# ============================================================================
121def test_json_serialize_serialize_method():
122 """Test objects with .serialize() method are handled correctly."""
123 serializer = JsonSerializer()
125 obj = ClassWithSerialize(value=5)
126 result = serializer.json_serialize(obj)
127 assert isinstance(result, dict)
129 # Should use the custom serialize method
130 assert result == {"custom_value": 10, "custom_name": "TEST"}
131 assert result["custom_value"] == obj.value * 2 # ty: ignore[invalid-argument-type]
132 assert result["custom_name"] == obj.name.upper() # ty: ignore[invalid-argument-type]
135def test_serialize_method_priority():
136 """Test that .serialize() method takes priority over other handlers."""
137 serializer = JsonSerializer()
139 # Even though this is a dataclass, the .serialize() method should take priority
140 @dataclass
141 class DataclassWithSerialize:
142 x: int
143 y: int
145 def serialize(self) -> dict:
146 return {"sum": self.x + self.y}
148 obj = DataclassWithSerialize(x=3, y=7)
149 result = serializer.json_serialize(obj)
150 assert isinstance(result, dict)
152 # Should use custom serialize, not dataclass handler
153 assert result == {"sum": 10}
154 assert "x" not in result
155 assert "y" not in result
158# ============================================================================
159# Tests for custom handlers
160# ============================================================================
163def test_JsonSerializer_custom_handlers():
164 """Test adding custom pre/post handlers and verify execution order."""
165 # Create a custom handler that captures specific types
166 custom_check_count = {"count": 0}
167 custom_serialize_count = {"count": 0}
169 def custom_check(self, obj, path):
170 custom_check_count["count"] += 1
171 return isinstance(obj, str) and obj.startswith("CUSTOM:")
173 def custom_serialize(self, obj, path):
174 custom_serialize_count["count"] += 1
175 return {"custom": True, "value": obj[7:]} # Remove "CUSTOM:" prefix
177 custom_handler = SerializerHandler(
178 check=custom_check,
179 serialize_func=custom_serialize,
180 uid="custom_string_handler",
181 desc="Custom handler for strings starting with CUSTOM:",
182 )
184 # Create serializer with custom handler in handlers_pre (before defaults)
185 serializer = JsonSerializer(handlers_pre=(custom_handler,))
187 # Test that custom handler is used
188 result = serializer.json_serialize("CUSTOM:test")
189 assert result == {"custom": True, "value": "test"}
190 assert custom_serialize_count["count"] == 1
192 # Test that normal strings still work (use default handler)
193 result = serializer.json_serialize("normal string")
194 assert result == "normal string"
197def test_custom_handler_execution_order():
198 """Test that handlers_pre are executed before default handlers."""
199 executed_handlers = []
201 def tracking_check(handler_name):
202 def check(self, obj, path):
203 executed_handlers.append(handler_name)
204 return isinstance(obj, dict) and "_test_marker" in obj
206 return check
208 def tracking_serialize(handler_name):
209 def serialize(self, obj, path):
210 return {"handled_by": handler_name}
212 return serialize
214 handler1 = SerializerHandler(
215 check=tracking_check("handler1"),
216 serialize_func=tracking_serialize("handler1"),
217 uid="handler1",
218 desc="First custom handler",
219 )
221 handler2 = SerializerHandler(
222 check=tracking_check("handler2"),
223 serialize_func=tracking_serialize("handler2"),
224 uid="handler2",
225 desc="Second custom handler",
226 )
228 serializer = JsonSerializer(handlers_pre=(handler1, handler2))
230 test_obj = {"_test_marker": True}
231 result = serializer.json_serialize(test_obj)
233 # First handler that matches should be used (handler1)
234 assert result == {"handled_by": "handler1"}
235 assert executed_handlers[0] == "handler1"
238# ============================================================================
239# Tests for DEFAULT_HANDLERS
240# ============================================================================
243def test_DEFAULT_HANDLERS():
244 """Test that all default type handlers serialize correctly."""
245 serializer = JsonSerializer()
247 # Test dataclass
248 dc = SimpleDataclass(x=1, y="test", z=False)
249 result = serializer.json_serialize(dc)
250 assert result == {"x": 1, "y": "test", "z": False}
252 # Test namedtuple - should serialize as dict
253 Point = namedtuple("Point", ["x", "y"])
254 point = Point(10, 20)
255 result = serializer.json_serialize(point)
256 assert result == {"x": 10, "y": 20}
257 assert isinstance(result, dict)
259 # Test Path
260 path = Path("/home/user/test.txt")
261 result = serializer.json_serialize(path)
262 assert result == "/home/user/test.txt"
263 assert isinstance(result, str)
265 # Test set (should become dict with _FORMAT_KEY)
266 result = serializer.json_serialize({1, 2, 3})
267 assert isinstance(result, dict)
268 assert result[_FORMAT_KEY] == "set" # ty: ignore[invalid-argument-type]
269 assert isinstance(result["data"], list) # ty: ignore[invalid-argument-type]
270 assert set(result["data"]) == {1, 2, 3} # ty: ignore[invalid-argument-type]
272 # Test tuple (should become list)
273 result = serializer.json_serialize((1, 2, 3))
274 assert result == [1, 2, 3]
275 assert isinstance(result, list)
278def test_BASE_HANDLERS():
279 """Test that BASE_HANDLERS work correctly (primitives, dicts, lists, tuples)."""
280 serializer = JsonSerializer(handlers_default=BASE_HANDLERS)
282 # Base handlers should handle primitives
283 assert serializer.json_serialize(42) == 42
284 assert serializer.json_serialize("test") == "test"
285 assert serializer.json_serialize(True) is True
286 assert serializer.json_serialize(None) is None
288 # Base handlers should handle dicts and lists
289 assert serializer.json_serialize([1, 2, 3]) == [1, 2, 3]
290 assert serializer.json_serialize({"a": 1}) == {"a": 1}
291 assert serializer.json_serialize((1, 2)) == [1, 2]
294def test_fallback_handler():
295 """Test that the fallback handler works for unserializable objects."""
296 serializer = JsonSerializer()
298 obj = UnserializableClass()
299 result = serializer.json_serialize(obj)
301 # Fallback handler should return dict with special keys
302 assert isinstance(result, dict)
303 assert "__name__" in result
304 assert "__module__" in result
305 assert "type" in result
306 assert "repr" in result
309# ============================================================================
310# Tests for nested structures
311# ============================================================================
314def test_nested_structures():
315 """Test serialization of mixed types and nested dicts/lists."""
316 serializer = JsonSerializer()
318 # Nested dicts and lists
319 nested = {"outer": {"inner": [1, 2, {"deep": "value"}]}}
320 nested_result = serializer.json_serialize(nested)
321 assert nested_result == {"outer": {"inner": [1, 2, {"deep": "value"}]}}
323 # List of dicts
324 list_of_dicts = [{"a": 1}, {"b": 2}, {"c": 3}]
325 list_result = serializer.json_serialize(list_of_dicts)
326 assert list_result == [{"a": 1}, {"b": 2}, {"c": 3}]
328 # Dict of lists
329 dict_of_lists = {"nums": [1, 2, 3], "strs": ["a", "b", "c"]}
330 result = serializer.json_serialize(dict_of_lists)
331 assert result == {"nums": [1, 2, 3], "strs": ["a", "b", "c"]}
334def test_nested_dataclasses():
335 """Test serialization of nested dataclasses."""
336 serializer = JsonSerializer()
338 simple = SimpleDataclass(x=5, y="inner", z=True)
339 nested = NestedDataclass(simple=simple, data={"key": "value"})
341 result = serializer.json_serialize(nested)
342 assert result == {
343 "simple": {"x": 5, "y": "inner", "z": True},
344 "data": {"key": "value"},
345 }
348def test_deeply_nested_structure():
349 """Test very deeply nested structures."""
350 serializer = JsonSerializer()
352 deep = {"l1": {"l2": {"l3": {"l4": {"l5": [1, 2, 3]}}}}}
353 result = serializer.json_serialize(deep)
354 assert result == {"l1": {"l2": {"l3": {"l4": {"l5": [1, 2, 3]}}}}}
357def test_mixed_types_nested():
358 """Test nested structures with mixed types (dataclass, dict, list, primitives)."""
359 serializer = JsonSerializer()
361 dc = SimpleDataclass(x=100, y="test", z=False)
362 mixed = {
363 "dataclass": dc,
364 "list": [1, 2, dc],
365 "nested": {"inner_dc": dc, "values": [10, 20]},
366 "primitive": 42,
367 }
369 result = serializer.json_serialize(mixed)
370 expected_dc = {"x": 100, "y": "test", "z": False}
371 assert result == {
372 "dataclass": expected_dc,
373 "list": [1, 2, expected_dc],
374 "nested": {"inner_dc": expected_dc, "values": [10, 20]},
375 "primitive": 42,
376 }
379# ============================================================================
380# Tests for ErrorMode handling
381# ============================================================================
384def test_error_mode_except():
385 """Test that ErrorMode.EXCEPT raises SerializationException on errors."""
387 # Create a handler that always raises an error
388 def error_check(self, obj, path):
389 return isinstance(obj, str) and obj == "ERROR"
391 def error_serialize(self, obj, path):
392 raise ValueError("Intentional error")
394 error_handler = SerializerHandler(
395 check=error_check,
396 serialize_func=error_serialize,
397 uid="error_handler",
398 desc="Handler that raises errors",
399 )
401 serializer = JsonSerializer(
402 error_mode=ErrorMode.EXCEPT, handlers_pre=(error_handler,)
403 )
405 with pytest.raises(SerializationException) as exc_info:
406 serializer.json_serialize("ERROR")
408 assert "error serializing" in str(exc_info.value)
409 assert "error_handler" in str(exc_info.value)
412def test_error_mode_warn():
413 """Test that ErrorMode.WARN returns repr on errors and emits warnings."""
415 # Create a handler that always raises an error
416 def error_check(self, obj, path):
417 return isinstance(obj, str) and obj == "ERROR"
419 def error_serialize(self, obj, path):
420 raise ValueError("Intentional error")
422 error_handler = SerializerHandler(
423 check=error_check,
424 serialize_func=error_serialize,
425 uid="error_handler",
426 desc="Handler that raises errors",
427 )
429 serializer = JsonSerializer(
430 error_mode=ErrorMode.WARN, handlers_pre=(error_handler,)
431 )
433 with warnings.catch_warnings(record=True) as w:
434 warnings.simplefilter("always")
435 result = serializer.json_serialize("ERROR")
437 # Should return repr instead of raising
438 assert result == "'ERROR'"
439 # Should have emitted a warning
440 assert len(w) > 0
441 assert "error serializing" in str(w[0].message)
444def test_error_mode_ignore():
445 """Test that ErrorMode.IGNORE returns repr on errors without warnings."""
447 # Create a handler that always raises an error
448 def error_check(self, obj, path):
449 return isinstance(obj, str) and obj == "ERROR"
451 def error_serialize(self, obj, path):
452 raise ValueError("Intentional error")
454 error_handler = SerializerHandler(
455 check=error_check,
456 serialize_func=error_serialize,
457 uid="error_handler",
458 desc="Handler that raises errors",
459 )
461 serializer = JsonSerializer(
462 error_mode=ErrorMode.IGNORE, handlers_pre=(error_handler,)
463 )
465 with warnings.catch_warnings(record=True) as w:
466 warnings.simplefilter("always")
467 result = serializer.json_serialize("ERROR")
469 # Should return repr
470 assert result == "'ERROR'"
471 # Should not have emitted warnings
472 assert len(w) == 0
475# ============================================================================
476# Tests for write_only_format
477# ============================================================================
480def test_write_only_format():
481 """Test that write_only_format changes _FORMAT_KEY to __write_format__."""
483 # Create a handler that outputs _FORMAT_KEY
484 def format_check(self, obj, path):
485 return isinstance(obj, str) and obj.startswith("FORMAT:")
487 def format_serialize(self, obj, path):
488 return {_FORMAT_KEY: "custom_format", "data": obj[7:]}
490 format_handler = SerializerHandler(
491 check=format_check,
492 serialize_func=format_serialize,
493 uid="format_handler",
494 desc="Handler that uses _FORMAT_KEY",
495 )
497 # Without write_only_format
498 serializer1 = JsonSerializer(handlers_pre=(format_handler,))
499 result1 = serializer1.json_serialize("FORMAT:test")
500 assert isinstance(result1, dict)
501 assert _FORMAT_KEY in result1
502 assert result1[_FORMAT_KEY] == "custom_format" # ty: ignore[invalid-argument-type]
504 # With write_only_format
505 serializer2 = JsonSerializer(handlers_pre=(format_handler,), write_only_format=True)
506 result2 = serializer2.json_serialize("FORMAT:test")
507 assert isinstance(result2, dict)
508 assert _FORMAT_KEY not in result2
509 assert "__write_format__" in result2
510 assert result2["__write_format__"] == "custom_format" # ty: ignore[invalid-argument-type]
511 assert result2["data"] == "test" # ty: ignore[invalid-argument-type]
514# ============================================================================
515# Tests for SerializerHandler.serialize()
516# ============================================================================
519def test_SerializerHandler_serialize():
520 """Test that SerializerHandler can serialize its own metadata."""
522 def simple_check(self, obj, path):
523 """Check if object is an integer."""
524 return isinstance(obj, int)
526 def simple_serialize(self, obj, path):
527 """Serialize integer."""
528 return obj * 2
530 handler = SerializerHandler(
531 check=simple_check,
532 serialize_func=simple_serialize,
533 uid="test_handler",
534 desc="Test handler description",
535 )
537 metadata = handler.serialize()
539 assert isinstance(metadata, dict)
540 assert "check" in metadata
541 assert "serialize_func" in metadata
542 assert "uid" in metadata
543 assert "desc" in metadata
545 assert metadata["uid"] == "test_handler"
546 assert metadata["desc"] == "Test handler description"
548 # Check that code and doc are included
549 check_data = metadata["check"]
550 assert isinstance(check_data, dict)
551 assert "code" in check_data
552 assert "doc" in check_data
554 serialize_func_data = metadata["serialize_func"]
555 assert isinstance(serialize_func_data, dict)
556 assert "code" in serialize_func_data
557 assert "doc" in serialize_func_data
560# ============================================================================
561# Tests for hashify
562# ============================================================================
565def test_hashify():
566 """Test JsonSerializer.hashify() method."""
567 serializer = JsonSerializer()
569 # Test that it converts to hashable types
570 result = serializer.hashify({"a": [1, 2, 3]})
571 assert isinstance(result, tuple)
572 assert result == (("a", (1, 2, 3)),)
573 # Should be hashable
574 hash(result)
576 # Test with list
577 result = serializer.hashify([1, 2, 3])
578 assert result == (1, 2, 3)
579 hash(result)
581 # Test with primitive (already hashable)
582 result = serializer.hashify(42)
583 assert result == 42
584 hash(result)
587def test_hashify_force():
588 """Test hashify with force parameter."""
589 serializer = JsonSerializer()
591 # With force=True (default), should handle unhashable objects
592 obj = UnserializableClass()
593 result = serializer.hashify(obj, force=True)
594 assert isinstance(result, tuple) # Converted to hashable form
597# ============================================================================
598# Tests for path tracking
599# ============================================================================
602def test_path_tracking():
603 """Test that paths are correctly tracked through nested serialization."""
604 paths_seen = []
606 def tracking_check(self, obj, path):
607 paths_seen.append(path)
608 return False # Never actually handle, just track
610 tracking_handler = SerializerHandler(
611 check=tracking_check,
612 serialize_func=lambda self, obj, path: obj,
613 uid="tracking",
614 desc="Path tracking handler",
615 )
617 serializer = JsonSerializer(handlers_pre=(tracking_handler,))
619 # Serialize nested structure
620 nested = {"a": {"b": [1, 2]}}
621 serializer.json_serialize(nested)
623 # Check that we saw paths for nested elements
624 assert tuple() in paths_seen # Root
625 assert ("a",) in paths_seen # nested dict
626 assert ("a", "b") in paths_seen # nested list
627 assert ("a", "b", 0) in paths_seen # first element
628 assert ("a", "b", 1) in paths_seen # second element
631# ============================================================================
632# Tests for initialization
633# ============================================================================
636def test_JsonSerializer_init_no_positional_args():
637 """Test that JsonSerializer raises ValueError on positional arguments."""
638 with pytest.raises(ValueError, match="no positional arguments"):
639 JsonSerializer("invalid", "args") # type: ignore[arg-type]
641 # Should work with keyword args
642 serializer = JsonSerializer(error_mode=ErrorMode.WARN)
643 assert serializer.error_mode == ErrorMode.WARN
646def test_JsonSerializer_init_defaults():
647 """Test JsonSerializer default initialization values."""
648 serializer = JsonSerializer()
650 assert serializer.array_mode == "array_list_meta"
651 assert serializer.error_mode == ErrorMode.EXCEPT
652 assert serializer.write_only_format is False
653 assert serializer.handlers == DEFAULT_HANDLERS
656def test_JsonSerializer_init_custom_values():
657 """Test JsonSerializer with custom initialization values."""
658 custom_handler = SerializerHandler(
659 check=lambda self, obj, path: False,
660 serialize_func=lambda self, obj, path: obj,
661 uid="custom",
662 desc="Custom handler",
663 )
665 serializer = JsonSerializer(
666 array_mode="list",
667 error_mode=ErrorMode.WARN,
668 handlers_pre=(custom_handler,),
669 handlers_default=BASE_HANDLERS,
670 write_only_format=True,
671 )
673 assert serializer.array_mode == "list"
674 assert serializer.error_mode == ErrorMode.WARN
675 assert serializer.write_only_format is True
676 assert serializer.handlers[0] == custom_handler
677 assert len(serializer.handlers) == len(BASE_HANDLERS) + 1
680# ============================================================================
681# Edge cases and integration tests
682# ============================================================================
685def test_empty_handlers():
686 """Test serializer with no handlers."""
687 serializer = JsonSerializer(handlers_default=tuple())
689 # Should fail to serialize anything
690 with pytest.raises(SerializationException):
691 serializer.json_serialize(42)
694# TODO: Implement circular reference protection in the future. see https://github.com/mivanit/muutils/issues/62
695@pytest.mark.skip(
696 reason="we don't currently have circular reference protection, see https://github.com/mivanit/muutils/issues/62"
697)
698def test_circular_reference_protection():
699 """Test that circular references don't cause infinite loops (will hit recursion limit)."""
700 # Note: This test verifies the expected behavior (recursion error) rather than
701 # infinite loop, as the module doesn't explicitly handle circular references
702 serializer = JsonSerializer()
704 # Create circular reference
705 circular = {"a": None}
706 circular["a"] = circular # type: ignore
708 # Should eventually raise RecursionError
709 with pytest.raises(RecursionError):
710 serializer.json_serialize(circular)
713def test_large_nested_structure():
714 """Test serialization of large nested structure."""
715 serializer = JsonSerializer()
717 # Create large nested list
718 large = [[i, i * 2, i * 3] for i in range(100)]
719 result = serializer.json_serialize(large)
720 assert isinstance(result, list)
721 assert len(result) == 100
722 assert result[0] == [0, 0, 0]
723 assert result[99] == [99, 198, 297]
726def test_mixed_container_types():
727 """Test serialization of sets, frozensets, and other iterables."""
728 serializer = JsonSerializer()
730 # Set - serialized with format key
731 result = serializer.json_serialize({3, 1, 2})
732 assert isinstance(result, dict)
733 assert _FORMAT_KEY in result
734 assert result[_FORMAT_KEY] == "set" # ty: ignore[invalid-argument-type]
735 assert isinstance(result["data"], list) # ty: ignore[invalid-argument-type]
736 assert set(result["data"]) == {1, 2, 3} # ty: ignore[invalid-argument-type]
738 # Frozenset - serialized with format key
739 result = serializer.json_serialize(frozenset([4, 5, 6]))
740 assert isinstance(result, dict)
741 assert _FORMAT_KEY in result
742 assert result[_FORMAT_KEY] == "frozenset" # ty: ignore[invalid-argument-type]
743 assert isinstance(result["data"], list) # ty: ignore[invalid-argument-type]
744 assert set(result["data"]) == {4, 5, 6} # ty: ignore[invalid-argument-type]
746 # Generator (Iterable) - serialized as list
747 gen = (x * 2 for x in range(3))
748 result = serializer.json_serialize(gen)
749 assert result == [0, 2, 4]
752def test_string_keys_in_dict():
753 """Test that dict keys are converted to strings."""
754 serializer = JsonSerializer()
756 # Integer keys should be converted to strings
757 result = serializer.json_serialize({1: "a", 2: "b", 3: "c"})
758 assert isinstance(result, dict)
759 assert result == {"1": "a", "2": "b", "3": "c"}
760 assert all(isinstance(k, str) for k in result.keys())