Skip to content

Commit 7906082

Browse files
committed
Refactor JSON Pointer resolution
1 parent 98b167b commit 7906082

File tree

6 files changed

+77
-75
lines changed

6 files changed

+77
-75
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
**Fixes**
66

7-
- Fixed JSON pointers using negative indices. The JSON Pointer specification (RFC 6901) does not allow negative array indexes. We now raise a `JSONPointerIndexError` if a JSON Pointer attempts to resolve an array item with a negative index. See [#115](https://github.com/jg-rp/python-jsonpath/issues/115).
7+
- Fixed JSON pointers using negative indices. The JSON Pointer specification (RFC 6901) does not allow negative array indexes. We now raise a `JSONPointerIndexError` if a JSON Pointer attempts to resolve an array item with a negative index. See [#115](https://github.com/jg-rp/python-jsonpath/issues/115). For anyone needing JSON Pointers that support negative indexes, set `JSONPointer.min_int_index` to a suitably negative integer, like `JSONPointer.min_int_index = -(2**53) + 1`.
88

99
## Version 2.0.0
1010

jsonpath/exceptions.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,21 +160,21 @@ class JSONPointerIndexError(JSONPointerResolutionError, IndexError):
160160
"""An exception raised when an array index is out of range."""
161161

162162
def __str__(self) -> str:
163-
return f"pointer index error {super().__str__()}"
163+
return f"pointer index error: {super().__str__()}"
164164

165165

166166
class JSONPointerKeyError(JSONPointerResolutionError, KeyError):
167167
"""An exception raised when a pointer references a mapping with a missing key."""
168168

169169
def __str__(self) -> str:
170-
return f"pointer key error {super().__str__()}"
170+
return f"pointer key error: {super().__str__()}"
171171

172172

173173
class JSONPointerTypeError(JSONPointerResolutionError, TypeError):
174174
"""An exception raised when a pointer resolves a string against a sequence."""
175175

176176
def __str__(self) -> str:
177-
return f"pointer type error {super().__str__()}"
177+
return f"pointer type error: {super().__str__()}"
178178

179179

180180
class RelativeJSONPointerError(Exception):

jsonpath/pointer.py

Lines changed: 52 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from typing import Any
1111
from typing import Iterable
1212
from typing import Mapping
13+
from typing import Optional
1314
from typing import Sequence
1415
from typing import Tuple
1516
from typing import Union
@@ -58,14 +59,14 @@ class JSONPointer:
5859
max_int_index (int): The maximum integer allowed when resolving array
5960
items by index. Defaults to `(2**53) - 1`.
6061
min_int_index (int): The minimum integer allowed when resolving array
61-
items by index. Defaults to `-(2**53) + 1`.
62+
items by index. Defaults to `0`.
6263
"""
6364

6465
__slots__ = ("_s", "parts")
6566

6667
keys_selector = "~"
6768
max_int_index = (2**53) - 1
68-
min_int_index = -(2**53) + 1
69+
min_int_index = 0
6970

7071
def __init__(
7172
self,
@@ -75,11 +76,15 @@ def __init__(
7576
unicode_escape: bool = True,
7677
uri_decode: bool = False,
7778
) -> None:
78-
self.parts = parts or self._parse(
79-
pointer,
80-
unicode_escape=unicode_escape,
81-
uri_decode=uri_decode,
82-
)
79+
if parts:
80+
self.parts = tuple(str(part) for part in parts)
81+
else:
82+
self.parts = self._parse(
83+
pointer,
84+
unicode_escape=unicode_escape,
85+
uri_decode=uri_decode,
86+
)
87+
8388
self._s = self._encode(self.parts)
8489

8590
def __str__(self) -> str:
@@ -91,7 +96,7 @@ def _parse(
9196
*,
9297
unicode_escape: bool,
9398
uri_decode: bool,
94-
) -> Tuple[Union[int, str], ...]:
99+
) -> Tuple[str, ...]:
95100
if uri_decode:
96101
s = unquote(s)
97102
if unicode_escape:
@@ -103,43 +108,49 @@ def _parse(
103108
"pointer must start with a slash or be the empty string"
104109
)
105110

106-
return tuple(
107-
self._index(p.replace("~1", "/").replace("~0", "~")) for p in s.split("/")
108-
)[1:]
111+
return tuple(p.replace("~1", "/").replace("~0", "~") for p in s.split("/"))[1:]
112+
113+
def _index(self, key: str) -> Optional[int]:
114+
"""Return an array index for `key`.
109115
110-
def _index(self, s: str) -> Union[str, int]:
111-
# Reject non-zero ints that start with a zero and negative integers.
112-
if len(s) > 1 and s.startswith(("0", "-")):
113-
return s
116+
Return `None` if key can't be converted to an index.
117+
"""
118+
# Reject indexes that start with a zero.
119+
if len(key) > 1 and key.startswith("0"):
120+
return None
114121

115122
try:
116-
index = int(s)
117-
if index < self.min_int_index or index > self.max_int_index:
118-
raise JSONPointerError("index out of range")
119-
return index
123+
index = int(key)
120124
except ValueError:
121-
return s
125+
return None
122126

123-
def _getitem(self, obj: Any, key: Any) -> Any:
127+
if index < self.min_int_index or index > self.max_int_index:
128+
raise JSONPointerIndexError(
129+
f"array indices must be between {self.min_int_index}"
130+
f" and {self.max_int_index}"
131+
)
132+
133+
return index
134+
135+
def _getitem(self, obj: Any, key: str) -> Any:
124136
try:
125137
# Handle the most common cases. A mapping with a string key, or a sequence
126138
# with an integer index.
127-
#
128-
# Note that `obj` does not have to be a Mapping or Sequence here. Any object
129-
# implementing `__getitem__` will do.
139+
if isinstance(obj, Sequence) and not isinstance(obj, str):
140+
index = self._index(key)
141+
if isinstance(index, int):
142+
return getitem(obj, index)
130143
return getitem(obj, key)
131144
except KeyError as err:
132145
return self._handle_key_error(obj, key, err)
133146
except TypeError as err:
134147
return self._handle_type_error(obj, key, err)
135148
except IndexError as err:
136-
raise JSONPointerIndexError(f"index out of range: {key}") from err
137-
138-
def _handle_key_error(self, obj: Any, key: Any, err: Exception) -> object:
139-
if isinstance(key, int):
140-
# Try a string repr of the index-like item as a mapping key.
141-
return self._getitem(obj, str(key))
149+
if not isinstance(err, JSONPointerIndexError):
150+
raise JSONPointerIndexError(f"index out of range: {key}") from err
151+
raise
142152

153+
def _handle_key_error(self, obj: Any, key: str, err: Exception) -> object:
143154
# Handle non-standard key/property selector/pointer.
144155
#
145156
# For the benefit of `RelativeJSONPointer.to()` and `JSONPathMatch.pointer()`,
@@ -149,26 +160,18 @@ def _handle_key_error(self, obj: Any, key: Any, err: Exception) -> object:
149160
# Note that if a key with a leading `#`/`~` exists in `obj`, it will have been
150161
# handled by `_getitem`.
151162
if (
152-
isinstance(key, str)
153-
and isinstance(obj, Mapping)
163+
isinstance(obj, Mapping)
154164
and key.startswith((self.keys_selector, "#"))
155165
and key[1:] in obj
156166
):
157167
return key[1:]
158168

159169
raise JSONPointerKeyError(key) from err
160170

161-
def _handle_type_error(self, obj: Any, key: Any, err: Exception) -> object:
162-
if (
163-
isinstance(obj, str)
164-
or not isinstance(obj, Sequence)
165-
or not isinstance(key, str)
166-
):
171+
def _handle_type_error(self, obj: Any, key: str, err: Exception) -> object:
172+
if not isinstance(obj, Sequence) or not isinstance(key, str):
167173
raise JSONPointerTypeError(f"{key}: {err}") from err
168174

169-
# `obj` is array-like
170-
# `key` is a string
171-
172175
if key == "-":
173176
# "-" is a valid index when appending to a JSON array with JSON Patch, but
174177
# not when resolving a JSON Pointer.
@@ -185,16 +188,6 @@ def _handle_type_error(self, obj: Any, key: Any, err: Exception) -> object:
185188
raise JSONPointerIndexError(f"index out of range: {_index}") from err
186189
return _index
187190

188-
# Try int index. Reject non-zero ints that start with a zero.
189-
index = self._index(key)
190-
if isinstance(index, int):
191-
return self._getitem(obj, index)
192-
193-
if re.match(r"-[0-9]+", index):
194-
raise JSONPointerIndexError(
195-
f"{key}: array indices must be positive integers or zero"
196-
) from err
197-
198191
raise JSONPointerTypeError(f"{key}: {err}") from err
199192

200193
def resolve(
@@ -354,13 +347,13 @@ def is_relative_to(self, other: JSONPointer) -> bool:
354347
)
355348

356349
def __eq__(self, other: object) -> bool:
357-
return isinstance(other, JSONPointer) and self.parts == other.parts
350+
return isinstance(other, self.__class__) and self.parts == other.parts
358351

359352
def __hash__(self) -> int:
360-
return hash(self.parts) # pragma: no cover
353+
return hash((self.__class__, self.parts)) # pragma: no cover
361354

362355
def __repr__(self) -> str:
363-
return f"JSONPointer({self._s!r})" # pragma: no cover
356+
return f"{self.__class__.__name__}({self._s!r})" # pragma: no cover
364357

365358
def exists(
366359
self, data: Union[str, IOBase, Sequence[object], Mapping[str, object]]
@@ -396,7 +389,7 @@ def parent(self) -> JSONPointer:
396389
if not self.parts:
397390
return self
398391
parent_parts = self.parts[:-1]
399-
return JSONPointer(
392+
return self.__class__(
400393
self._encode(parent_parts),
401394
parts=parent_parts,
402395
unicode_escape=False,
@@ -420,14 +413,13 @@ def __truediv__(self, other: object) -> JSONPointer:
420413

421414
other = self._unicode_escape(other.lstrip())
422415
if other.startswith("/"):
423-
return JSONPointer(other, unicode_escape=False, uri_decode=False)
416+
return self.__class__(other, unicode_escape=False, uri_decode=False)
424417

425418
parts = self.parts + tuple(
426-
self._index(p.replace("~1", "/").replace("~0", "~"))
427-
for p in other.split("/")
419+
p.replace("~1", "/").replace("~0", "~") for p in other.split("/")
428420
)
429421

430-
return JSONPointer(
422+
return self.__class__(
431423
self._encode(parts), parts=parts, unicode_escape=False, uri_decode=False
432424
)
433425

@@ -617,7 +609,7 @@ def to(
617609
raise RelativeJSONPointerIndexError(
618610
f"index offset out of range {new_index}"
619611
)
620-
parts[-1] = int(parts[-1]) + self.index
612+
parts[-1] = str(int(parts[-1]) + self.index)
621613

622614
# Pointer or index/property
623615
if isinstance(self.pointer, JSONPointer):

tests/test_cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,7 @@ def test_pointer_command_resolution_error(
377377

378378
captured = capsys.readouterr()
379379
assert err.value.code == 1
380-
assert captured.err.startswith("pointer key error 'foo'")
380+
assert captured.err.startswith("pointer key error: 'foo'")
381381

382382

383383
def test_pointer_command_resolution_error_debug(

tests/test_json_patch_rfc6902.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
3131
POSSIBILITY OF SUCH DAMAGE.
3232
"""
33+
3334
import copy
3435
import dataclasses
3536
import re
@@ -181,7 +182,7 @@ def test_test_op_failure() -> None:
181182
def test_add_to_nonexistent_target() -> None:
182183
patch = JSONPatch().add(path="/baz/bat", value="qux")
183184
with pytest.raises(
184-
JSONPatchError, match=re.escape("pointer key error 'baz' (add:0)")
185+
JSONPatchError, match=re.escape("pointer key error: 'baz' (add:0)")
185186
):
186187
patch.apply({"foo": "bar"})
187188

tests/test_json_pointer.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,11 @@ def test_resolve_with_default() -> None:
4040
assert pointer.resolve(data, default=None) is None
4141

4242

43-
def test_pointer_index_out_of_range() -> None:
44-
max_plus_one = JSONPointer.max_int_index + 1
45-
# min_minus_one = JSONPointer.min_int_index - 1
46-
47-
with pytest.raises(jsonpath.JSONPointerError):
48-
JSONPointer(f"/some/thing/{max_plus_one}")
49-
50-
# with pytest.raises(jsonpath.JSONPointerError):
51-
# JSONPointer(f"/some/thing/{min_minus_one}")
43+
def test_pointer_min_int_index() -> None:
44+
data = {"some": {"thing": [1, 2, 3]}}
45+
pointer = JSONPointer(f"/some/thing/{JSONPointer.min_int_index - 1}")
46+
with pytest.raises(jsonpath.JSONPointerIndexError):
47+
pointer.resolve(data)
5248

5349

5450
def test_resolve_int_key() -> None:
@@ -320,3 +316,16 @@ def test_trailing_slash() -> None:
320316
data = {"foo": {"": [1, 2, 3], " ": [4, 5, 6]}}
321317
assert JSONPointer("/foo/").resolve(data) == [1, 2, 3]
322318
assert JSONPointer("/foo/ ").resolve(data) == [4, 5, 6]
319+
320+
321+
def test_index_token_on_string_value() -> None:
322+
data = {"foo": "bar"}
323+
pointer = JSONPointer("/foo/1")
324+
with pytest.raises(JSONPointerTypeError):
325+
pointer.resolve(data)
326+
327+
328+
def test_index_like_token_on_object_value() -> None:
329+
data = {"foo": {"-1": "bar"}}
330+
pointer = JSONPointer("/foo/-1")
331+
assert pointer.resolve(data) == "bar"

0 commit comments

Comments
 (0)