diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 57b1c2e..4494d02 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -15,6 +15,18 @@ jobs: python-version: "3.8" - os: windows-latest python-version: "3.8" + - os: macos-latest + python-version: "3.9" + - os: windows-latest + python-version: "3.9" + - os: macos-latest + python-version: "3.10" + - os: windows-latest + python-version: "3.10" + - os: macos-latest + python-version: "3.11" + - os: windows-latest + python-version: "3.11" steps: - uses: actions/checkout@v3 with: diff --git a/docs/exceptions.md b/docs/exceptions.md index 22f4098..99f7076 100644 --- a/docs/exceptions.md +++ b/docs/exceptions.md @@ -20,9 +20,6 @@ Each of the following exceptions has a `token` property, referencing the [`Token ::: jsonpath.JSONPointerError handler: python -::: jsonpath.JSONPointerEncodeError - handler: python - ::: jsonpath.JSONPointerResolutionError handler: python diff --git a/docs/pointers.md b/docs/pointers.md index 6f8738a..aab6934 100644 --- a/docs/pointers.md +++ b/docs/pointers.md @@ -2,13 +2,40 @@ **_New in version 0.8.0_** -JSON Pointer ([RFC 6901](https://datatracker.ietf.org/doc/html/rfc6901)) is a string syntax for targeting a single value (JSON object, array or scalar) in a JSON document. Whereas a JSONPath has the potential to yield many values from a JSON document, a JSON Pointer can _resolve_ to at most one value. +JSON Pointer ([RFC 6901](https://datatracker.ietf.org/doc/html/rfc6901)) is a string syntax for targeting a single value (JSON object, array, or scalar) within a JSON document. Unlike a JSONPath expression, which can yield multiple values, a JSON Pointer resolves to **at most one value**. -JSON Pointers are a fundamental part of JSON Patch ([RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902)). Each patch operation must have at least one pointer, identifying the target value. +JSON Pointers are a fundamental component of JSON Patch ([RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902)), where each patch operation must have at least one pointer identifying the target location to modify. -!!! note +??? note "Extensions to RFC 6901" - We have extended RFC 6901 to handle our non-standard JSONPath [keys selector](syntax.md#keys-or) and index/property pointers from [Relative JSON Pointer](#torel). + We have extended RFC 6901 to support: + + - Interoperability with the JSONPath [keys selector](syntax.md#keys-or) (`~`) + - A special non-standard syntax for targeting **keys or indices themselves**, used in conjunction with [Relative JSON Pointer](#torel) + + **Keys Selector Compatibility** + + The JSONPath **keys selector** (`.~` or `[~]`) allows expressions to target the *keys* of an object, rather than their associated values. To maintain compatibility when translating between JSONPath and JSON Pointer, our implementation includes special handling for this selector. + + While standard JSON Pointers always refer to values, we ensure that paths derived from expressions like `$.categories.~` can be represented in our pointer system. This is especially important when converting from JSONPath to JSON Pointer or when evaluating expressions that mix value and key access. + + **Key/Index Pointers (`#`)** + + This non-standard pointer form represents **keys or indices themselves**, not the values they map to. Examples: + + - `#foo` points to the object key `"foo"` (not the value at `"foo"`) + - `#0` points to the index `0` of an array (not the value at that index) + + This syntax is introduced to support the full capabilities of [Relative JSON Pointer](#torel), which allows references to both values and the *keys or indices* that identify them. To ensure that any `RelativeJSONPointer` can be losslessly converted into a `JSONPointer`, we use the `#` form to represent these special cases. + + #### Example + + ```python + from jsonpath import RelativeJSONPointer + + rjp = RelativeJSONPointer("1#") + print(repr(rjp.to("/items/0/name"))) # JSONPointer('/items/#0') + ``` ## `resolve(data)` diff --git a/docs/quickstart.md b/docs/quickstart.md index 003344b..bae1781 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -4,9 +4,22 @@ This page gets you started using JSONPath, JSON Pointer and JSON Patch wih Pytho ## `findall(path, data)` -Find all objects matching a JSONPath with [`jsonpath.findall()`](api.md#jsonpath.JSONPathEnvironment.findall). It takes, as arguments, a JSONPath string and some _data_ object. It always returns a list of objects selected from _data_, never a scalar value. +Find all values matching a JSONPath expression using [`jsonpath.findall()`](api.md#jsonpath.JSONPathEnvironment.findall). -_data_ can be a file-like object or string containing JSON formatted data, or a Python [`Mapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Mapping) or [`Sequence`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Sequence), like a dictionary or list. In this example we select user names from a dictionary containing a list of user dictionaries. +This function takes two arguments: + +- `path`: a JSONPath expression as a string (e.g., `"$.users[*].name"`) +- `data`: the JSON document to query + +It always returns a **list** of matched values, even if the path resolves to a single result or nothing at all. + +The `data` argument can be: + +- A Python [`Mapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Mapping) (e.g., `dict`) or [`Sequence`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Sequence) (e.g., `list`) +- A JSON-formatted string +- A file-like object containing JSON + +For example, the following query extracts all user names from a dictionary containing a list of user objects: ```python import jsonpath @@ -173,7 +186,18 @@ if match: **_New in version 0.8.0_** -Resolve a JSON Pointer ([RFC 6901](https://datatracker.ietf.org/doc/html/rfc6901)) against some data. A JSON Pointer references a single object on a specific "path" in a JSON document. Here, _pointer_ can be a string representation of a JSON Pointer or a list of parts that make up a pointer. _data_ can be a file-like object or string containing JSON formatted data, or equivalent Python objects. +Resolves a JSON Pointer ([RFC 6901](https://datatracker.ietf.org/doc/html/rfc6901)) against a JSON document, returning the value located at the specified path. + +The `pointer` argument can be either: + +- A string representation of a JSON Pointer (e.g., `"/foo/bar/0"`) +- A list of unescaped pointer segments (e.g., `["foo", "bar", "0"]`) + +The `data` argument can be: + +- A Python data structure (`dict`, `list`, etc.) +- A JSON-formatted string +- A file-like object containing JSON ```python from jsonpath import pointer @@ -206,7 +230,15 @@ jane_score = pointer.resolve(["users", 3, "score"], data) print(jane_score) # 55 ``` -If the pointer can't be resolved against the target JSON document - due to missing keys/properties or out of range indices - a `JSONPointerIndexError`, `JSONPointerKeyError` or `JSONPointerTypeError` will be raised, each of which inherit from `JSONPointerResolutionError`. A default value can be given, which will be returned in the event of a `JSONPointerResolutionError`. +If the pointer cannot be resolved against the target JSON data — due to a missing key, an out-of-range index, or an unexpected data type — an exception will be raised: + +- `JSONPointerKeyError` – when a referenced key is missing from an object +- `JSONPointerIndexError` – when an array index is out of bounds +- `JSONPointerTypeError` – when a path segment expects the wrong type (e.g., indexing into a non-array) + +All of these exceptions are subclasses of `JSONPointerResolutionError`. + +You can optionally provide a `default` value to `resolve()`, which will be returned instead of raising an error if the pointer cannot be resolved. ```python from jsonpath import pointer diff --git a/jsonpath/__init__.py b/jsonpath/__init__.py index 2a34e01..2604d4a 100644 --- a/jsonpath/__init__.py +++ b/jsonpath/__init__.py @@ -10,7 +10,6 @@ from .exceptions import JSONPathNameError from .exceptions import JSONPathSyntaxError from .exceptions import JSONPathTypeError -from .exceptions import JSONPointerEncodeError from .exceptions import JSONPointerError from .exceptions import JSONPointerIndexError from .exceptions import JSONPointerKeyError @@ -52,7 +51,6 @@ "JSONPathSyntaxError", "JSONPathTypeError", "JSONPointer", - "JSONPointerEncodeError", "JSONPointerError", "JSONPointerIndexError", "JSONPointerKeyError", diff --git a/jsonpath/env.py b/jsonpath/env.py index 8980414..d951c90 100644 --- a/jsonpath/env.py +++ b/jsonpath/env.py @@ -99,7 +99,7 @@ class attributes `root_token`, `self_token` and `filter_context_token`. intersection_token (str): The pattern used as the intersection operator. Defaults to `"&"`. key_token (str): The pattern used to identify the current key or index when - filtering a, mapping or sequence. Defaults to `"#"`. + filtering a mapping or sequence. Defaults to `"#"`. keys_selector_token (str): The pattern used as the "keys" selector. Defaults to `"~"`. lexer_class: The lexer to use when tokenizing path strings. diff --git a/jsonpath/exceptions.py b/jsonpath/exceptions.py index 7e61acb..c6797c5 100644 --- a/jsonpath/exceptions.py +++ b/jsonpath/exceptions.py @@ -1,4 +1,5 @@ """JSONPath exceptions.""" + from __future__ import annotations from typing import TYPE_CHECKING @@ -80,10 +81,6 @@ class JSONPointerError(Exception): """Base class for all JSON Pointer errors.""" -class JSONPointerEncodeError(JSONPointerError): - """An exception raised when a JSONPathMatch can't be encoded to a JSON Pointer.""" - - class JSONPointerResolutionError(JSONPointerError): """Base exception for those that can be raised during pointer resolution.""" @@ -145,7 +142,7 @@ class JSONPatchTestFailure(JSONPatchError): # noqa: N818 def _truncate_message(value: str, num: int, end: str = "...") -> str: if len(value) < num: return value - return f"{value[:num-len(end)]}{end}" + return f"{value[: num - len(end)]}{end}" def _truncate_words(val: str, num: int, end: str = "...") -> str: diff --git a/jsonpath/pointer.py b/jsonpath/pointer.py index 778426c..a5aa299 100644 --- a/jsonpath/pointer.py +++ b/jsonpath/pointer.py @@ -32,7 +32,7 @@ class _Undefined: def __str__(self) -> str: - return "" + return "" # pragma: no cover UNDEFINED = _Undefined() @@ -115,59 +115,83 @@ def _index(self, s: str) -> Union[str, int]: try: index = int(s) if index < self.min_int_index or index > self.max_int_index: - raise JSONPointerIndexError("index out of range") + raise JSONPointerError("index out of range") return index except ValueError: return s - def _getitem(self, obj: Any, key: Any) -> Any: # noqa: PLR0912 + def _getitem(self, obj: Any, key: Any) -> Any: try: + # Handle the most common cases. A mapping with a string key, or a sequence + # with an integer index. + # + # Note that `obj` does not have to be a Mapping or Sequence here. Any object + # implementing `__getitem__` will do. return getitem(obj, key) except KeyError as err: - # Try a string repr of the index-like item as a mapping key. - if isinstance(key, int): - try: - return getitem(obj, str(key)) - except KeyError: - raise JSONPointerKeyError(key) from err - # Handle non-standard keys/property selector/pointer. - if ( - isinstance(key, str) - and isinstance(obj, Mapping) - and key.startswith((self.keys_selector, "#")) - and key[1:] in obj - ): - return key[1:] - # Handle non-standard index/property pointer (`#`) - raise JSONPointerKeyError(key) from err + return self._handle_key_error(obj, key, err) except TypeError as err: - if isinstance(obj, Sequence) and not isinstance(obj, str): - if key == "-": - # "-" is a valid index when appending to a JSON array - # with JSON Patch, but not when resolving a JSON Pointer. - raise JSONPointerIndexError("index out of range") from None - # Handle non-standard index pointer. - if isinstance(key, str) and key.startswith("#"): - _index = int(key[1:]) - if _index >= len(obj): - raise JSONPointerIndexError( - f"index out of range: {_index}" - ) from err - return _index - # Try int index. Reject non-zero ints that start with a zero. - if isinstance(key, str): - index = self._index(key) - if isinstance(index, int): - try: - return getitem(obj, int(key)) - except IndexError as index_err: - raise JSONPointerIndexError( - f"index out of range: {key}" - ) from index_err - raise JSONPointerTypeError(f"{key}: {err}") from err + return self._handle_type_error(obj, key, err) except IndexError as err: raise JSONPointerIndexError(f"index out of range: {key}") from err + def _handle_key_error(self, obj: Any, key: Any, err: Exception) -> object: + if isinstance(key, int): + # Try a string repr of the index-like item as a mapping key. + return self._getitem(obj, str(key)) + + # Handle non-standard key/property selector/pointer. + # + # For the benefit of `RelativeJSONPointer.to()` and `JSONPathMatch.pointer()`, + # treat keys starting with a `#` or `~` as a "key pointer". If `key[1:]` is a + # key in `obj`, return the key. + # + # Note that if a key with a leading `#`/`~` exists in `obj`, it will have been + # handled by `_getitem`. + if ( + isinstance(key, str) + and isinstance(obj, Mapping) + and key.startswith((self.keys_selector, "#")) + and key[1:] in obj + ): + return key[1:] + + raise JSONPointerKeyError(key) from err + + def _handle_type_error(self, obj: Any, key: Any, err: Exception) -> object: + if ( + isinstance(obj, str) + or not isinstance(obj, Sequence) + or not isinstance(key, str) + ): + raise JSONPointerTypeError(f"{key}: {err}") from err + + # `obj` is array-like + # `key` is a string + + if key == "-": + # "-" is a valid index when appending to a JSON array with JSON Patch, but + # not when resolving a JSON Pointer. + raise JSONPointerIndexError("index out of range") from None + + # Handle non-standard index pointer. + # + # For the benefit of `RelativeJSONPointer.to()`, treat keys starting with a `#` + # and followed by a valid index as an "index pointer". If `int(key[1:])` is + # less than `len(obj)`, return the index. + if re.match(r"#[1-9]\d*", key): + _index = int(key[1:]) + if _index >= len(obj): + raise JSONPointerIndexError(f"index out of range: {_index}") from err + return _index + + # Try int index. Reject non-zero ints that start with a zero. + index = self._index(key) + if isinstance(index, int): + return self._getitem(obj, index) + + raise JSONPointerTypeError(f"{key}: {err}") from err + def resolve( self, data: Union[str, IOBase, Sequence[object], Mapping[str, object]], @@ -263,7 +287,7 @@ def from_match( pointer = cls._encode(match.parts) else: # This should not happen, unless the JSONPathMatch has been tampered with. - pointer = "" + pointer = "" # pragma: no cover return cls( pointer, @@ -328,10 +352,10 @@ def __eq__(self, other: object) -> bool: return isinstance(other, JSONPointer) and self.parts == other.parts def __hash__(self) -> int: - return hash(self.parts) + return hash(self.parts) # pragma: no cover def __repr__(self) -> str: - return f"JSONPointer({self._s!r})" + return f"JSONPointer({self._s!r})" # pragma: no cover def exists( self, data: Union[str, IOBase, Sequence[object], Mapping[str, object]] @@ -486,7 +510,7 @@ def __eq__(self, __value: object) -> bool: return isinstance(__value, RelativeJSONPointer) and str(self) == str(__value) def __hash__(self) -> int: - return hash((self.origin, self.index, self.pointer)) + return hash((self.origin, self.index, self.pointer)) # pragma: no cover def _parse( self, diff --git a/mkdocs.yml b/mkdocs.yml index effc41f..8183760 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -64,6 +64,7 @@ markdown_extensions: - pymdownx.inlinehilite - pymdownx.snippets - pymdownx.superfences + - pymdownx.details extra_css: - css/style.css diff --git a/tests/test_json_pointer.py b/tests/test_json_pointer.py index f1a8e59..418208c 100644 --- a/tests/test_json_pointer.py +++ b/tests/test_json_pointer.py @@ -1,4 +1,5 @@ """JSONPointer test cases.""" + from io import StringIO from typing import List from typing import Union @@ -39,7 +40,7 @@ def test_resolve_with_default() -> None: assert pointer.resolve(data, default=None) is None -def test_pointer_index_out_fo_range() -> None: +def test_pointer_index_out_of_range() -> None: max_plus_one = JSONPointer.max_int_index + 1 min_minus_one = JSONPointer.min_int_index - 1 @@ -261,7 +262,7 @@ def test_join_pointers_with_slash() -> None: assert str(pointer / "/bar") == "/bar" with pytest.raises(TypeError): - pointer / 0 + pointer / 0 # type: ignore def test_join_pointers() -> None: @@ -299,6 +300,21 @@ def test_non_standard_index_pointer() -> None: JSONPointer("/foo/bar/#9").resolve(data) +def test_non_standard_index_pointer_with_leading_zero() -> None: + data = {"foo": {"bar": [1, 2, 3], "#baz": "hello"}} + with pytest.raises(JSONPointerTypeError): + JSONPointer("/foo/bar/#01").resolve(data) + + with pytest.raises(JSONPointerTypeError): + JSONPointer("/foo/bar/#09").resolve(data) + + +def test_non_standard_index_pointer_to_non_array_object() -> None: + data = {"foo": {"bar": True, "#baz": "hello"}} + with pytest.raises(JSONPointerTypeError): + JSONPointer("/foo/bar/#1").resolve(data) + + def test_trailing_slash() -> None: data = {"foo": {"": [1, 2, 3], " ": [4, 5, 6]}} assert JSONPointer("/foo/").resolve(data) == [1, 2, 3]