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.
@@ -0,0 +1,5 @@
1
+ /*.iml
2
+ .idea
3
+ dist/
4
+ build/
5
+ *.egg-info/
@@ -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,10 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+
8
+ def load_json_file(path: str | Path) -> Any:
9
+ with open(path, encoding="utf-8") as handle:
10
+ return json.load(handle)
@@ -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
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from typing import Any
5
+
6
+
7
+ def deep_clone(value: Any) -> Any:
8
+ return copy.deepcopy(value)
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def is_plain_object(value: Any) -> bool:
7
+ return isinstance(value, dict)
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+
7
+ def stable_stringify(value: Any) -> str:
8
+ return json.dumps(value, sort_keys=True, separators=(",", ":"))