pytest-httpchain-jsonref 0.1.0__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.
- pytest_httpchain_jsonref-0.1.0/PKG-INFO +10 -0
- pytest_httpchain_jsonref-0.1.0/README.md +0 -0
- pytest_httpchain_jsonref-0.1.0/pyproject.toml +15 -0
- pytest_httpchain_jsonref-0.1.0/src/pytest_httpchain_jsonref/__init__.py +0 -0
- pytest_httpchain_jsonref-0.1.0/src/pytest_httpchain_jsonref/exceptions.py +2 -0
- pytest_httpchain_jsonref-0.1.0/src/pytest_httpchain_jsonref/loader.py +23 -0
- pytest_httpchain_jsonref-0.1.0/src/pytest_httpchain_jsonref/plumbing/__init__.py +0 -0
- pytest_httpchain_jsonref-0.1.0/src/pytest_httpchain_jsonref/plumbing/circular.py +69 -0
- pytest_httpchain_jsonref-0.1.0/src/pytest_httpchain_jsonref/plumbing/path.py +70 -0
- pytest_httpchain_jsonref-0.1.0/src/pytest_httpchain_jsonref/plumbing/reference.py +212 -0
|
@@ -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
|
+
|
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pytest-httpchain-jsonref"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "JSON reference ($ref) support for pytest-httpchain"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
authors = [{ name = "Alexander Eresov", email = "aeresov@gmail.com" }]
|
|
8
|
+
dependencies = ["deepmerge>=2.0"]
|
|
9
|
+
|
|
10
|
+
[build-system]
|
|
11
|
+
requires = ["uv_build>=0.7.21,<0.8.0"]
|
|
12
|
+
build-backend = "uv_build"
|
|
13
|
+
|
|
14
|
+
[dependency-groups]
|
|
15
|
+
dev = ["pytest-datadir>=1.7.2"]
|
|
File without changes
|
|
@@ -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'}")
|