Skip to content

Commit

Permalink
Instantiate correct [R]JP subclasses
Browse files Browse the repository at this point in the history
Always return an instance of the subclass where appropraite.
For RelativeJSONPointer, add a class property for the matching
JSONPointer subclass.
  • Loading branch information
handrews committed Jun 21, 2023
1 parent 3ff123b commit 5d90705
Show file tree
Hide file tree
Showing 3 changed files with 65 additions and 40 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ Features:

* ``JSONPointer`` and ``RelativeJSONPointer`` now have class attributes defining
the exceptions that they use, which can be overidden in subclasses
* ``RelativeJSONPointer`` now has a class attribute defining which matching
``JSONPointer`` subclass to use for the path component, which can be
overridden in subclasses
* ``JSONPointer`` and ``RelativeJSONPointer`` now instantiate the same subclass
as ``self`` when producing new instances such as when the ``/`` operator is used


v0.11.0 (2023-06-03)
Expand Down
54 changes: 32 additions & 22 deletions jschon/jsonpointer.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ class JSONPointer(Sequence[str]):
_json_pointer_re = re.compile(JSON_POINTER_RE)
_array_index_re = re.compile(JSON_INDEX_RE)

def __new__(cls, *values: Union[str, Iterable[str]]) -> JSONPointer:
def __new__(cls, *values: Union[str, Iterable[str]]) -> Type[JSONPointer]:
"""Create and return a new :class:`JSONPointer` instance, constructed by
the concatenation of the given `values`.
Expand Down Expand Up @@ -115,44 +115,44 @@ def __getitem__(self, index: int) -> str:
...

@overload
def __getitem__(self, index: slice) -> JSONPointer:
def __getitem__(self, index: slice) -> Type[JSONPointer]:
...

def __getitem__(self, index):
"""Return `self[index]`."""
if isinstance(index, int):
return self._keys[index]
if isinstance(index, slice):
return JSONPointer(self._keys[index])
return self.__class__(self._keys[index])
raise TypeError("Expecting int or slice")

def __len__(self) -> int:
"""Return `len(self)`."""
return len(self._keys)

@overload
def __truediv__(self, suffix: str) -> JSONPointer:
def __truediv__(self, suffix: str) -> Type[JSONPointer]:
...

@overload
def __truediv__(self, suffix: Iterable[str]) -> JSONPointer:
def __truediv__(self, suffix: Iterable[str]) -> Type[JSONPointer]:
...

def __truediv__(self, suffix) -> JSONPointer:
def __truediv__(self, suffix) -> Type[JSONPointer]:
"""Return `self / suffix`."""
if isinstance(suffix, str):
return JSONPointer(self, (suffix,))
return self.__class__(self, (suffix,))
if isinstance(suffix, Iterable):
return JSONPointer(self, suffix)
return self.__class__(self, suffix)
return NotImplemented

def __eq__(self, other: JSONPointer) -> bool:
def __eq__(self, other: Type[JSONPointer]) -> bool:
"""Return `self == other`."""
if isinstance(other, JSONPointer):
return self._keys == other._keys
return NotImplemented

def __le__(self, other: JSONPointer) -> bool:
def __le__(self, other: Type[JSONPointer]) -> bool:
"""Return `self <= other`.
Test whether self is a prefix of other, that is,
Expand All @@ -162,7 +162,7 @@ def __le__(self, other: JSONPointer) -> bool:
return self._keys == other._keys[:len(self._keys)]
return NotImplemented

def __lt__(self, other: JSONPointer) -> bool:
def __lt__(self, other: Type[JSONPointer]) -> bool:
"""Return `self < other`.
Test whether self is a proper prefix of other, that is,
Expand All @@ -182,7 +182,7 @@ def __str__(self) -> str:

def __repr__(self) -> str:
"""Return `repr(self)`."""
return f"JSONPointer({str(self)!r})"
return f"{self.__class__.__name__}({str(self)!r})"

def evaluate(self, document: Any) -> Any:
"""Return the value within `document` at the location referenced by `self`.
Expand Down Expand Up @@ -210,7 +210,7 @@ def resolve(value, keys):
if isjson and value.type == "array" or \
not isjson and isinstance(value, Sequence) and \
not isinstance(value, str) and \
JSONPointer._array_index_re.fullmatch(key):
self._array_index_re.fullmatch(key):
return resolve(value[int(key)], keys)

except (KeyError, IndexError):
Expand All @@ -221,7 +221,7 @@ def resolve(value, keys):
return resolve(document, collections.deque(self._keys))

@classmethod
def parse_uri_fragment(cls, value: str) -> JSONPointer:
def parse_uri_fragment(cls, value: str) -> Type[JSONPointer]:
"""Return a new :class:`JSONPointer` constructed from the :rfc:`6901`
string obtained by decoding `value`.
Expand All @@ -230,7 +230,7 @@ def parse_uri_fragment(cls, value: str) -> JSONPointer:
:param value: a percent-encoded URI fragment
"""
return JSONPointer(urllib.parse.unquote(value))
return cls(urllib.parse.unquote(value))

def uri_fragment(self) -> str:
"""Return a percent-encoded URI fragment representation of `self`.
Expand Down Expand Up @@ -283,6 +283,9 @@ class RelativeJSONPointer:
] = RelativeJSONPointerReferenceError
"""Exception raised when the Relative JSON Pointer cannot be resolved against a document."""

json_pointer_class: Type[JSONPointer] = JSONPointer
"""JSONPointer subclass used to implement the ``ref`` portion of the relative pointer."""

_regex = re.compile(RELATIVE_JSON_POINTER_RE)

def __new__(
Expand All @@ -292,8 +295,8 @@ def __new__(
*,
up: int = 0,
over: int = 0,
ref: Union[JSONPointer, Literal['#']] = JSONPointer(),
) -> RelativeJSONPointer:
ref: Union[Type[JSONPointer], Literal['#'], Literal['']] = '',
) -> Type[RelativeJSONPointer]:
"""Create and return a new :class:`RelativeJSONPointer` instance.
:param value: a relative JSON pointer-conformant string; if `value` is
Expand All @@ -310,18 +313,25 @@ def __new__(
self = object.__new__(cls)

if value is not None:
if not (match := RelativeJSONPointer._regex.fullmatch(value)):
if not (match := cls._regex.fullmatch(value)):
raise cls.malformed_exc(f"'{value}' is not a valid relative JSON pointer")

up, over, ref = match.group('up', 'over', 'ref')
self.up = int(up)
self.over = int(over) if over else 0
self.path = JSONPointer(ref) if ref != '#' else None
self.path = cls.json_pointer_class(ref) if ref != '#' else None

else:
self.up = up
self.over = over
self.path = ref if isinstance(ref, JSONPointer) else None
if isinstance(ref, cls.json_pointer_class):
self.path = ref
elif ref == '':
self.path = cls.json_pointer_class()
elif isinstance(ref, JSONPointer):
self.path = cls.json_pointer_class(str(ref))
else:
self.path = None

self.index = ref == '#'

Expand All @@ -332,7 +342,7 @@ def __new__(

return self

def __eq__(self, other: RelativeJSONPointer) -> bool:
def __eq__(self, other: Type[RelativeJSONPointer]) -> bool:
"""Return `self == other`."""
if isinstance(other, RelativeJSONPointer):
return (self.up == other.up and
Expand All @@ -353,7 +363,7 @@ def __str__(self) -> str:

def __repr__(self) -> str:
"""Return `repr(self)`."""
return f'RelativeJSONPointer({str(self)!r})'
return f'{self.__class__.__name__}({str(self)!r})'

def evaluate(self, document: JSON) -> Union[int, str, JSON]:
"""Return the value within `document` at the location referenced by `self`.
Expand Down
46 changes: 28 additions & 18 deletions tests/test_jsonpointer.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import pathlib
import re
from copy import copy
from typing import Dict, List, Union
from typing import Dict, List, Union, Type

import pytest
from hypothesis import example, given, strategies as hs
Expand Down Expand Up @@ -44,6 +44,7 @@ class RJPRefError(RelativeJSONPointerReferenceError):
class RJPtr(RelativeJSONPointer):
malformed_exc = RJPMalError
reference_exc = RJPRefError
json_pointer_class = JPtr


##################### End subclasses #########################
Expand Down Expand Up @@ -71,19 +72,20 @@ def jsonpointer_unescape(token: str):
return token.replace('~1', '/').replace('~0', '~')


@pytest.mark.parametrize('jp_cls', (JSONPointer, JPtr))
@given(hs.lists(jsonpointer | hs.lists(jsonpointer_key)))
def test_create_jsonpointer(values: List[Union[str, List[str]]]):
def test_create_jsonpointer(jp_cls: Type[JSONPointer], values: List[Union[str, List[str]]]):
keys = []
for value in values:
keys += [jsonpointer_unescape(token) for token in value.split('/')[1:]] if isinstance(value, str) else value

ptr0 = JSONPointer(*values)
assert ptr0 == (ptr1 := JSONPointer(*values))
assert ptr0 == (ptr2 := JSONPointer(keys))
assert ptr0 == (ptr3 := JSONPointer(ptr0))
assert ptr0 != (ptr4 := JSONPointer() if keys else JSONPointer('/'))
assert ptr0 != (ptr5 := JSONPointer('/', keys))
assert JSONPointer(ptr0, keys, *values) == JSONPointer(*values, keys, ptr0)
ptr0 = jp_cls(*values)
assert ptr0 == (ptr1 := jp_cls(*values))
assert ptr0 == (ptr2 := jp_cls(keys))
assert ptr0 == (ptr3 := jp_cls(ptr0))
assert ptr0 != (ptr4 := jp_cls() if keys else jp_cls('/'))
assert ptr0 != (ptr5 := jp_cls('/', keys))
assert jp_cls(ptr0, keys, *values) == jp_cls(*values, keys, ptr0)

ptrs = {ptr0, ptr1, ptr2, ptr3}
assert ptrs == {ptr0}
Expand All @@ -103,23 +105,27 @@ def test_malformed_jsonpointer(jp_cls):
assert exc_info.type == jp_cls.malformed_exc


@pytest.mark.parametrize('jp_cls', (JSONPointer, JPtr))
@given(jsonpointer, jsonpointer_key)
def test_extend_jsonpointer_one_key(value, newkey):
pointer = JSONPointer(value) / newkey
def test_extend_jsonpointer_one_key(jp_cls, value, newkey):
pointer = jp_cls(value) / newkey
newtoken = jsonpointer_escape(newkey)
assert type(pointer) == jp_cls
assert pointer[-1] == newkey
assert str(pointer) == f'{value}/{newtoken}'


@pytest.mark.parametrize('jp_cls', (JSONPointer, JPtr))
@given(jsonpointer, hs.lists(jsonpointer_key))
def test_extend_jsonpointer_multi_keys(value, newkeys):
pointer = (base_ptr := JSONPointer(value)) / newkeys
def test_extend_jsonpointer_multi_keys(jp_cls, value, newkeys):
pointer = (base_ptr := jp_cls(value)) / newkeys
assert type(pointer) == jp_cls
for i in range(len(newkeys)):
assert pointer[len(base_ptr) + i] == newkeys[i]
assert str(pointer) == value + ''.join(f'/{jsonpointer_escape(key)}' for key in newkeys)


def test_uri_fragment_safe_characters():
def test_uri_fragment_safe_characers():
pointer_str = "/!$&'()*+,;="
pointer = JSONPointer(pointer_str)
assert pointer.uri_fragment() == pointer_str
Expand Down Expand Up @@ -160,7 +166,7 @@ def test_evaluate_jsonpointer(jp_cls, value, testkey):

@given(jsonpointer, jsonpointer)
def test_compare_jsonpointer(value1, value2):
ptr1 = JSONPointer(value1)
ptr1 = JPtr(value1)
ptr2 = JSONPointer(value2)
assert ptr1 == JSONPointer(ptr1)
assert ptr1 <= JSONPointer(ptr1)
Expand All @@ -179,8 +185,9 @@ def test_compare_jsonpointer(value1, value2):
assert (ptr1 > ptr2) == (ptr1 >= ptr2 and ptr1 != ptr2)


@pytest.mark.parametrize('rjp_cls', (RelativeJSONPointer, RJPtr))
@given(relative_jsonpointer)
def test_create_relative_jsonpointer(value):
def test_create_relative_jsonpointer(rjp_cls, value):
match = re.fullmatch(relative_jsonpointer_regex, value)
up, over, ref = match.group('up', 'over', 'ref')
kwargs = dict(
Expand All @@ -189,11 +196,14 @@ def test_create_relative_jsonpointer(value):
ref=JSONPointer(ref) if ref != '#' else ref,
)

r1 = RelativeJSONPointer(value)
r2 = RelativeJSONPointer(**kwargs)
r1 = rjp_cls(value)
r2 = rjp_cls(**kwargs)
assert r1 == r2
assert str(r1) == value
assert eval(repr(r1)) == r1
if type(kwargs['ref']) == JSONPointer:
assert type(r1.path) == rjp_cls.json_pointer_class
assert type(r2.path) == rjp_cls.json_pointer_class

oldkwargs = copy(kwargs)
if up == '0':
Expand Down

0 comments on commit 5d90705

Please sign in to comment.