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.
- fixturify/__init__.py +21 -0
- fixturify/_utils/__init__.py +7 -0
- fixturify/_utils/_constants.py +10 -0
- fixturify/_utils/_fixture_discovery.py +165 -0
- fixturify/_utils/_path_resolver.py +135 -0
- fixturify/http_d/__init__.py +80 -0
- fixturify/http_d/_config.py +214 -0
- fixturify/http_d/_decorator.py +267 -0
- fixturify/http_d/_exceptions.py +153 -0
- fixturify/http_d/_fixture_discovery.py +33 -0
- fixturify/http_d/_matcher.py +372 -0
- fixturify/http_d/_mock_context.py +154 -0
- fixturify/http_d/_models.py +205 -0
- fixturify/http_d/_patcher.py +524 -0
- fixturify/http_d/_player.py +222 -0
- fixturify/http_d/_recorder.py +1350 -0
- fixturify/http_d/_stubs/__init__.py +8 -0
- fixturify/http_d/_stubs/_aiohttp.py +220 -0
- fixturify/http_d/_stubs/_connection.py +478 -0
- fixturify/http_d/_stubs/_httpcore.py +269 -0
- fixturify/http_d/_stubs/_tornado.py +95 -0
- fixturify/http_d/_utils.py +194 -0
- fixturify/json_assert/__init__.py +13 -0
- fixturify/json_assert/_actual_saver.py +67 -0
- fixturify/json_assert/_assert.py +173 -0
- fixturify/json_assert/_comparator.py +183 -0
- fixturify/json_assert/_diff_formatter.py +265 -0
- fixturify/json_assert/_normalizer.py +83 -0
- fixturify/object_mapper/__init__.py +5 -0
- fixturify/object_mapper/_deserializers/__init__.py +19 -0
- fixturify/object_mapper/_deserializers/_base.py +186 -0
- fixturify/object_mapper/_deserializers/_dataclass.py +52 -0
- fixturify/object_mapper/_deserializers/_plain.py +55 -0
- fixturify/object_mapper/_deserializers/_pydantic_v1.py +38 -0
- fixturify/object_mapper/_deserializers/_pydantic_v2.py +41 -0
- fixturify/object_mapper/_deserializers/_sqlalchemy.py +72 -0
- fixturify/object_mapper/_deserializers/_sqlmodel.py +43 -0
- fixturify/object_mapper/_detectors/__init__.py +5 -0
- fixturify/object_mapper/_detectors/_type_detector.py +186 -0
- fixturify/object_mapper/_serializers/__init__.py +19 -0
- fixturify/object_mapper/_serializers/_base.py +260 -0
- fixturify/object_mapper/_serializers/_dataclass.py +55 -0
- fixturify/object_mapper/_serializers/_plain.py +49 -0
- fixturify/object_mapper/_serializers/_pydantic_v1.py +49 -0
- fixturify/object_mapper/_serializers/_pydantic_v2.py +49 -0
- fixturify/object_mapper/_serializers/_sqlalchemy.py +70 -0
- fixturify/object_mapper/_serializers/_sqlmodel.py +54 -0
- fixturify/object_mapper/mapper.py +256 -0
- fixturify/read_d/__init__.py +5 -0
- fixturify/read_d/_decorator.py +193 -0
- fixturify/read_d/_fixture_loader.py +88 -0
- fixturify/sql_d/__init__.py +7 -0
- fixturify/sql_d/_config.py +30 -0
- fixturify/sql_d/_decorator.py +373 -0
- fixturify/sql_d/_driver_registry.py +133 -0
- fixturify/sql_d/_executor.py +82 -0
- fixturify/sql_d/_fixture_discovery.py +55 -0
- fixturify/sql_d/_phase.py +10 -0
- fixturify/sql_d/_strategies/__init__.py +11 -0
- fixturify/sql_d/_strategies/_aiomysql.py +63 -0
- fixturify/sql_d/_strategies/_aiosqlite.py +29 -0
- fixturify/sql_d/_strategies/_asyncpg.py +34 -0
- fixturify/sql_d/_strategies/_base.py +118 -0
- fixturify/sql_d/_strategies/_mysql.py +70 -0
- fixturify/sql_d/_strategies/_psycopg.py +35 -0
- fixturify/sql_d/_strategies/_psycopg2.py +40 -0
- fixturify/sql_d/_strategies/_registry.py +109 -0
- fixturify/sql_d/_strategies/_sqlite.py +33 -0
- fixturify-0.1.9.dist-info/METADATA +122 -0
- fixturify-0.1.9.dist-info/RECORD +71 -0
- 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
|