pytest-httpchain-jsonref 0.1.0__tar.gz → 0.2.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 → pytest_httpchain_jsonref-0.2.0}/PKG-INFO +1 -1
- {pytest_httpchain_jsonref-0.1.0 → pytest_httpchain_jsonref-0.2.0}/pyproject.toml +1 -4
- pytest_httpchain_jsonref-0.2.0/src/pytest_httpchain_jsonref/__init__.py +18 -0
- {pytest_httpchain_jsonref-0.1.0 → pytest_httpchain_jsonref-0.2.0}/src/pytest_httpchain_jsonref/loader.py +3 -2
- {pytest_httpchain_jsonref-0.1.0 → pytest_httpchain_jsonref-0.2.0}/src/pytest_httpchain_jsonref/plumbing/circular.py +6 -4
- {pytest_httpchain_jsonref-0.1.0 → pytest_httpchain_jsonref-0.2.0}/src/pytest_httpchain_jsonref/plumbing/path.py +35 -9
- {pytest_httpchain_jsonref-0.1.0 → pytest_httpchain_jsonref-0.2.0}/src/pytest_httpchain_jsonref/plumbing/reference.py +24 -14
- pytest_httpchain_jsonref-0.1.0/src/pytest_httpchain_jsonref/plumbing/__init__.py +0 -0
- {pytest_httpchain_jsonref-0.1.0 → pytest_httpchain_jsonref-0.2.0}/README.md +0 -0
- {pytest_httpchain_jsonref-0.1.0 → pytest_httpchain_jsonref-0.2.0}/src/pytest_httpchain_jsonref/exceptions.py +0 -0
- {pytest_httpchain_jsonref-0.1.0/src/pytest_httpchain_jsonref → pytest_httpchain_jsonref-0.2.0/src/pytest_httpchain_jsonref/plumbing}/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pytest-httpchain-jsonref"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "JSON reference ($ref) support for pytest-httpchain"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
@@ -10,6 +10,3 @@ dependencies = ["deepmerge>=2.0"]
|
|
|
10
10
|
[build-system]
|
|
11
11
|
requires = ["uv_build>=0.7.21,<0.8.0"]
|
|
12
12
|
build-backend = "uv_build"
|
|
13
|
-
|
|
14
|
-
[dependency-groups]
|
|
15
|
-
dev = ["pytest-datadir>=1.7.2"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""JSON reference ($ref) resolution for pytest-httpchain.
|
|
2
|
+
|
|
3
|
+
This package provides JSON loading with $ref resolution and deep merging support.
|
|
4
|
+
References can point to local files or external paths, with security controls
|
|
5
|
+
for parent directory traversal.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
>>> from pytest_httpchain_jsonref import load_json
|
|
9
|
+
>>> data = load_json("test_scenario.http.json")
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .exceptions import ReferenceResolverError
|
|
13
|
+
from .loader import load_json
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"load_json",
|
|
17
|
+
"ReferenceResolverError",
|
|
18
|
+
]
|
|
@@ -6,12 +6,13 @@ from typing import Any
|
|
|
6
6
|
from pytest_httpchain_jsonref.plumbing.reference import ReferenceResolver
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
def load_json(path: Path, max_parent_traversal_depth: int = 3) -> dict[str, Any]:
|
|
9
|
+
def load_json(path: Path, max_parent_traversal_depth: int = 3, root_path: Path | None = None) -> dict[str, Any]:
|
|
10
10
|
"""Load JSON from file and resolve all $ref statements with circular reference protection.
|
|
11
11
|
|
|
12
12
|
Args:
|
|
13
13
|
path: Path to the JSON file to load
|
|
14
14
|
max_parent_traversal_depth: Maximum number of parent directory traversals allowed in $ref paths
|
|
15
|
+
root_path: Optional root directory for resolving references (e.g., pytest's rootdir)
|
|
15
16
|
|
|
16
17
|
Returns:
|
|
17
18
|
Dictionary with all $ref statements resolved
|
|
@@ -19,5 +20,5 @@ def load_json(path: Path, max_parent_traversal_depth: int = 3) -> dict[str, Any]
|
|
|
19
20
|
Raises:
|
|
20
21
|
ReferenceResolverError: If the file cannot be loaded or parsed, if merge conflicts occur, or if circular references are detected
|
|
21
22
|
"""
|
|
22
|
-
resolver = ReferenceResolver(max_parent_traversal_depth)
|
|
23
|
+
resolver = ReferenceResolver(max_parent_traversal_depth, root_path)
|
|
23
24
|
return resolver.resolve_file(path)
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from typing import Self
|
|
5
5
|
|
|
6
|
+
from pytest_httpchain_jsonref.exceptions import ReferenceResolverError
|
|
7
|
+
|
|
6
8
|
|
|
7
9
|
class CircularDependencyTracker:
|
|
8
10
|
"""Tracks references to detect circular dependencies."""
|
|
@@ -19,11 +21,11 @@ class CircularDependencyTracker:
|
|
|
19
21
|
pointer: The JSON pointer within the file
|
|
20
22
|
|
|
21
23
|
Raises:
|
|
22
|
-
|
|
24
|
+
ReferenceResolverError: If a circular reference is detected
|
|
23
25
|
"""
|
|
24
26
|
ref_key = (file_path, pointer)
|
|
25
27
|
if ref_key in self.external_refs:
|
|
26
|
-
raise
|
|
28
|
+
raise ReferenceResolverError(f"Circular reference detected: {file_path}#{pointer}")
|
|
27
29
|
self.external_refs.add(ref_key)
|
|
28
30
|
|
|
29
31
|
def check_internal_ref(self, pointer: str) -> None:
|
|
@@ -33,10 +35,10 @@ class CircularDependencyTracker:
|
|
|
33
35
|
pointer: The JSON pointer being referenced
|
|
34
36
|
|
|
35
37
|
Raises:
|
|
36
|
-
|
|
38
|
+
ReferenceResolverError: If a circular reference is detected
|
|
37
39
|
"""
|
|
38
40
|
if pointer in self.internal_refs:
|
|
39
|
-
raise
|
|
41
|
+
raise ReferenceResolverError(f"Circular reference detected: #{pointer}")
|
|
40
42
|
self.internal_refs.add(pointer)
|
|
41
43
|
|
|
42
44
|
def clear_external_ref(self, file_path: Path, pointer: str) -> None:
|
|
@@ -30,16 +30,42 @@ class PathValidator:
|
|
|
30
30
|
if parent_traversals > max_parent_traversal_depth:
|
|
31
31
|
raise ReferenceResolverError(f"Reference path '{ref_path}' exceeds maximum parent traversal depth of {max_parent_traversal_depth}")
|
|
32
32
|
|
|
33
|
-
# Resolve and validate path containment
|
|
34
|
-
resolved_path = (base_path / ref_path).resolve()
|
|
35
33
|
root_path_resolved = root_path.resolve()
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
34
|
+
base_path_resolved = base_path.resolve()
|
|
35
|
+
|
|
36
|
+
def is_valid_and_exists(resolved: Path) -> bool:
|
|
37
|
+
"""Check if path exists and is within allowed directory tree."""
|
|
38
|
+
if not resolved.exists():
|
|
39
|
+
return False
|
|
40
|
+
try:
|
|
41
|
+
resolved.relative_to(root_path_resolved)
|
|
42
|
+
return True
|
|
43
|
+
except ValueError:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
# Try resolving from different base paths in order of preference
|
|
47
|
+
paths_to_try = [base_path]
|
|
48
|
+
|
|
49
|
+
# Add root_path if it's different from base_path
|
|
50
|
+
if root_path_resolved != base_path_resolved:
|
|
51
|
+
paths_to_try.append(root_path)
|
|
52
|
+
|
|
53
|
+
# Try each base path and find the first valid existing file
|
|
54
|
+
result_path = None
|
|
55
|
+
for base in paths_to_try:
|
|
56
|
+
resolved = (base / ref_path).resolve()
|
|
57
|
+
if is_valid_and_exists(resolved):
|
|
58
|
+
result_path = resolved
|
|
59
|
+
break
|
|
60
|
+
|
|
61
|
+
# If no existing file found, raise an error
|
|
62
|
+
if result_path is None:
|
|
63
|
+
# Provide helpful error message showing what paths were tried
|
|
64
|
+
tried_paths = [str((base / ref_path).resolve()) for base in paths_to_try]
|
|
65
|
+
paths_msg = "\n - ".join(tried_paths)
|
|
66
|
+
raise ReferenceResolverError(f"Reference path '{ref_path}' not found. Tried:\n - {paths_msg}")
|
|
67
|
+
|
|
68
|
+
return result_path
|
|
43
69
|
|
|
44
70
|
@staticmethod
|
|
45
71
|
def parse_json_pointer(pointer: str) -> list[str]:
|
|
@@ -18,13 +18,12 @@ REF_PATTERN = re.compile(r"^(?P<file>[^#]+)?(?:#(?P<pointer>/.*))?$")
|
|
|
18
18
|
class ReferenceResolver:
|
|
19
19
|
"""Resolves JSON references ($ref) in documents."""
|
|
20
20
|
|
|
21
|
-
def __init__(self, max_parent_traversal_depth: int = 3):
|
|
21
|
+
def __init__(self, max_parent_traversal_depth: int = 3, root_path: Path | None = None):
|
|
22
22
|
self.max_parent_traversal_depth = max_parent_traversal_depth
|
|
23
23
|
self.path_validator = PathValidator()
|
|
24
24
|
self.tracker = CircularDependencyTracker()
|
|
25
25
|
self.base_path: Path | None = None
|
|
26
|
-
self.root_path
|
|
27
|
-
self._cache: dict[tuple[Path, str], Any] = {}
|
|
26
|
+
self.root_path = root_path
|
|
28
27
|
|
|
29
28
|
def resolve_document(self, data: dict[str, Any], base_path: Path) -> dict[str, Any]:
|
|
30
29
|
"""Resolve all references in a document.
|
|
@@ -58,7 +57,16 @@ class ReferenceResolver:
|
|
|
58
57
|
with open(path, encoding="utf-8") as f:
|
|
59
58
|
data = json.load(f)
|
|
60
59
|
|
|
61
|
-
|
|
60
|
+
# If root_path wasn't provided, find a suitable one by going up the directory tree
|
|
61
|
+
# up to max_parent_traversal_depth levels
|
|
62
|
+
if not self.root_path:
|
|
63
|
+
self.root_path = path.parent
|
|
64
|
+
for _ in range(self.max_parent_traversal_depth):
|
|
65
|
+
parent = self.root_path.parent
|
|
66
|
+
if parent == self.root_path:
|
|
67
|
+
break # Reached filesystem root
|
|
68
|
+
self.root_path = parent
|
|
69
|
+
|
|
62
70
|
return self.resolve_document(data, path.parent)
|
|
63
71
|
|
|
64
72
|
except (OSError, json.JSONDecodeError) as e:
|
|
@@ -110,10 +118,6 @@ class ReferenceResolver:
|
|
|
110
118
|
) -> Any:
|
|
111
119
|
resolved_path = self.path_validator.validate_ref_path(file_path, current_path, self.root_path or current_path, self.max_parent_traversal_depth)
|
|
112
120
|
|
|
113
|
-
cache_key = (resolved_path, pointer)
|
|
114
|
-
if cache_key in self._cache:
|
|
115
|
-
return self._cache[cache_key]
|
|
116
|
-
|
|
117
121
|
self.tracker.check_external_ref(resolved_path, pointer)
|
|
118
122
|
|
|
119
123
|
try:
|
|
@@ -121,8 +125,8 @@ class ReferenceResolver:
|
|
|
121
125
|
external_data = self._navigate_pointer(full_external_data, pointer) if pointer else full_external_data
|
|
122
126
|
|
|
123
127
|
child_resolver = self._create_child_resolver()
|
|
128
|
+
child_resolver.base_path = resolved_path.parent
|
|
124
129
|
result = child_resolver._resolve_refs(external_data, resolved_path.parent, root_data=full_external_data)
|
|
125
|
-
self._cache[cache_key] = result
|
|
126
130
|
return result
|
|
127
131
|
|
|
128
132
|
except (OSError, json.JSONDecodeError) as e:
|
|
@@ -149,8 +153,16 @@ class ReferenceResolver:
|
|
|
149
153
|
|
|
150
154
|
parts = self.path_validator.parse_json_pointer(pointer)
|
|
151
155
|
|
|
156
|
+
def navigate_step(obj: Any, key: str) -> Any:
|
|
157
|
+
if isinstance(obj, list):
|
|
158
|
+
# RFC 6901: array indices must not have leading zeros (except "0" itself)
|
|
159
|
+
if len(key) > 1 and key.startswith("0"):
|
|
160
|
+
raise ValueError(f"Array index '{key}' has leading zeros")
|
|
161
|
+
return obj[int(key)]
|
|
162
|
+
return obj[key]
|
|
163
|
+
|
|
152
164
|
try:
|
|
153
|
-
return reduce(
|
|
165
|
+
return reduce(navigate_step, parts, data)
|
|
154
166
|
except (KeyError, IndexError, ValueError, TypeError) as e:
|
|
155
167
|
raise ReferenceResolverError(f"Invalid JSON pointer {pointer}: {e}") from e
|
|
156
168
|
|
|
@@ -178,16 +190,14 @@ class ReferenceResolver:
|
|
|
178
190
|
return always_merger.merge(referenced_data, resolved_siblings)
|
|
179
191
|
|
|
180
192
|
def _load_json_file(self, path: Path) -> dict[str, Any]:
|
|
181
|
-
"""Load
|
|
193
|
+
"""Load JSON file content."""
|
|
182
194
|
with open(path, encoding="utf-8") as f:
|
|
183
195
|
return json.load(f)
|
|
184
196
|
|
|
185
197
|
def _create_child_resolver(self) -> Self:
|
|
186
198
|
"""Create a child resolver with inherited state."""
|
|
187
|
-
child_resolver = type(self)(self.max_parent_traversal_depth)
|
|
199
|
+
child_resolver = type(self)(self.max_parent_traversal_depth, self.root_path)
|
|
188
200
|
child_resolver.tracker = self.tracker.create_child_tracker()
|
|
189
|
-
child_resolver.root_path = self.root_path
|
|
190
|
-
child_resolver._cache = self._cache.copy()
|
|
191
201
|
return child_resolver
|
|
192
202
|
|
|
193
203
|
def _detect_merge_conflicts(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|