Skip to content

Commit 8cd8a86

Browse files
authored
feat: relative json pointers (#29)
feat: relative json pointers
1 parent d6e15a2 commit 8cd8a86

10 files changed

Lines changed: 409 additions & 15 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
- Implemented `JSONPointer.__truediv__()` to allow creation of child pointers from an existing pointer using the slash (`/`) operator.
1818
- Added `JSONPointer.join()`, a method for creating child pointers. This is equivalent to using the slash (`/`) operator for each argument given to `join()`.
1919
- Added `JSONPointer.exists()`, a method that returns `True` if a the pointer can be resolved against some data, or `False` otherwise.
20+
- Added the `RelativeJSONPointer` class for building new `JSONPointer` instances from Relative JSON Pointer syntax.
21+
- Added support for a non-standard index/property pointer using `#<property or index>`. This is to support Relative JSON Pointer's use of hash (`#`) when building `JSONPointer` instances from relative JSON Pointers.
2022

2123
## Version 0.8.1
2224

docs/api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,8 @@
1414
::: jsonpath.JSONPointer
1515
handler: python
1616

17+
::: jsonpath.RelativeJSONPointer
18+
handler: python
19+
1720
::: jsonpath.JSONPatch
1821
handler: python

docs/pointers.md

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ print(pointer.join("baz", "0")) # /foo/bar/baz/0
8585

8686
**_New in version 0.9.0_**
8787

88-
Return this pointer's parent, as a new `JSONPointer`. If this pointer points to the document root, _self_ is returned.
88+
Return this pointer's parent as a new `JSONPointer`. If this pointer points to the document root, _self_ is returned.
8989

9090
```python
9191
from jsonpath import JSONPointer
@@ -97,7 +97,7 @@ print(pointer.parent()) # /foo
9797

9898
## `is_relative_to(pointer)`
9999

100-
Return _True_ if this pointer points to a child of the argument pointer, which must be a `JSONPointer` instance, or _False_ otherwise
100+
Return _True_ if this pointer points to a child of the argument pointer, which must be a `JSONPointer` instance.
101101

102102
```python
103103
from jsonpath import JSONPointer
@@ -111,11 +111,38 @@ another_pointer = JSONPointer("/foo/baz")
111111
print(another_pointer.is_relative_to(pointer)) # False
112112
```
113113

114-
## `relative(relative_pointer)`
114+
## `to(rel)`
115115

116116
**_New in version 0.9.0_**
117117

118-
TODO:
118+
Return a new `JSONPointer` relative to this pointer. _rel_ should be a [`RelativeJSONPointer`](api.md#jsonpath.RelativeJSONPointer) instance or a string following [Relative JSON Pointer](https://www.ietf.org/id/draft-hha-relative-json-pointer-00.html) syntax.
119+
120+
```python
121+
from jsonpath import JSONPointer
122+
123+
data = {"foo": {"bar": [1, 2, 3], "baz": [4, 5, 6]}}
124+
pointer = JSONPointer("/foo/bar/2")
125+
126+
print(pointer.resolve(data)) # 3
127+
print(pointer.to("0-1").resolve(data)) # 2
128+
print(pointer.to("2/baz/2").resolve(data)) # 6
129+
```
130+
131+
A `RelativeJSONPointer` can be instantiated for repeated application to multiple different pointers.
132+
133+
```python
134+
from jsonpath import JSONPointer
135+
from jsonpath import RelativeJSONPointer
136+
137+
data = {"foo": {"bar": [1, 2, 3], "baz": [4, 5, 6], "some": "thing"}}
138+
139+
some_pointer = JSONPointer("/foo/bar/0")
140+
another_pointer = JSONPointer("/foo/baz/2")
141+
rel = RelativeJSONPointer("2/some")
142+
143+
print(rel.to(some_pointer).resolve(data)) # thing
144+
print(rel.to(another_pointer).resolve(data)) # thing
145+
```
119146

120147
## Slash Operator
121148

jsonpath/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
from .exceptions import JSONPointerKeyError
1414
from .exceptions import JSONPointerResolutionError
1515
from .exceptions import JSONPointerTypeError
16+
from .exceptions import RelativeJSONPointerError
17+
from .exceptions import RelativeJSONPointerIndexError
18+
from .exceptions import RelativeJSONPointerSyntaxError
1619
from .filter import UNDEFINED
1720
from .lex import Lexer
1821
from .match import JSONPathMatch
@@ -21,6 +24,7 @@
2124
from .path import CompoundJSONPath
2225
from .path import JSONPath
2326
from .pointer import JSONPointer
27+
from .pointer import RelativeJSONPointer
2428
from .pointer import resolve
2529

2630
__all__ = (
@@ -48,6 +52,10 @@
4852
"Lexer",
4953
"match",
5054
"Parser",
55+
"RelativeJSONPointer",
56+
"RelativeJSONPointerError",
57+
"RelativeJSONPointerIndexError",
58+
"RelativeJSONPointerSyntaxError",
5159
"resolve",
5260
"UNDEFINED",
5361
)

jsonpath/exceptions.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,31 @@ def __str__(self) -> str:
109109
return f"pointer type error {super().__str__()}"
110110

111111

112+
class RelativeJSONPointerError(Exception):
113+
"""Base class for all Relative JSON Pointer errors."""
114+
115+
116+
class RelativeJSONPointerIndexError(RelativeJSONPointerError):
117+
"""An exception raised when modifying a pointer index out of range."""
118+
119+
120+
class RelativeJSONPointerSyntaxError(RelativeJSONPointerError):
121+
"""An exception raised when we fail to parse a relative JSON Pointer."""
122+
123+
def __init__(self, msg: str, rel: str) -> None:
124+
super().__init__(msg)
125+
self.rel = rel
126+
127+
def __str__(self) -> str:
128+
if not self.rel:
129+
return super().__str__()
130+
131+
msg = self.rel[:7]
132+
if len(msg) == 6: # noqa: PLR2004
133+
msg += ".."
134+
return f"{super().__str__()} {msg!r}"
135+
136+
112137
class JSONPatchError(Exception):
113138
"""Base class for all JSON Patch errors."""
114139

jsonpath/pointer.py

Lines changed: 204 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import codecs
55
import json
6+
import re
67
from functools import reduce
78
from io import IOBase
89
from operator import getitem
@@ -15,11 +16,13 @@
1516
from typing import Union
1617
from urllib.parse import unquote
1718

18-
from .exceptions import JSONPointerError
19-
from .exceptions import JSONPointerIndexError
20-
from .exceptions import JSONPointerKeyError
21-
from .exceptions import JSONPointerResolutionError
22-
from .exceptions import JSONPointerTypeError
19+
from jsonpath.exceptions import JSONPointerError
20+
from jsonpath.exceptions import JSONPointerIndexError
21+
from jsonpath.exceptions import JSONPointerKeyError
22+
from jsonpath.exceptions import JSONPointerResolutionError
23+
from jsonpath.exceptions import JSONPointerTypeError
24+
from jsonpath.exceptions import RelativeJSONPointerIndexError
25+
from jsonpath.exceptions import RelativeJSONPointerSyntaxError
2326

2427
if TYPE_CHECKING:
2528
from .match import JSONPathMatch
@@ -125,22 +128,30 @@ def _getitem(self, obj: Any, key: Any) -> Any: # noqa: PLR0912
125128
return getitem(obj, str(key))
126129
except KeyError:
127130
raise JSONPointerKeyError(key) from err
128-
# Handle non-standard keys selector
131+
# Handle non-standard keys/property selector/pointer.
129132
if (
130133
isinstance(key, str)
131134
and isinstance(obj, Mapping)
132-
and key.startswith(self.keys_selector)
135+
and key.startswith((self.keys_selector, "#"))
133136
and key[1:] in obj
134137
):
135138
return key[1:]
139+
# Handle non-standard index/property pointer (`#`)
136140
raise JSONPointerKeyError(key) from err
137141
except TypeError as err:
138-
if isinstance(obj, Sequence):
142+
if isinstance(obj, Sequence) and not isinstance(obj, str):
139143
if key == "-":
140144
# "-" is a valid index when appending to a JSON array
141145
# with JSON Patch, but not when resolving a JSON Pointer.
142146
raise JSONPointerIndexError("index out of range") from None
143-
147+
# Handle non-standard index pointer.
148+
if isinstance(key, str) and key.startswith("#"):
149+
_index = int(key[1:])
150+
if _index >= len(obj):
151+
raise JSONPointerIndexError(
152+
f"index out of range: {_index}"
153+
) from err
154+
return _index
144155
# Try int index. Reject non-zero ints that start with a zero.
145156
if isinstance(key, str):
146157
index = self._index(key)
@@ -151,7 +162,7 @@ def _getitem(self, obj: Any, key: Any) -> Any: # noqa: PLR0912
151162
raise JSONPointerIndexError(
152163
f"index out of range: {key}"
153164
) from index_err
154-
raise JSONPointerTypeError(f"pointer type error: {key}: {err}") from err
165+
raise JSONPointerTypeError(f"{key}: {err}") from err
155166
except IndexError as err:
156167
raise JSONPointerIndexError(f"index out of range: {key}") from err
157168

@@ -408,6 +419,189 @@ def join(self, *parts: str) -> JSONPointer:
408419
pointer = pointer / part
409420
return pointer
410421

422+
def to(
423+
self,
424+
rel: Union[RelativeJSONPointer, str],
425+
*,
426+
unicode_escape: bool = True,
427+
uri_decode: bool = False,
428+
) -> JSONPointer:
429+
"""Return a new pointer relative to this pointer.
430+
431+
Args:
432+
rel: A `RelativeJSONPointer` or a string following "Relative JSON
433+
Pointer" syntax.
434+
unicode_escape: If `True`, UTF-16 escape sequences will be decoded
435+
before parsing the pointer.
436+
uri_decode: If `True`, the pointer will be unescaped using _urllib_
437+
before being parsed.
438+
439+
See https://www.ietf.org/id/draft-hha-relative-json-pointer-00.html
440+
"""
441+
relative_pointer = (
442+
RelativeJSONPointer(
443+
rel, unicode_escape=unicode_escape, uri_decode=uri_decode
444+
)
445+
if isinstance(rel, str)
446+
else rel
447+
)
448+
449+
return relative_pointer.to(self)
450+
451+
452+
RE_RELATIVE_POINTER = re.compile(
453+
r"(?P<ORIGIN>\d+)(?P<INDEX_G>(?P<SIGN>[+\-])(?P<INDEX>\d))?(?P<POINTER>.*)",
454+
re.DOTALL,
455+
)
456+
457+
458+
class RelativeJSONPointer:
459+
"""A Relative JSON Pointer.
460+
461+
See https://www.ietf.org/id/draft-hha-relative-json-pointer-00.html
462+
463+
Args:
464+
rel: A string following Relative JSON Pointer syntax.
465+
unicode_escape: If `True`, UTF-16 escape sequences will be decoded
466+
before parsing the pointer.
467+
uri_decode: If `True`, the pointer will be unescaped using _urllib_
468+
before being parsed.
469+
"""
470+
471+
__slots__ = ("origin", "index", "pointer")
472+
473+
def __init__(
474+
self,
475+
rel: str,
476+
*,
477+
unicode_escape: bool = True,
478+
uri_decode: bool = False,
479+
) -> None:
480+
self.origin, self.index, self.pointer = self._parse(
481+
rel, unicode_escape=unicode_escape, uri_decode=uri_decode
482+
)
483+
484+
def __str__(self) -> str:
485+
sign = "+" if self.index > 0 else ""
486+
index = "" if self.index == 0 else f"{sign}{self.index}"
487+
return f"{self.origin}{index}{self.pointer}"
488+
489+
def __eq__(self, __value: object) -> bool:
490+
return isinstance(__value, RelativeJSONPointer) and str(self) == str(__value)
491+
492+
def _parse(
493+
self,
494+
rel: str,
495+
*,
496+
unicode_escape: bool = True,
497+
uri_decode: bool = False,
498+
) -> Tuple[int, int, Union[JSONPointer, str]]:
499+
rel = rel.lstrip()
500+
match = RE_RELATIVE_POINTER.match(rel)
501+
if not match:
502+
raise RelativeJSONPointerSyntaxError("", rel)
503+
504+
# Steps to move
505+
origin = self._zero_or_positive(match.group("ORIGIN"), rel)
506+
507+
# Optional index manipulation
508+
if match.group("INDEX_G"):
509+
index = self._zero_or_positive(match.group("INDEX"), rel)
510+
if index == 0:
511+
raise RelativeJSONPointerSyntaxError("index offset can't be zero", rel)
512+
if match.group("SIGN") == "-":
513+
index = -index
514+
else:
515+
index = 0
516+
517+
# Pointer or '#'. Empty string is OK.
518+
_pointer = match.group("POINTER").strip()
519+
pointer = (
520+
JSONPointer(
521+
_pointer,
522+
unicode_escape=unicode_escape,
523+
uri_decode=uri_decode,
524+
)
525+
if _pointer != "#"
526+
else _pointer
527+
)
528+
529+
return (origin, index, pointer)
530+
531+
def _zero_or_positive(self, s: str, rel: str) -> int:
532+
# TODO: accept start and stop index for better error messages
533+
if s.startswith("0") and len(s) > 1:
534+
raise RelativeJSONPointerSyntaxError("unexpected leading zero", rel)
535+
try:
536+
return int(s)
537+
except ValueError as err:
538+
raise RelativeJSONPointerSyntaxError(
539+
"expected positive int or zero", rel
540+
) from err
541+
542+
def _int_like(self, obj: Any) -> bool:
543+
if isinstance(obj, int):
544+
return True
545+
try:
546+
int(obj)
547+
except ValueError:
548+
return False
549+
return True
550+
551+
def to(
552+
self,
553+
pointer: Union[JSONPointer, str],
554+
*,
555+
unicode_escape: bool = True,
556+
uri_decode: bool = False,
557+
) -> JSONPointer:
558+
"""Return a new JSONPointer relative to _pointer_.
559+
560+
Args:
561+
pointer: A `JSONPointer` instance or a string following JSON
562+
Pointer syntax.
563+
unicode_escape: If `True`, UTF-16 escape sequences will be decoded
564+
before parsing the pointer.
565+
uri_decode: If `True`, the pointer will be unescaped using _urllib_
566+
before being parsed.
567+
"""
568+
_pointer = (
569+
JSONPointer(pointer, unicode_escape=unicode_escape, uri_decode=uri_decode)
570+
if isinstance(pointer, str)
571+
else pointer
572+
)
573+
574+
# Move to origin
575+
if self.origin > len(_pointer.parts):
576+
raise RelativeJSONPointerIndexError(
577+
f"origin ({self.origin}) exceeds root ({len(_pointer.parts)})"
578+
)
579+
580+
if self.origin < 1:
581+
parts = list(_pointer.parts)
582+
else:
583+
parts = list(_pointer.parts[: -self.origin])
584+
585+
# Array index offset
586+
if self.index and parts and self._int_like(parts[-1]):
587+
new_index = int(parts[-1]) + self.index
588+
if new_index < 0:
589+
raise RelativeJSONPointerIndexError(
590+
f"index offset out of range {new_index}"
591+
)
592+
parts[-1] = int(parts[-1]) + self.index
593+
594+
# Pointer or index/property
595+
if isinstance(self.pointer, JSONPointer):
596+
parts.extend(self.pointer.parts)
597+
else:
598+
assert self.pointer == "#"
599+
parts[-1] = f"#{parts[-1]}"
600+
601+
return JSONPointer.from_parts(
602+
parts, unicode_escape=unicode_escape, uri_decode=uri_decode
603+
)
604+
411605

412606
def resolve(
413607
pointer: Union[str, Iterable[Union[str, int]]],

0 commit comments

Comments
 (0)