snapline-engine 0.1.10__tar.gz
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.
- snapline_engine-0.1.10/.gitignore +5 -0
- snapline_engine-0.1.10/PKG-INFO +18 -0
- snapline_engine-0.1.10/pyproject.toml +30 -0
- snapline_engine-0.1.10/snapline/engine/__init__.py +26 -0
- snapline_engine-0.1.10/snapline/engine/apply_data_mapping.py +46 -0
- snapline_engine-0.1.10/snapline/engine/apply_transformations.py +34 -0
- snapline_engine-0.1.10/snapline/engine/assert_against_file.py +17 -0
- snapline_engine-0.1.10/snapline/engine/compare_objects.py +10 -0
- snapline_engine-0.1.10/snapline/engine/diff_values.py +77 -0
- snapline_engine-0.1.10/snapline/engine/io/load_json_file.py +10 -0
- snapline_engine-0.1.10/snapline/engine/reconcile.py +37 -0
- snapline_engine-0.1.10/snapline/engine/strip_fields.py +52 -0
- snapline_engine-0.1.10/snapline/engine/types.py +37 -0
- snapline_engine-0.1.10/snapline/engine/utils/deep_clone.py +8 -0
- snapline_engine-0.1.10/snapline/engine/utils/is_plain_object.py +7 -0
- snapline_engine-0.1.10/snapline/engine/utils/stable_stringify.py +8 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: snapline-engine
|
|
3
|
+
Version: 0.1.10
|
|
4
|
+
Summary: Deep JSON reconciliation engine for Snapline
|
|
5
|
+
Project-URL: Homepage, https://github.com/vaagatech/snapline-python
|
|
6
|
+
Project-URL: Repository, https://github.com/vaagatech/snapline-python
|
|
7
|
+
Author-email: VaagaTech <info@vaagatech.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: reconciliation,snapline,snapshot,testing
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Requires-Python: >=3.10
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "snapline-engine"
|
|
7
|
+
version = "0.1.10"
|
|
8
|
+
description = "Deep JSON reconciliation engine for Snapline"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [{ name = "VaagaTech", email = "info@vaagatech.com" }]
|
|
12
|
+
keywords = ["snapline", "testing", "snapshot", "reconciliation"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
]
|
|
23
|
+
dependencies = []
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/vaagatech/snapline-python"
|
|
27
|
+
Repository = "https://github.com/vaagatech/snapline-python"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["snapline"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from .apply_data_mapping import apply_data_mapping, map_field_value
|
|
2
|
+
from .apply_transformations import apply_transformations
|
|
3
|
+
from .assert_against_file import assert_against_file
|
|
4
|
+
from .compare_objects import compare_objects
|
|
5
|
+
from .diff_values import diff_values
|
|
6
|
+
from .io.load_json_file import load_json_file
|
|
7
|
+
from .reconcile import reconcile
|
|
8
|
+
from .strip_fields import strip_fields
|
|
9
|
+
from .utils.deep_clone import deep_clone
|
|
10
|
+
from .utils.is_plain_object import is_plain_object
|
|
11
|
+
from .utils.stable_stringify import stable_stringify
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"apply_data_mapping",
|
|
15
|
+
"apply_transformations",
|
|
16
|
+
"assert_against_file",
|
|
17
|
+
"compare_objects",
|
|
18
|
+
"deep_clone",
|
|
19
|
+
"diff_values",
|
|
20
|
+
"is_plain_object",
|
|
21
|
+
"load_json_file",
|
|
22
|
+
"map_field_value",
|
|
23
|
+
"reconcile",
|
|
24
|
+
"stable_stringify",
|
|
25
|
+
"strip_fields",
|
|
26
|
+
]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
|
|
5
|
+
from .types import DataMappingMap, FieldMapping
|
|
6
|
+
from .utils.deep_clone import deep_clone
|
|
7
|
+
from .utils.is_plain_object import is_plain_object
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def map_field_value(value: Any, mapping: FieldMapping) -> Any:
|
|
11
|
+
if callable(mapping):
|
|
12
|
+
return mapping(value)
|
|
13
|
+
|
|
14
|
+
if is_plain_object(mapping):
|
|
15
|
+
key = str(value)
|
|
16
|
+
if key in mapping:
|
|
17
|
+
return mapping[key]
|
|
18
|
+
|
|
19
|
+
return value
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def apply_data_mapping(
|
|
23
|
+
data: Any,
|
|
24
|
+
data_mapping: DataMappingMap | None = None,
|
|
25
|
+
) -> Any:
|
|
26
|
+
mappings = data_mapping or {}
|
|
27
|
+
if not mappings:
|
|
28
|
+
return data
|
|
29
|
+
|
|
30
|
+
def walk(value: Any) -> Any:
|
|
31
|
+
if isinstance(value, list):
|
|
32
|
+
return [walk(item) for item in value]
|
|
33
|
+
|
|
34
|
+
if not is_plain_object(value):
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
result: dict[str, Any] = {}
|
|
38
|
+
for key, child in value.items():
|
|
39
|
+
mapping = mappings.get(key)
|
|
40
|
+
if mapping:
|
|
41
|
+
result[key] = map_field_value(child, mapping)
|
|
42
|
+
else:
|
|
43
|
+
result[key] = walk(child)
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
return walk(deep_clone(data))
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .types import TransformationMap
|
|
6
|
+
from .utils.deep_clone import deep_clone
|
|
7
|
+
from .utils.is_plain_object import is_plain_object
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def apply_transformations(
|
|
11
|
+
data: Any,
|
|
12
|
+
transformations: TransformationMap | None = None,
|
|
13
|
+
) -> Any:
|
|
14
|
+
transforms = transformations or {}
|
|
15
|
+
if not transforms:
|
|
16
|
+
return data
|
|
17
|
+
|
|
18
|
+
def walk(value: Any) -> Any:
|
|
19
|
+
if isinstance(value, list):
|
|
20
|
+
return [walk(item) for item in value]
|
|
21
|
+
|
|
22
|
+
if not is_plain_object(value):
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
result: dict[str, Any] = {}
|
|
26
|
+
for key, child in value.items():
|
|
27
|
+
transform = transforms.get(key)
|
|
28
|
+
if transform:
|
|
29
|
+
result[key] = transform(child, key, value)
|
|
30
|
+
else:
|
|
31
|
+
result[key] = walk(child)
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
return walk(deep_clone(data))
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .io.load_json_file import load_json_file
|
|
7
|
+
from .reconcile import reconcile
|
|
8
|
+
from .types import ReconcileOptions
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def assert_against_file(
|
|
12
|
+
live_data: Any,
|
|
13
|
+
expected_file_path: str | Path,
|
|
14
|
+
options: ReconcileOptions | dict[str, Any] | None = None,
|
|
15
|
+
) -> dict[str, Any]:
|
|
16
|
+
expected_data = load_json_file(expected_file_path)
|
|
17
|
+
return reconcile(live_data, expected_data, options)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .diff_values import diff_values
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def compare_objects(actual: Any, expected: Any) -> dict[str, Any]:
|
|
9
|
+
diff = diff_values(actual, expected)
|
|
10
|
+
return {"match": diff is None, "diff": diff}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .utils.is_plain_object import is_plain_object
|
|
6
|
+
from .utils.stable_stringify import stable_stringify
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def diff_values(
|
|
10
|
+
actual: Any,
|
|
11
|
+
expected: Any,
|
|
12
|
+
path_prefix: str = "",
|
|
13
|
+
) -> dict[str, Any] | None:
|
|
14
|
+
if actual == expected:
|
|
15
|
+
return None
|
|
16
|
+
|
|
17
|
+
actual_type = "array" if isinstance(actual, list) else type(actual).__name__
|
|
18
|
+
expected_type = "array" if isinstance(expected, list) else type(expected).__name__
|
|
19
|
+
|
|
20
|
+
if actual_type != expected_type:
|
|
21
|
+
return {
|
|
22
|
+
"path": path_prefix or "(root)",
|
|
23
|
+
"actual": actual,
|
|
24
|
+
"expected": expected,
|
|
25
|
+
"message": f"Type mismatch: {actual_type} !== {expected_type}",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if not is_plain_object(actual) and not isinstance(actual, list):
|
|
29
|
+
return {
|
|
30
|
+
"path": path_prefix or "(root)",
|
|
31
|
+
"actual": actual,
|
|
32
|
+
"expected": expected,
|
|
33
|
+
"message": "Value mismatch",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if isinstance(actual, list) and isinstance(expected, list):
|
|
37
|
+
if len(actual) != len(expected):
|
|
38
|
+
return {
|
|
39
|
+
"path": path_prefix or "(root)",
|
|
40
|
+
"actual": len(actual),
|
|
41
|
+
"expected": len(expected),
|
|
42
|
+
"message": "Array length mismatch",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for index, (actual_item, expected_item) in enumerate(zip(actual, expected)):
|
|
46
|
+
child_path = f"{path_prefix}[{index}]" if path_prefix else f"[{index}]"
|
|
47
|
+
child_diff = diff_values(actual_item, expected_item, child_path)
|
|
48
|
+
if child_diff:
|
|
49
|
+
return child_diff
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
if not is_plain_object(actual) or not is_plain_object(expected):
|
|
53
|
+
return {
|
|
54
|
+
"path": path_prefix or "(root)",
|
|
55
|
+
"actual": actual,
|
|
56
|
+
"expected": expected,
|
|
57
|
+
"message": "Value mismatch",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
actual_keys = sorted(actual.keys())
|
|
61
|
+
expected_keys = sorted(expected.keys())
|
|
62
|
+
|
|
63
|
+
if stable_stringify(actual_keys) != stable_stringify(expected_keys):
|
|
64
|
+
return {
|
|
65
|
+
"path": path_prefix or "(root)",
|
|
66
|
+
"actual": actual_keys,
|
|
67
|
+
"expected": expected_keys,
|
|
68
|
+
"message": "Object key mismatch",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for key in actual_keys:
|
|
72
|
+
child_path = f"{path_prefix}.{key}" if path_prefix else key
|
|
73
|
+
child_diff = diff_values(actual[key], expected[key], child_path)
|
|
74
|
+
if child_diff:
|
|
75
|
+
return child_diff
|
|
76
|
+
|
|
77
|
+
return None
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .apply_data_mapping import apply_data_mapping
|
|
6
|
+
from .apply_transformations import apply_transformations
|
|
7
|
+
from .compare_objects import compare_objects
|
|
8
|
+
from .strip_fields import strip_fields
|
|
9
|
+
from .types import ReconcileOptions
|
|
10
|
+
from .utils.deep_clone import deep_clone
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def reconcile(
|
|
14
|
+
live_data: Any,
|
|
15
|
+
expected_data: Any,
|
|
16
|
+
options: ReconcileOptions | dict[str, Any] | None = None,
|
|
17
|
+
) -> dict[str, Any]:
|
|
18
|
+
opts = options or {}
|
|
19
|
+
ignore_fields = opts.get("ignoreFields", [])
|
|
20
|
+
transformations = opts.get("transformations", {})
|
|
21
|
+
data_mapping = opts.get("dataMapping", {})
|
|
22
|
+
|
|
23
|
+
processed = deep_clone(live_data)
|
|
24
|
+
processed = strip_fields(processed, ignore_fields)
|
|
25
|
+
processed = apply_transformations(processed, transformations)
|
|
26
|
+
processed = apply_data_mapping(processed, data_mapping)
|
|
27
|
+
|
|
28
|
+
expected = deep_clone(expected_data)
|
|
29
|
+
expected = strip_fields(expected, ignore_fields)
|
|
30
|
+
|
|
31
|
+
comparison = compare_objects(processed, expected)
|
|
32
|
+
return {
|
|
33
|
+
"match": comparison["match"],
|
|
34
|
+
"processed": processed,
|
|
35
|
+
"expected": expected,
|
|
36
|
+
"diff": comparison["diff"],
|
|
37
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .utils.deep_clone import deep_clone
|
|
6
|
+
from .utils.is_plain_object import is_plain_object
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _remove_nested(target: Any, field_path: str) -> None:
|
|
10
|
+
parts = field_path.split(".")
|
|
11
|
+
cursor: Any = target if is_plain_object(target) else None
|
|
12
|
+
|
|
13
|
+
for part in parts[:-1]:
|
|
14
|
+
if not part or not is_plain_object(cursor) or part not in cursor:
|
|
15
|
+
return
|
|
16
|
+
cursor = cursor[part]
|
|
17
|
+
|
|
18
|
+
last_key = parts[-1]
|
|
19
|
+
if not last_key or cursor is None:
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
if is_plain_object(cursor):
|
|
23
|
+
cursor.pop(last_key, None)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def strip_fields(data: Any, ignore_fields: list[str] | None = None) -> Any:
|
|
27
|
+
fields = ignore_fields or []
|
|
28
|
+
if not fields:
|
|
29
|
+
return data
|
|
30
|
+
|
|
31
|
+
top_level_keys = {field for field in fields if "." not in field}
|
|
32
|
+
nested_paths = [field for field in fields if "." in field]
|
|
33
|
+
|
|
34
|
+
def walk(value: Any) -> Any:
|
|
35
|
+
if isinstance(value, list):
|
|
36
|
+
return [walk(item) for item in value]
|
|
37
|
+
|
|
38
|
+
if not is_plain_object(value):
|
|
39
|
+
return value
|
|
40
|
+
|
|
41
|
+
result: dict[str, Any] = {}
|
|
42
|
+
for key, child in value.items():
|
|
43
|
+
if key in top_level_keys:
|
|
44
|
+
continue
|
|
45
|
+
result[key] = walk(child)
|
|
46
|
+
|
|
47
|
+
for field_path in nested_paths:
|
|
48
|
+
_remove_nested(result, field_path)
|
|
49
|
+
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
return walk(deep_clone(data))
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any, TypeAlias
|
|
5
|
+
|
|
6
|
+
JsonValue: TypeAlias = Any
|
|
7
|
+
FieldTransformation: TypeAlias = Callable[[Any, str, dict[str, Any]], Any]
|
|
8
|
+
FieldMapping: TypeAlias = dict[str, Any] | Callable[[Any], Any]
|
|
9
|
+
TransformationMap: TypeAlias = dict[str, FieldTransformation]
|
|
10
|
+
DataMappingMap: TypeAlias = dict[str, FieldMapping]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DiffResult(dict):
|
|
14
|
+
"""path, actual, expected, message."""
|
|
15
|
+
|
|
16
|
+
path: str
|
|
17
|
+
actual: Any
|
|
18
|
+
expected: Any
|
|
19
|
+
message: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ReconcileOptions(dict):
|
|
23
|
+
ignoreFields: list[str]
|
|
24
|
+
transformations: TransformationMap
|
|
25
|
+
dataMapping: DataMappingMap
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ReconcileResult(dict):
|
|
29
|
+
match: bool
|
|
30
|
+
processed: Any
|
|
31
|
+
expected: Any
|
|
32
|
+
diff: DiffResult | None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CompareResult(dict):
|
|
36
|
+
match: bool
|
|
37
|
+
diff: DiffResult | None
|