pytest-httpchain-jsonref 0.1.0__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.
File without changes
@@ -0,0 +1,2 @@
1
+ class ReferenceResolverError(Exception):
2
+ """Base exception for reference resolution errors."""
@@ -0,0 +1,23 @@
1
+ """JSON file loading with reference resolution."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from pytest_httpchain_jsonref.plumbing.reference import ReferenceResolver
7
+
8
+
9
+ def load_json(path: Path, max_parent_traversal_depth: int = 3) -> dict[str, Any]:
10
+ """Load JSON from file and resolve all $ref statements with circular reference protection.
11
+
12
+ Args:
13
+ path: Path to the JSON file to load
14
+ max_parent_traversal_depth: Maximum number of parent directory traversals allowed in $ref paths
15
+
16
+ Returns:
17
+ Dictionary with all $ref statements resolved
18
+
19
+ Raises:
20
+ ReferenceResolverError: If the file cannot be loaded or parsed, if merge conflicts occur, or if circular references are detected
21
+ """
22
+ resolver = ReferenceResolver(max_parent_traversal_depth)
23
+ return resolver.resolve_file(path)
File without changes
@@ -0,0 +1,69 @@
1
+ """Circular dependency tracking for reference resolution."""
2
+
3
+ from pathlib import Path
4
+ from typing import Self
5
+
6
+
7
+ class CircularDependencyTracker:
8
+ """Tracks references to detect circular dependencies."""
9
+
10
+ def __init__(self):
11
+ self.external_refs: set[tuple[Path, str]] = set()
12
+ self.internal_refs: set[str] = set()
13
+
14
+ def check_external_ref(self, file_path: Path, pointer: str) -> None:
15
+ """Check if an external reference would create a circular dependency.
16
+
17
+ Args:
18
+ file_path: The file being referenced
19
+ pointer: The JSON pointer within the file
20
+
21
+ Raises:
22
+ RuntimeError: If a circular reference is detected
23
+ """
24
+ ref_key = (file_path, pointer)
25
+ if ref_key in self.external_refs:
26
+ raise RuntimeError(f"Circular reference detected: {file_path}#{pointer}")
27
+ self.external_refs.add(ref_key)
28
+
29
+ def check_internal_ref(self, pointer: str) -> None:
30
+ """Check if an internal reference would create a circular dependency.
31
+
32
+ Args:
33
+ pointer: The JSON pointer being referenced
34
+
35
+ Raises:
36
+ RuntimeError: If a circular reference is detected
37
+ """
38
+ if pointer in self.internal_refs:
39
+ raise RuntimeError(f"Circular reference detected: #{pointer}")
40
+ self.internal_refs.add(pointer)
41
+
42
+ def clear_external_ref(self, file_path: Path, pointer: str) -> None:
43
+ """Clear an external reference after processing.
44
+
45
+ Args:
46
+ file_path: The file that was referenced
47
+ pointer: The JSON pointer that was referenced
48
+ """
49
+ ref_key = (file_path, pointer)
50
+ self.external_refs.discard(ref_key)
51
+
52
+ def clear_internal_ref(self, pointer: str) -> None:
53
+ """Clear an internal reference after processing.
54
+
55
+ Args:
56
+ pointer: The JSON pointer that was referenced
57
+ """
58
+ self.internal_refs.discard(pointer)
59
+
60
+ def create_child_tracker(self) -> Self:
61
+ """Create a child tracker that inherits current state.
62
+
63
+ Returns:
64
+ A new tracker with copies of the current reference sets
65
+ """
66
+ child = CircularDependencyTracker()
67
+ child.external_refs = self.external_refs.copy()
68
+ child.internal_refs = self.internal_refs.copy()
69
+ return child
@@ -0,0 +1,70 @@
1
+ """Path validation utilities for reference resolution."""
2
+
3
+ from pathlib import Path
4
+
5
+ from pytest_httpchain_jsonref.exceptions import ReferenceResolverError
6
+
7
+
8
+ class PathValidator:
9
+ """Validates paths for security and correctness."""
10
+
11
+ @staticmethod
12
+ def validate_ref_path(ref_path: str, base_path: Path, root_path: Path, max_parent_traversal_depth: int) -> Path:
13
+ """Validate and resolve a reference path.
14
+
15
+ Args:
16
+ ref_path: The reference path to validate
17
+ base_path: The base path to resolve relative references from
18
+ root_path: The root path that references should not escape
19
+ max_parent_traversal_depth: Maximum allowed parent directory traversals
20
+
21
+ Returns:
22
+ The resolved absolute path
23
+
24
+ Raises:
25
+ ReferenceResolverError: If the path is invalid or violates security constraints
26
+ """
27
+ # Count parent traversals before resolution
28
+ parent_traversals = sum(1 for part in Path(ref_path).parts if part == "..")
29
+
30
+ if parent_traversals > max_parent_traversal_depth:
31
+ raise ReferenceResolverError(f"Reference path '{ref_path}' exceeds maximum parent traversal depth of {max_parent_traversal_depth}")
32
+
33
+ # Resolve and validate path containment
34
+ resolved_path = (base_path / ref_path).resolve()
35
+ root_path_resolved = root_path.resolve()
36
+
37
+ try:
38
+ resolved_path.relative_to(root_path_resolved)
39
+ except ValueError:
40
+ raise ReferenceResolverError(f"Reference path '{ref_path}' points outside allowed directory tree") from None
41
+
42
+ return resolved_path
43
+
44
+ @staticmethod
45
+ def parse_json_pointer(pointer: str) -> list[str]:
46
+ """Parse a JSON pointer into path components.
47
+
48
+ Args:
49
+ pointer: JSON pointer string (e.g., "/path/to/node")
50
+
51
+ Returns:
52
+ List of path components
53
+
54
+ Raises:
55
+ ReferenceResolverError: If the pointer is invalid
56
+ """
57
+ if not pointer:
58
+ return []
59
+
60
+ if not pointer.startswith("/"):
61
+ raise ReferenceResolverError(f"Invalid JSON pointer: {pointer} (must start with '/')")
62
+
63
+ # Split by / and handle escaped characters
64
+ parts = []
65
+ for part in pointer[1:].split("/"):
66
+ # Unescape JSON pointer escape sequences
67
+ part = part.replace("~1", "/").replace("~0", "~")
68
+ parts.append(part)
69
+
70
+ return parts
@@ -0,0 +1,212 @@
1
+ """Reference resolution for JSON files."""
2
+
3
+ import json
4
+ import re
5
+ from functools import reduce
6
+ from pathlib import Path
7
+ from typing import Any, Self
8
+
9
+ from deepmerge import always_merger
10
+
11
+ from pytest_httpchain_jsonref.exceptions import ReferenceResolverError
12
+ from pytest_httpchain_jsonref.plumbing.circular import CircularDependencyTracker
13
+ from pytest_httpchain_jsonref.plumbing.path import PathValidator
14
+
15
+ REF_PATTERN = re.compile(r"^(?P<file>[^#]+)?(?:#(?P<pointer>/.*))?$")
16
+
17
+
18
+ class ReferenceResolver:
19
+ """Resolves JSON references ($ref) in documents."""
20
+
21
+ def __init__(self, max_parent_traversal_depth: int = 3):
22
+ self.max_parent_traversal_depth = max_parent_traversal_depth
23
+ self.path_validator = PathValidator()
24
+ self.tracker = CircularDependencyTracker()
25
+ self.base_path: Path | None = None
26
+ self.root_path: Path | None = None
27
+ self._cache: dict[tuple[Path, str], Any] = {}
28
+
29
+ def resolve_document(self, data: dict[str, Any], base_path: Path) -> dict[str, Any]:
30
+ """Resolve all references in a document.
31
+
32
+ Args:
33
+ data: The document data to resolve references in
34
+ base_path: The base path for resolving relative references
35
+
36
+ Returns:
37
+ The document with all references resolved
38
+
39
+ Raises:
40
+ ReferenceResolverError: If resolution fails
41
+ """
42
+ self.base_path = base_path
43
+ return self._resolve_refs(data, base_path, root_data=data)
44
+
45
+ def resolve_file(self, path: Path) -> dict[str, Any]:
46
+ """Load a JSON file and resolve all references.
47
+
48
+ Args:
49
+ path: Path to the JSON file to load
50
+
51
+ Returns:
52
+ The loaded document with all references resolved
53
+
54
+ Raises:
55
+ ReferenceResolverError: If the file cannot be loaded or references cannot be resolved
56
+ """
57
+ try:
58
+ with open(path, encoding="utf-8") as f:
59
+ data = json.load(f)
60
+
61
+ self.root_path = path.parent
62
+ return self.resolve_document(data, path.parent)
63
+
64
+ except (OSError, json.JSONDecodeError) as e:
65
+ raise ReferenceResolverError(f"Failed to load JSON from {path}: {e}") from e
66
+
67
+ def _resolve_refs(
68
+ self,
69
+ data: Any,
70
+ current_path: Path,
71
+ root_data: Any,
72
+ ) -> Any:
73
+ match data:
74
+ case dict() if "$ref" in data:
75
+ return self._resolve_single_ref(data, current_path, root_data)
76
+ case dict():
77
+ return {key: self._resolve_refs(value, current_path, root_data) for key, value in data.items()}
78
+ case list():
79
+ return [self._resolve_refs(item, current_path, root_data) for item in data]
80
+ case _:
81
+ return data
82
+
83
+ def _resolve_single_ref(
84
+ self,
85
+ data: dict[str, Any],
86
+ current_path: Path,
87
+ root_data: Any,
88
+ ) -> Any:
89
+ ref_value = data["$ref"]
90
+ match = REF_PATTERN.match(ref_value)
91
+
92
+ if not match:
93
+ raise ReferenceResolverError(f"Invalid $ref format: {ref_value}")
94
+
95
+ file_path = match.group("file")
96
+ pointer = match.group("pointer") or ""
97
+
98
+ if file_path:
99
+ referenced_data = self._resolve_external_ref(file_path, pointer, current_path)
100
+ else:
101
+ referenced_data = self._resolve_internal_ref(pointer, root_data)
102
+
103
+ return self._merge_with_siblings(data, referenced_data, current_path, root_data)
104
+
105
+ def _resolve_external_ref(
106
+ self,
107
+ file_path: str,
108
+ pointer: str,
109
+ current_path: Path,
110
+ ) -> Any:
111
+ resolved_path = self.path_validator.validate_ref_path(file_path, current_path, self.root_path or current_path, self.max_parent_traversal_depth)
112
+
113
+ cache_key = (resolved_path, pointer)
114
+ if cache_key in self._cache:
115
+ return self._cache[cache_key]
116
+
117
+ self.tracker.check_external_ref(resolved_path, pointer)
118
+
119
+ try:
120
+ full_external_data = self._load_json_file(resolved_path)
121
+ external_data = self._navigate_pointer(full_external_data, pointer) if pointer else full_external_data
122
+
123
+ child_resolver = self._create_child_resolver()
124
+ result = child_resolver._resolve_refs(external_data, resolved_path.parent, root_data=full_external_data)
125
+ self._cache[cache_key] = result
126
+ return result
127
+
128
+ except (OSError, json.JSONDecodeError) as e:
129
+ raise ReferenceResolverError(f"Failed to load external reference {file_path}: {e}") from e
130
+ finally:
131
+ self.tracker.clear_external_ref(resolved_path, pointer)
132
+
133
+ def _resolve_internal_ref(
134
+ self,
135
+ pointer: str,
136
+ root_data: Any,
137
+ ) -> Any:
138
+ self.tracker.check_internal_ref(pointer)
139
+
140
+ try:
141
+ referenced_data = self._navigate_pointer(root_data, pointer)
142
+ return self._resolve_refs(referenced_data, self.base_path, root_data)
143
+ finally:
144
+ self.tracker.clear_internal_ref(pointer)
145
+
146
+ def _navigate_pointer(self, data: Any, pointer: str) -> Any:
147
+ if not pointer:
148
+ return data
149
+
150
+ parts = self.path_validator.parse_json_pointer(pointer)
151
+
152
+ try:
153
+ return reduce(lambda obj, key: obj[int(key)] if isinstance(obj, list) else obj[key], parts, data)
154
+ except (KeyError, IndexError, ValueError, TypeError) as e:
155
+ raise ReferenceResolverError(f"Invalid JSON pointer {pointer}: {e}") from e
156
+
157
+ def _merge_with_siblings(
158
+ self,
159
+ ref_dict: dict[str, Any],
160
+ referenced_data: Any,
161
+ current_path: Path,
162
+ root_data: Any,
163
+ ) -> Any:
164
+ siblings = {k: v for k, v in ref_dict.items() if k != "$ref"}
165
+
166
+ if not siblings:
167
+ return referenced_data
168
+
169
+ if not isinstance(referenced_data, dict):
170
+ if len(siblings) > 0:
171
+ raise ReferenceResolverError("Cannot merge non-dict reference with sibling properties")
172
+ return referenced_data
173
+
174
+ resolved_siblings = self._resolve_refs(siblings, current_path, root_data)
175
+
176
+ self._detect_merge_conflicts(referenced_data, resolved_siblings)
177
+
178
+ return always_merger.merge(referenced_data, resolved_siblings)
179
+
180
+ def _load_json_file(self, path: Path) -> dict[str, Any]:
181
+ """Load and cache JSON file content."""
182
+ with open(path, encoding="utf-8") as f:
183
+ return json.load(f)
184
+
185
+ def _create_child_resolver(self) -> Self:
186
+ """Create a child resolver with inherited state."""
187
+ child_resolver = type(self)(self.max_parent_traversal_depth)
188
+ child_resolver.tracker = self.tracker.create_child_tracker()
189
+ child_resolver.root_path = self.root_path
190
+ child_resolver._cache = self._cache.copy()
191
+ return child_resolver
192
+
193
+ def _detect_merge_conflicts(
194
+ self,
195
+ base: Any,
196
+ overlay: Any,
197
+ path: str = "",
198
+ ) -> None:
199
+ if base is None or overlay is None or base == overlay:
200
+ return
201
+
202
+ if isinstance(base, dict) and isinstance(overlay, dict):
203
+ for key, value in overlay.items():
204
+ if key in base:
205
+ new_path = f"{path}.{key}" if path else key
206
+ self._detect_merge_conflicts(base[key], value, new_path)
207
+ return
208
+
209
+ if isinstance(base, list) and isinstance(overlay, list):
210
+ return
211
+
212
+ raise ReferenceResolverError(f"Merge conflict at {path if path else 'root'}")
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.3
2
+ Name: pytest-httpchain-jsonref
3
+ Version: 0.1.0
4
+ Summary: JSON reference ($ref) support for pytest-httpchain
5
+ Author: Alexander Eresov
6
+ Author-email: Alexander Eresov <aeresov@gmail.com>
7
+ Requires-Dist: deepmerge>=2.0
8
+ Requires-Python: >=3.13
9
+ Description-Content-Type: text/markdown
10
+
@@ -0,0 +1,10 @@
1
+ pytest_httpchain_jsonref/__init__.py,sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855,0
2
+ pytest_httpchain_jsonref/exceptions.py,sha256=9f22da68c6ad384fbe170ed0ec79eec8bf5ad3f1217094534079501710fd74ed,99
3
+ pytest_httpchain_jsonref/loader.py,sha256=a1fd67b1afc45355438a82c6be4e43acab40b7f07dd43afa0e49958ef7535afb,838
4
+ pytest_httpchain_jsonref/plumbing/__init__.py,sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855,0
5
+ pytest_httpchain_jsonref/plumbing/circular.py,sha256=ca98f4e177381623a4ae2e63997147003bd9e185ade5c4abcf88de31329fb80c,2333
6
+ pytest_httpchain_jsonref/plumbing/path.py,sha256=5018995155887abd702fee6bee285f255720aa983148b1368f7c3824b57b9ac4,2462
7
+ pytest_httpchain_jsonref/plumbing/reference.py,sha256=8dc82a933ac75d17cdac8dbb664f317f8f6802d1eed58b3ee66af36812cb5eb9,7497
8
+ pytest_httpchain_jsonref-0.1.0.dist-info/WHEEL,sha256=ab6157bc637547491fb4567cd7ddf26b04d63382916ca16c29a5c8e94c9c9ef7,79
9
+ pytest_httpchain_jsonref-0.1.0.dist-info/METADATA,sha256=775e5b1c1cb1864dc06c310dcc23849616859ef688045254192ca35f9b8640be,299
10
+ pytest_httpchain_jsonref-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.7.22
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any