Skip to content

Commit

Permalink
Merge pull request #104 from handrews/ptr-ext
Browse files Browse the repository at this point in the history
Allow changing [Relative]JSONPointer exceptions in subclasses
  • Loading branch information
marksparkza committed Jun 8, 2023
2 parents 9ed5699 + 2cd7088 commit eff73d8
Show file tree
Hide file tree
Showing 3 changed files with 141 additions and 40 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ Changelog

v0.11.1 (in development)
------------------------
Features:

* ``JSONPointer`` and ``RelativeJSONPointer`` now have class attributes defining
the exceptions that they use, which can be overidden in subclasses


v0.11.0 (2023-06-03)
Expand Down
46 changes: 36 additions & 10 deletions jschon/jsonpointer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import collections
import re
import urllib.parse
from typing import Any, Iterable, Literal, Mapping, Sequence, TYPE_CHECKING, Union, overload
from typing import (
Any, Iterable, Literal, Mapping, Sequence, Type, TYPE_CHECKING, Union, overload,
)

from jschon.exc import (
JSONPointerMalformedError,
Expand Down Expand Up @@ -70,6 +72,12 @@ class JSONPointer(Sequence[str]):
and referred to as *keys* in the JSONPointer class.
"""

malformed_exc: Type[JSONPointerMalformedError] = JSONPointerMalformedError
"""Exception raised when the input is not a valid JSON Pointer."""

reference_exc: Type[JSONPointerReferenceError] = JSONPointerReferenceError
"""Exception raised when the JSON Pointer cannot be resolved against a document."""

_json_pointer_re = re.compile(JSON_POINTER_RE)
_array_index_re = re.compile(JSON_INDEX_RE)

Expand All @@ -88,7 +96,7 @@ def __new__(cls, *values: Union[str, Iterable[str]]) -> JSONPointer:
for value in values:
if isinstance(value, str):
if not JSONPointer._json_pointer_re.fullmatch(value):
raise JSONPointerMalformedError(f"'{value}' is not a valid JSON pointer")
raise cls.malformed_exc(f"'{value}' is not a valid JSON pointer")
self._keys.extend(self.unescape(token) for token in value.split('/')[1:])

elif isinstance(value, JSONPointer):
Expand Down Expand Up @@ -208,7 +216,7 @@ def resolve(value, keys):
except (KeyError, IndexError):
pass

raise JSONPointerReferenceError(f"Path '{self}' not found in document")
raise self.reference_exc(f"Path '{self}' not found in document")

return resolve(document, collections.deque(self._keys))

Expand Down Expand Up @@ -266,6 +274,15 @@ def unescape(token: str) -> str:


class RelativeJSONPointer:
malformed_exc: Type[
RelativeJSONPointerMalformedError
] = RelativeJSONPointerMalformedError
"""Exception raised when the input is not a valid Relative JSON Pointer."""
reference_exc: Type[
RelativeJSONPointerReferenceError
] = RelativeJSONPointerReferenceError
"""Exception raised when the Relative JSON Pointer cannot be resolved against a document."""

_regex = re.compile(RELATIVE_JSON_POINTER_RE)

def __new__(
Expand Down Expand Up @@ -294,7 +311,7 @@ def __new__(

if value is not None:
if not (match := RelativeJSONPointer._regex.fullmatch(value)):
raise RelativeJSONPointerMalformedError(f"'{value}' is not a valid relative JSON pointer")
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)
Expand Down Expand Up @@ -339,28 +356,37 @@ def __repr__(self) -> str:
return f'RelativeJSONPointer({str(self)!r})'

def evaluate(self, document: JSON) -> Union[int, str, JSON]:
"""Return the value within `document` at the location referenced by `self`.
`document` must be an instance of :class:`JSON`, as evaluation relies
on parent and sibling links provided by that class.
:param document: a :class:`JSON` instance representing the document
:raise RelativeJSONPointerReferenceError: if `self` references a non-existent
location in `document`
"""
node = document
for _ in range(self.up):
if node.parent is None:
raise RelativeJSONPointerReferenceError('Up too many levels')
raise self.reference_exc('Up too many levels')
node = node.parent

if self.over:
if node.parent is None:
raise RelativeJSONPointerReferenceError('No containing node for index adjustment')
raise self.reference_exc('No containing node for index adjustment')
if node.parent.type != "array":
raise RelativeJSONPointerReferenceError(f'Index adjustment not valid for type {node.parent.type}')
raise self.reference_exc(f'Index adjustment not valid for type {node.parent.type}')
adjusted = int(node.key) + self.over
if adjusted < 0 or adjusted >= len(node.parent):
raise RelativeJSONPointerReferenceError(f'Index adjustment out of range')
raise self.reference_exc(f'Index adjustment out of range')
node = node.parent[adjusted]

if self.index:
if node.parent is None:
raise RelativeJSONPointerReferenceError('No containing node')
raise self.reference_exc('No containing node')
return int(node.key) if node.parent.type == "array" else node.key

try:
return self.path.evaluate(node)
except JSONPointerReferenceError as e:
raise RelativeJSONPointerReferenceError from e
raise self.reference_exc from e
131 changes: 101 additions & 30 deletions tests/test_jsonpointer.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,51 @@
from typing import Dict, List, Union

import pytest
from hypothesis import given, strategies as hs
from hypothesis import example, given, strategies as hs

from jschon import JSON, JSONCompatible, JSONPointer, RelativeJSONPointer
from jschon.exc import JSONPointerReferenceError, RelativeJSONPointerReferenceError
from jschon.exc import (
JSONPointerMalformedError, JSONPointerReferenceError,
RelativeJSONPointerMalformedError, RelativeJSONPointerReferenceError,
)
from jschon.utils import json_loadf
from tests.strategies import json, jsonpointer, jsonpointer_key, relative_jsonpointer, relative_jsonpointer_regex


##############################################################
# These subclasses exist to test the ability to override the #
# exception classes used in [Relative]JSONPointer subclasses #
##############################################################

class JPMalError(JSONPointerMalformedError):
pass


class JPRefError(JSONPointerReferenceError):
pass


class JPtr(JSONPointer):
malformed_exc = JPMalError
reference_exc = JPRefError


class RJPMalError(RelativeJSONPointerMalformedError):
pass


class RJPRefError(RelativeJSONPointerReferenceError):
pass


class RJPtr(RelativeJSONPointer):
malformed_exc = RJPMalError
reference_exc = RJPRefError


##################### End subclasses #########################


def generate_jsonpointers(
targets: Dict[str, JSONCompatible],
target: JSONCompatible,
Expand Down Expand Up @@ -59,6 +96,13 @@ def test_create_jsonpointer(values: List[Union[str, List[str]]]):
assert eval(repr(ptr0)) == ptr0


@pytest.mark.parametrize('jp_cls', (JSONPointer, JPtr))
def test_malformed_jsonpointer(jp_cls):
with pytest.raises(jp_cls.malformed_exc) as exc_info:
jp_cls('0/foo')
assert exc_info.type == jp_cls.malformed_exc


@given(jsonpointer, jsonpointer_key)
def test_extend_jsonpointer_one_key(value, newkey):
pointer = JSONPointer(value) / newkey
Expand All @@ -75,30 +119,43 @@ def test_extend_jsonpointer_multi_keys(value, newkeys):
assert str(pointer) == value + ''.join(f'/{jsonpointer_escape(key)}' for key in newkeys)


def test_uri_fragment_safe_characters():
pointer_str = "/!$&'()*+,;="
pointer = JSONPointer(pointer_str)
assert pointer.uri_fragment() == pointer_str


@pytest.mark.parametrize('jp_cls', (JSONPointer, JPtr))
@given(json, jsonpointer_key)
def test_evaluate_jsonpointer(value, testkey):
assert JSONPointer().evaluate(value) == value
assert JSONPointer().evaluate(JSON(value)) == value
@example('~', '')
def test_evaluate_jsonpointer(jp_cls, value, testkey):
assert jp_cls().evaluate(value) == value
assert jp_cls().evaluate(JSON(value)) == value

generate_jsonpointers(resolved_pointers := {}, value)
for pointer, target in resolved_pointers.items():
assert JSONPointer(pointer).evaluate(value) == target
assert JSONPointer(pointer).evaluate(JSON(value)) == target
assert jp_cls(pointer).evaluate(value) == target
assert jp_cls(pointer).evaluate(JSON(value)) == target

if isinstance(value, list):
with pytest.raises(JSONPointerReferenceError):
JSONPointer(f'/{len(value)}').evaluate(value)
with pytest.raises(JSONPointerReferenceError):
JSONPointer('/-').evaluate(value)
with pytest.raises(JSONPointerReferenceError):
JSONPointer('/').evaluate(value)
with pytest.raises(jp_cls.reference_exc) as exc_info:
jp_cls(f'/{len(value)}').evaluate(value)
assert exc_info.type == jp_cls.reference_exc
with pytest.raises(jp_cls.reference_exc) as exc_info:
jp_cls('/-').evaluate(value)
assert exc_info.type == jp_cls.reference_exc
with pytest.raises(jp_cls.reference_exc) as exc_info:
jp_cls('/').evaluate(value)
assert exc_info.type == jp_cls.reference_exc
elif isinstance(value, dict):
if testkey not in value:
with pytest.raises(JSONPointerReferenceError):
JSONPointer(f'/{jsonpointer_escape(testkey)}').evaluate(value)
with pytest.raises(jp_cls.reference_exc) as exc_info:
jp_cls(f'/{jsonpointer_escape(testkey)}').evaluate(value)
assert exc_info.type == jp_cls.reference_exc
else:
with pytest.raises(JSONPointerReferenceError):
JSONPointer(f'/{value}').evaluate(value)
with pytest.raises(jp_cls.reference_exc) as exc_info:
jp_cls('/foo').evaluate(value)
assert exc_info.type == jp_cls.reference_exc


@given(jsonpointer, jsonpointer)
Expand Down Expand Up @@ -149,6 +206,13 @@ def test_create_relative_jsonpointer(value):
assert r1 == RelativeJSONPointer(**kwargs)


@pytest.mark.parametrize('rjp_cls', (RelativeJSONPointer, RJPtr))
def test_malformed_relative_jsonpointer(rjp_cls):
with pytest.raises(rjp_cls.malformed_exc) as exc_info:
rjp_cls('/bar')
assert exc_info.type == rjp_cls.malformed_exc


# Examples from:
# https://datatracker.ietf.org/doc/html/draft-handrews-relative-json-pointer-01#section-5.1
# https://gist.github.com/geraintluff/5911303
Expand All @@ -158,31 +222,38 @@ def test_create_relative_jsonpointer(value):

def pytest_generate_tests(metafunc):
if metafunc.definition.name == 'test_evaluate_relative_jsonpointer':
argnames = ('data', 'start', 'ref', 'result')
argnames = ('jp_cls', 'rjp_cls', 'data', 'start', 'ref', 'result')
argvalues = []
examples = json_loadf(example_file)
for example in examples:
for test in example['tests']:
argvalues += [pytest.param(
example['data'],
test['start'],
test['ref'],
test['result'],
)]
for jp_cls, rjp_cls in (
(JSONPointer, RelativeJSONPointer),
(JPtr, RJPtr),
):
for example in examples:
for test in example['tests']:
argvalues += [pytest.param(
jp_cls,
rjp_cls,
example['data'],
test['start'],
test['ref'],
test['result'],
)]
metafunc.parametrize(argnames, argvalues)


def test_evaluate_relative_jsonpointer(data, start, ref, result):
def test_evaluate_relative_jsonpointer(jp_cls, rjp_cls, data, start, ref, result):
data = JSON(data)
start = JSONPointer(start)
ref = RelativeJSONPointer(ref)
start = jp_cls(start)
ref = rjp_cls(ref)
node = start.evaluate(data)

if result == '<data>':
result = data
elif result == '<fail>':
with pytest.raises(RelativeJSONPointerReferenceError):
with pytest.raises(rjp_cls.reference_exc) as exc_info:
ref.evaluate(node)
assert exc_info.type == rjp_cls.reference_exc
return

value = ref.evaluate(node)
Expand Down

0 comments on commit eff73d8

Please sign in to comment.