fixturify 0.1.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. fixturify/__init__.py +21 -0
  2. fixturify/_utils/__init__.py +7 -0
  3. fixturify/_utils/_constants.py +10 -0
  4. fixturify/_utils/_fixture_discovery.py +165 -0
  5. fixturify/_utils/_path_resolver.py +135 -0
  6. fixturify/http_d/__init__.py +80 -0
  7. fixturify/http_d/_config.py +214 -0
  8. fixturify/http_d/_decorator.py +267 -0
  9. fixturify/http_d/_exceptions.py +153 -0
  10. fixturify/http_d/_fixture_discovery.py +33 -0
  11. fixturify/http_d/_matcher.py +372 -0
  12. fixturify/http_d/_mock_context.py +154 -0
  13. fixturify/http_d/_models.py +205 -0
  14. fixturify/http_d/_patcher.py +524 -0
  15. fixturify/http_d/_player.py +222 -0
  16. fixturify/http_d/_recorder.py +1350 -0
  17. fixturify/http_d/_stubs/__init__.py +8 -0
  18. fixturify/http_d/_stubs/_aiohttp.py +220 -0
  19. fixturify/http_d/_stubs/_connection.py +478 -0
  20. fixturify/http_d/_stubs/_httpcore.py +269 -0
  21. fixturify/http_d/_stubs/_tornado.py +95 -0
  22. fixturify/http_d/_utils.py +194 -0
  23. fixturify/json_assert/__init__.py +13 -0
  24. fixturify/json_assert/_actual_saver.py +67 -0
  25. fixturify/json_assert/_assert.py +173 -0
  26. fixturify/json_assert/_comparator.py +183 -0
  27. fixturify/json_assert/_diff_formatter.py +265 -0
  28. fixturify/json_assert/_normalizer.py +83 -0
  29. fixturify/object_mapper/__init__.py +5 -0
  30. fixturify/object_mapper/_deserializers/__init__.py +19 -0
  31. fixturify/object_mapper/_deserializers/_base.py +186 -0
  32. fixturify/object_mapper/_deserializers/_dataclass.py +52 -0
  33. fixturify/object_mapper/_deserializers/_plain.py +55 -0
  34. fixturify/object_mapper/_deserializers/_pydantic_v1.py +38 -0
  35. fixturify/object_mapper/_deserializers/_pydantic_v2.py +41 -0
  36. fixturify/object_mapper/_deserializers/_sqlalchemy.py +72 -0
  37. fixturify/object_mapper/_deserializers/_sqlmodel.py +43 -0
  38. fixturify/object_mapper/_detectors/__init__.py +5 -0
  39. fixturify/object_mapper/_detectors/_type_detector.py +186 -0
  40. fixturify/object_mapper/_serializers/__init__.py +19 -0
  41. fixturify/object_mapper/_serializers/_base.py +260 -0
  42. fixturify/object_mapper/_serializers/_dataclass.py +55 -0
  43. fixturify/object_mapper/_serializers/_plain.py +49 -0
  44. fixturify/object_mapper/_serializers/_pydantic_v1.py +49 -0
  45. fixturify/object_mapper/_serializers/_pydantic_v2.py +49 -0
  46. fixturify/object_mapper/_serializers/_sqlalchemy.py +70 -0
  47. fixturify/object_mapper/_serializers/_sqlmodel.py +54 -0
  48. fixturify/object_mapper/mapper.py +256 -0
  49. fixturify/read_d/__init__.py +5 -0
  50. fixturify/read_d/_decorator.py +193 -0
  51. fixturify/read_d/_fixture_loader.py +88 -0
  52. fixturify/sql_d/__init__.py +7 -0
  53. fixturify/sql_d/_config.py +30 -0
  54. fixturify/sql_d/_decorator.py +373 -0
  55. fixturify/sql_d/_driver_registry.py +133 -0
  56. fixturify/sql_d/_executor.py +82 -0
  57. fixturify/sql_d/_fixture_discovery.py +55 -0
  58. fixturify/sql_d/_phase.py +10 -0
  59. fixturify/sql_d/_strategies/__init__.py +11 -0
  60. fixturify/sql_d/_strategies/_aiomysql.py +63 -0
  61. fixturify/sql_d/_strategies/_aiosqlite.py +29 -0
  62. fixturify/sql_d/_strategies/_asyncpg.py +34 -0
  63. fixturify/sql_d/_strategies/_base.py +118 -0
  64. fixturify/sql_d/_strategies/_mysql.py +70 -0
  65. fixturify/sql_d/_strategies/_psycopg.py +35 -0
  66. fixturify/sql_d/_strategies/_psycopg2.py +40 -0
  67. fixturify/sql_d/_strategies/_registry.py +109 -0
  68. fixturify/sql_d/_strategies/_sqlite.py +33 -0
  69. fixturify-0.1.9.dist-info/METADATA +122 -0
  70. fixturify-0.1.9.dist-info/RECORD +71 -0
  71. fixturify-0.1.9.dist-info/WHEEL +4 -0
@@ -0,0 +1,173 @@
1
+ """Main JsonAssert class with fluent API."""
2
+
3
+ import json
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ from fixturify._utils._constants import ENCODING
7
+ from fixturify._utils._path_resolver import _PathResolver
8
+ from fixturify.json_assert._normalizer import _Normalizer
9
+ from fixturify.json_assert._comparator import _Comparator
10
+ from fixturify.json_assert._diff_formatter import _DiffFormatter
11
+ from fixturify.json_assert._actual_saver import _ActualSaver
12
+
13
+
14
+ class JsonAssert:
15
+ """
16
+ JSON comparison assertions for testing.
17
+
18
+ Provides a fluent API for comparing Python objects/data to JSON files.
19
+
20
+ Example:
21
+ JsonAssert(user).compare_to_file("./expected.json")
22
+
23
+ JsonAssert(response)
24
+ .ignore("root['id']", "root['timestamp']")
25
+ .options(ignore_order=True)
26
+ .compare_to_file("./expected.json")
27
+ """
28
+
29
+ def __init__(self, data: Any) -> None:
30
+ """
31
+ Initialize JsonAssert with data to compare.
32
+
33
+ Args:
34
+ data: The actual data to compare against expected JSON file.
35
+ Can be: object, list of objects, dict, or JSON string.
36
+ """
37
+ self._data = data
38
+ self._normalizer = _Normalizer()
39
+ self._exclude_paths: List[str] = []
40
+ self._exclude_regex_paths: List[str] = []
41
+ self._ignore_order: bool = False
42
+ self._numeric_tolerance: Optional[float] = None
43
+ self._ignore_type_in_groups: Optional[List[tuple]] = None
44
+ self._extra_options: Dict[str, Any] = {}
45
+
46
+ def ignore(self, *paths: str) -> "JsonAssert":
47
+ """
48
+ Specify paths to ignore during comparison.
49
+
50
+ Supports both bracket notation and dot notation:
51
+ - "id" or "root['id']" - top-level field
52
+ - "user.name" or "root['user']['name']" - nested field
53
+ - "users[*].id" - field in all list items (wildcard)
54
+
55
+ Args:
56
+ *paths: Paths to ignore
57
+
58
+ Returns:
59
+ Self for method chaining (accumulates with previous calls)
60
+ """
61
+ self._exclude_paths.extend(paths)
62
+ return self
63
+
64
+ def ignore_regex(self, *patterns: str) -> "JsonAssert":
65
+ """
66
+ Specify regex patterns to ignore during comparison.
67
+
68
+ Use this for ignoring fields across all list indices with complex patterns.
69
+
70
+ Pattern syntax (regex):
71
+ - r"root\\[\\d+\\]\\['id'\\]" - 'id' in any list item
72
+ - r"root\\['users'\\]\\[\\d+\\]\\['email'\\]" - 'email' in any user
73
+
74
+ Args:
75
+ *patterns: Regex patterns to ignore
76
+
77
+ Returns:
78
+ Self for method chaining (accumulates with previous calls)
79
+ """
80
+ self._exclude_regex_paths.extend(patterns)
81
+ return self
82
+
83
+ def options(
84
+ self,
85
+ ignore_order: bool = False,
86
+ numeric_tolerance: Optional[float] = None,
87
+ ignore_type_in_groups: Optional[List[tuple]] = None,
88
+ **kwargs,
89
+ ) -> "JsonAssert":
90
+ """
91
+ Set comparison options.
92
+
93
+ Args:
94
+ ignore_order: If True, ignore order in lists/arrays
95
+ numeric_tolerance: Tolerance for float comparisons (e.g., 0.001)
96
+ ignore_type_in_groups: Type groups to treat as equal (e.g., [(int, float)])
97
+ **kwargs: Additional deepdiff options
98
+
99
+ Returns:
100
+ Self for method chaining
101
+ """
102
+ if ignore_order:
103
+ self._ignore_order = True
104
+
105
+ if numeric_tolerance is not None:
106
+ self._numeric_tolerance = numeric_tolerance
107
+
108
+ if ignore_type_in_groups is not None:
109
+ self._ignore_type_in_groups = ignore_type_in_groups
110
+
111
+ self._extra_options.update(kwargs)
112
+ return self
113
+
114
+ def compare_to_file(self, relative_path: str) -> None:
115
+ """
116
+ Compare data to JSON file. Raises AssertionError if different.
117
+
118
+ Args:
119
+ relative_path: Path to JSON file, relative to caller's location
120
+
121
+ Raises:
122
+ AssertionError: If data doesn't match file content (with diff details)
123
+ FileNotFoundError: If JSON file doesn't exist
124
+ ValueError: If data can't be converted or file can't be parsed
125
+ """
126
+ # Resolve the path relative to the caller's file
127
+ # Use centralized caller detection from _PathResolver
128
+ caller_file = _PathResolver._get_caller_file(stack_level=1)
129
+ expected_path = _PathResolver.resolve(relative_path, caller_file)
130
+
131
+ # Read and parse the expected JSON file
132
+ try:
133
+ content = expected_path.read_text(encoding=ENCODING)
134
+ expected_data = json.loads(content)
135
+ except json.JSONDecodeError as e:
136
+ raise ValueError(f"Invalid JSON in expected file {expected_path}: {e}")
137
+
138
+ # Normalize the actual data
139
+ try:
140
+ actual_data = self._normalizer.normalize(self._data)
141
+ except ValueError as e:
142
+ raise ValueError(f"Cannot normalize actual data: {e}")
143
+
144
+ # Create comparator with accumulated options
145
+ comparator = _Comparator(
146
+ exclude_paths=self._exclude_paths,
147
+ exclude_regex_paths=self._exclude_regex_paths,
148
+ ignore_order=self._ignore_order,
149
+ numeric_tolerance=self._numeric_tolerance,
150
+ ignore_type_in_groups=self._ignore_type_in_groups,
151
+ extra_options=self._extra_options,
152
+ )
153
+
154
+ # Perform comparison
155
+ result = comparator.compare(actual_data, expected_data)
156
+
157
+ if not result.is_equal:
158
+ # Save actual data to ACTUAL folder
159
+ actual_saved_path, save_ok = _ActualSaver.save(actual_data, expected_path)
160
+
161
+ # Format the diff
162
+ formatted = _DiffFormatter.format(
163
+ result.diff,
164
+ expected_path=str(relative_path),
165
+ actual_saved_path=str(actual_saved_path),
166
+ save_failed=not save_ok,
167
+ )
168
+
169
+ # Print to stderr for test visibility
170
+ _DiffFormatter.print_diff(formatted)
171
+
172
+ # Raise AssertionError with formatted message
173
+ raise AssertionError(f"\n{formatted}")
@@ -0,0 +1,183 @@
1
+ """Comparator using deepdiff for JSON comparison."""
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Any, Dict, List, Optional, Union
6
+
7
+ from deepdiff import DeepDiff
8
+
9
+
10
+ @dataclass
11
+ class CompareResult:
12
+ """Result of a comparison operation."""
13
+
14
+ is_equal: bool
15
+ diff: Optional[DeepDiff] = None
16
+
17
+
18
+ class _Comparator:
19
+ """Handles the actual comparison using deepdiff."""
20
+
21
+ def __init__(
22
+ self,
23
+ exclude_paths: List[str] = None,
24
+ exclude_regex_paths: List[str] = None,
25
+ ignore_order: bool = False,
26
+ numeric_tolerance: Optional[float] = None,
27
+ ignore_type_in_groups: Optional[List[tuple]] = None,
28
+ extra_options: Dict[str, Any] = None,
29
+ ):
30
+ """
31
+ Initialize the comparator with options.
32
+
33
+ Args:
34
+ exclude_paths: Exact paths to ignore (deepdiff exclude_paths)
35
+ exclude_regex_paths: Regex patterns to ignore (deepdiff exclude_regex_paths)
36
+ ignore_order: If True, ignore order in lists
37
+ numeric_tolerance: Tolerance for float comparisons
38
+ ignore_type_in_groups: Type groups to treat as equal
39
+ extra_options: Additional deepdiff options
40
+ """
41
+ self._exclude_paths = exclude_paths or []
42
+ self._exclude_regex_paths = exclude_regex_paths or []
43
+ self._ignore_order = ignore_order
44
+ self._numeric_tolerance = numeric_tolerance
45
+ self._ignore_type_in_groups = ignore_type_in_groups
46
+ self._extra_options = extra_options or {}
47
+
48
+ def compare(
49
+ self, actual: Union[dict, list], expected: Union[dict, list]
50
+ ) -> CompareResult:
51
+ """
52
+ Compare actual vs expected data using DeepDiff.
53
+
54
+ Args:
55
+ actual: The actual data
56
+ expected: The expected data
57
+
58
+ Returns:
59
+ CompareResult with is_equal bool and diff details
60
+ """
61
+ options = self._build_deepdiff_options()
62
+
63
+ # Use verbose_level=2 to preserve actual values for added/removed items
64
+ # This allows the diff formatter to show the values, not just the paths
65
+ diff = DeepDiff(expected, actual, verbose_level=2, **options)
66
+
67
+ return CompareResult(is_equal=not bool(diff), diff=diff if diff else None)
68
+
69
+ def _build_deepdiff_options(self) -> Dict[str, Any]:
70
+ """Build options dict for DeepDiff."""
71
+ options = {}
72
+
73
+ # Normalize and process exclude paths
74
+ normalized_paths = []
75
+ additional_regex_paths = list(self._exclude_regex_paths)
76
+
77
+ for path in self._exclude_paths:
78
+ normalized = self._normalize_path(path)
79
+ if self._has_wildcard(normalized):
80
+ # Convert wildcard to regex
81
+ regex_pattern = self._convert_wildcard_to_regex(normalized)
82
+ additional_regex_paths.append(regex_pattern)
83
+ else:
84
+ normalized_paths.append(normalized)
85
+
86
+ if normalized_paths:
87
+ options["exclude_paths"] = set(normalized_paths)
88
+
89
+ if additional_regex_paths:
90
+ options["exclude_regex_paths"] = [
91
+ re.compile(p) for p in additional_regex_paths
92
+ ]
93
+
94
+ if self._ignore_order:
95
+ options["ignore_order"] = True
96
+
97
+ if self._numeric_tolerance is not None:
98
+ options["math_epsilon"] = self._numeric_tolerance
99
+
100
+ if self._ignore_type_in_groups:
101
+ options["ignore_type_in_groups"] = self._ignore_type_in_groups
102
+
103
+ # Add any extra options
104
+ options.update(self._extra_options)
105
+
106
+ return options
107
+
108
+ def _normalize_path(self, path: str) -> str:
109
+ """
110
+ Normalize path to deepdiff format.
111
+
112
+ Converts:
113
+ - Simple field names to root['field']
114
+ - Dot notation to bracket notation
115
+ - Preserves [n] and [*] index notation
116
+
117
+ Examples:
118
+ "id" -> "root['id']"
119
+ "user.name" -> "root['user']['name']"
120
+ "users[0].name" -> "root['users'][0]['name']"
121
+ "users[*].id" -> "root['users'][*]['id']"
122
+ "root['users'][*].id" -> "root['users'][*]['id']"
123
+ """
124
+ # If already in pure bracket format (no dots after root), return as-is
125
+ if path.startswith("root[") and "." not in path:
126
+ return path
127
+
128
+ # If it doesn't start with root, add it
129
+ if not path.startswith("root"):
130
+ path = f"root.{path}"
131
+
132
+ # Convert dot notation to bracket notation
133
+ # But preserve [n] and [*] indices
134
+ result = ""
135
+ i = 0
136
+ while i < len(path):
137
+ char = path[i]
138
+
139
+ if char == ".":
140
+ # Start of a new segment
141
+ i += 1
142
+ # Find the end of this segment (next . or [)
143
+ segment_start = i
144
+ while i < len(path) and path[i] not in ".[]":
145
+ i += 1
146
+ segment = path[segment_start:i]
147
+ if segment:
148
+ result += f"['{segment}']"
149
+ elif char == "[":
150
+ # Copy the bracket notation as-is
151
+ bracket_start = i
152
+ i += 1
153
+ while i < len(path) and path[i] != "]":
154
+ i += 1
155
+ i += 1 # Include the ]
156
+ result += path[bracket_start:i]
157
+ else:
158
+ # Part of 'root' or other text before first . or [
159
+ segment_start = i
160
+ while i < len(path) and path[i] not in ".[]":
161
+ i += 1
162
+ segment = path[segment_start:i]
163
+ result += segment
164
+
165
+ return result
166
+
167
+ def _has_wildcard(self, path: str) -> bool:
168
+ """Check if path contains [*] wildcard."""
169
+ return "[*]" in path
170
+
171
+ def _convert_wildcard_to_regex(self, path: str) -> str:
172
+ r"""
173
+ Convert wildcard [*] syntax to deepdiff regex pattern.
174
+
175
+ Example:
176
+ "root['users'][*]['id']" -> r"root\['users'\]\[\d+\]\['id'\]"
177
+ """
178
+ # Escape special regex characters
179
+ escaped = re.escape(path)
180
+ # Convert escaped [*] back to regex pattern for any index
181
+ # re.escape turns [*] into \[\*\]
182
+ regex = escaped.replace(r"\[\*\]", r"\[\d+\]")
183
+ return regex
@@ -0,0 +1,265 @@
1
+ """Diff formatter for human-readable comparison output."""
2
+
3
+ import sys
4
+ from typing import Any, Dict, List, Set, Union
5
+
6
+ from deepdiff import DeepDiff
7
+
8
+
9
+ class _DiffFormatter:
10
+ """Formats DeepDiff results into human-readable tables."""
11
+
12
+ MAX_VALUE_LENGTH = 50
13
+
14
+ @staticmethod
15
+ def format(
16
+ diff: DeepDiff,
17
+ expected_path: str,
18
+ actual_saved_path: str,
19
+ save_failed: bool = False,
20
+ ) -> str:
21
+ """
22
+ Format DeepDiff result into human-readable table format.
23
+
24
+ Args:
25
+ diff: DeepDiff result object
26
+ expected_path: Path to expected JSON file
27
+ actual_saved_path: Path where actual JSON was saved
28
+ save_failed: Whether saving the actual file failed
29
+
30
+ Returns:
31
+ Formatted string with tables showing all differences
32
+ """
33
+ lines = []
34
+
35
+ # Header
36
+ lines.append("=" * 80)
37
+ lines.append(" JSON COMPARISON FAILED")
38
+ lines.append("=" * 80)
39
+ lines.append("")
40
+ lines.append(f"Expected file: {expected_path}")
41
+ if save_failed:
42
+ lines.append(f"Actual saved: {actual_saved_path} (SAVE FAILED)")
43
+ else:
44
+ lines.append(f"Actual saved: {actual_saved_path}")
45
+ lines.append("")
46
+ lines.append("-" * 80)
47
+ lines.append(" DIFFERENCES SUMMARY")
48
+ lines.append("-" * 80)
49
+ lines.append("")
50
+
51
+ # Process different types of differences
52
+ has_content = False
53
+
54
+ # Changed values
55
+ if "values_changed" in diff:
56
+ has_content = True
57
+ lines.append(_DiffFormatter._format_changed_values(diff["values_changed"]))
58
+ lines.append("")
59
+
60
+ # Type changes
61
+ if "type_changes" in diff:
62
+ has_content = True
63
+ lines.append(_DiffFormatter._format_type_changes(diff["type_changes"]))
64
+ lines.append("")
65
+
66
+ # Items added (extra in actual)
67
+ if "dictionary_item_added" in diff or "iterable_item_added" in diff:
68
+ has_content = True
69
+ added_paths: List[str] = []
70
+ if "dictionary_item_added" in diff:
71
+ added_paths.extend(
72
+ _DiffFormatter._extract_paths(diff["dictionary_item_added"])
73
+ )
74
+ if "iterable_item_added" in diff:
75
+ added_paths.extend(
76
+ _DiffFormatter._extract_paths(diff["iterable_item_added"])
77
+ )
78
+ lines.append(_DiffFormatter._format_added_items(added_paths, diff))
79
+ lines.append("")
80
+
81
+ # Items removed (missing in actual)
82
+ if "dictionary_item_removed" in diff or "iterable_item_removed" in diff:
83
+ has_content = True
84
+ removed_paths: List[str] = []
85
+ if "dictionary_item_removed" in diff:
86
+ removed_paths.extend(
87
+ _DiffFormatter._extract_paths(diff["dictionary_item_removed"])
88
+ )
89
+ if "iterable_item_removed" in diff:
90
+ removed_paths.extend(
91
+ _DiffFormatter._extract_paths(diff["iterable_item_removed"])
92
+ )
93
+ lines.append(_DiffFormatter._format_removed_items(removed_paths, diff))
94
+ lines.append("")
95
+
96
+ if not has_content:
97
+ lines.append("No specific differences to display.")
98
+ lines.append("")
99
+
100
+ lines.append("=" * 80)
101
+
102
+ return "\n".join(lines)
103
+
104
+ @staticmethod
105
+ def _extract_paths(items: Union[Set, Dict, Any]) -> List[str]:
106
+ """
107
+ Extract paths from DeepDiff items.
108
+
109
+ DeepDiff returns different types depending on version and diff type:
110
+ - Sets of path strings (e.g., SetOrdered(["root['key']"]))
111
+ - Dicts of path -> value
112
+ """
113
+ if items is None:
114
+ return []
115
+
116
+ # If it's a dict-like with items(), use keys
117
+ if hasattr(items, "items"):
118
+ return list(items.keys())
119
+
120
+ # If it's iterable (set-like), convert to list
121
+ try:
122
+ return list(items)
123
+ except TypeError:
124
+ return []
125
+
126
+ @staticmethod
127
+ def print_diff(formatted_output: str) -> None:
128
+ """
129
+ Print the formatted diff to stderr for test visibility.
130
+
131
+ Args:
132
+ formatted_output: The formatted diff string
133
+ """
134
+ print(formatted_output, file=sys.stderr)
135
+
136
+ @staticmethod
137
+ def _format_changed_values(changed: Dict[str, Any]) -> str:
138
+ """Format changed values as a table."""
139
+ lines = ["CHANGED VALUES:"]
140
+ lines.append("-" * 70)
141
+ lines.append(f"{'Path':<30} | {'Actual':<18} | {'Expected':<18}")
142
+ lines.append("-" * 70)
143
+
144
+ for path, change in changed.items():
145
+ # old_value = expected (from file), new_value = actual (from data)
146
+ expected_val = _DiffFormatter._truncate_value(
147
+ change.get("old_value", change)
148
+ )
149
+ actual_val = _DiffFormatter._truncate_value(change.get("new_value", change))
150
+ path_display = _DiffFormatter._truncate_value(path, 28)
151
+ lines.append(f"{path_display:<30} | {actual_val:<18} | {expected_val:<18}")
152
+
153
+ lines.append("-" * 70)
154
+ return "\n".join(lines)
155
+
156
+ @staticmethod
157
+ def _format_type_changes(changes: Dict[str, Any]) -> str:
158
+ """Format type changes as a table."""
159
+ lines = ["TYPE CHANGES:"]
160
+ lines.append("-" * 70)
161
+ lines.append(f"{'Path':<30} | {'Actual Type':<18} | {'Expected Type':<18}")
162
+ lines.append("-" * 70)
163
+
164
+ for path, change in changes.items():
165
+ # old_value = expected (from file), new_value = actual (from data)
166
+ expected_type = type(change.get("old_value", "")).__name__
167
+ actual_type = type(change.get("new_value", "")).__name__
168
+ path_display = _DiffFormatter._truncate_value(path, 28)
169
+ lines.append(
170
+ f"{path_display:<30} | {actual_type:<18} | {expected_type:<18}"
171
+ )
172
+
173
+ lines.append("-" * 70)
174
+ return "\n".join(lines)
175
+
176
+ @staticmethod
177
+ def _format_added_items(paths: List[str], diff: DeepDiff) -> str:
178
+ """Format items that are extra in actual (added)."""
179
+ lines = ["EXTRA IN ACTUAL (found but not expected):"]
180
+ lines.append("-" * 70)
181
+ lines.append(f"{'Path':<30} | {'Actual Value':<38}")
182
+ lines.append("-" * 70)
183
+
184
+ for path in paths:
185
+ path_display = _DiffFormatter._truncate_value(path, 28)
186
+ # Try to get the value from DeepDiff if available
187
+ value = _DiffFormatter._get_value_for_path(diff, path, "added")
188
+ val_display = _DiffFormatter._truncate_value(value, 36)
189
+ lines.append(f"{path_display:<30} | {val_display:<38}")
190
+
191
+ lines.append("-" * 70)
192
+ return "\n".join(lines)
193
+
194
+ @staticmethod
195
+ def _format_removed_items(paths: List[str], diff: DeepDiff) -> str:
196
+ """Format items that are missing in actual (removed)."""
197
+ lines = ["MISSING IN ACTUAL (expected but not found):"]
198
+ lines.append("-" * 70)
199
+ lines.append(f"{'Path':<30} | {'Expected Value':<38}")
200
+ lines.append("-" * 70)
201
+
202
+ for path in paths:
203
+ path_display = _DiffFormatter._truncate_value(path, 28)
204
+ # Try to get the value from DeepDiff if available
205
+ value = _DiffFormatter._get_value_for_path(diff, path, "removed")
206
+ val_display = _DiffFormatter._truncate_value(value, 36)
207
+ lines.append(f"{path_display:<30} | {val_display:<38}")
208
+
209
+ lines.append("-" * 70)
210
+ return "\n".join(lines)
211
+
212
+ # Sentinel value to distinguish "key not found" from "key exists with None value"
213
+ _NOT_FOUND = object()
214
+
215
+ @staticmethod
216
+ def _get_value_for_path(diff: DeepDiff, path: str, change_type: str) -> Any:
217
+ """
218
+ Try to extract the actual value for a path from DeepDiff.
219
+
220
+ Args:
221
+ diff: The DeepDiff object
222
+ path: The path string (e.g., "root['key']")
223
+ change_type: Either "added" or "removed"
224
+
225
+ Returns:
226
+ The value if found (including None), or "<value not available>" placeholder
227
+ """
228
+ # DeepDiff stores values differently depending on version
229
+ # Try to access via the verbose output or tree view
230
+ try:
231
+ # Check if diff has the value stored (depends on DeepDiff version/config)
232
+ if change_type == "added":
233
+ for key in ["dictionary_item_added", "iterable_item_added"]:
234
+ if key in diff:
235
+ items = diff[key]
236
+ # If it's a dict with path->value mapping, check if path exists
237
+ if hasattr(items, "get") and hasattr(items, "__contains__"):
238
+ if path in items:
239
+ return items[
240
+ path
241
+ ] # Returns the value, even if it's None
242
+ elif change_type == "removed":
243
+ for key in ["dictionary_item_removed", "iterable_item_removed"]:
244
+ if key in diff:
245
+ items = diff[key]
246
+ if hasattr(items, "get") and hasattr(items, "__contains__"):
247
+ if path in items:
248
+ return items[
249
+ path
250
+ ] # Returns the value, even if it's None
251
+ except Exception:
252
+ pass
253
+
254
+ return "<value not available>"
255
+
256
+ @staticmethod
257
+ def _truncate_value(value: Any, max_length: int = None) -> str:
258
+ """Truncate long values for table display."""
259
+ if max_length is None:
260
+ max_length = _DiffFormatter.MAX_VALUE_LENGTH
261
+
262
+ str_val = repr(value)
263
+ if len(str_val) > max_length:
264
+ return str_val[: max_length - 3] + "..."
265
+ return str_val