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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pytest-httpchain-jsonref
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: JSON reference ($ref) support for pytest-httpchain
5
5
  Author: Alexander Eresov
6
6
  Author-email: Alexander Eresov <aeresov@gmail.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pytest-httpchain-jsonref"
3
- version = "0.1.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
- RuntimeError: If a circular reference is detected
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 RuntimeError(f"Circular reference detected: {file_path}#{pointer}")
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
- RuntimeError: If a circular reference is detected
38
+ ReferenceResolverError: If a circular reference is detected
37
39
  """
38
40
  if pointer in self.internal_refs:
39
- raise RuntimeError(f"Circular reference detected: #{pointer}")
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
- 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
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: Path | None = None
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
- self.root_path = path.parent
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(lambda obj, key: obj[int(key)] if isinstance(obj, list) else obj[key], parts, data)
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 and cache JSON file content."""
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(