diff --git a/CHANGELOG.md b/CHANGELOG.md index fba0006..5f0df0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ These breaking changes apply to Python JSONPath in its default configuration. We - Slice selector indexes and step now follow the specification. Previously leading zeros and negative zero were allowed, now they raise a `JSONPathSyntaxError`. - Whitespace is no longer allowed between a dot (`.` or `..`) and a name when using shorthand notation for the name selector. Whitespace before the dot oor double dot is OK. +**JSONPath function extension changes** + +- The non-standard `keys()` function extension has been reimplemented. It used to be a simple Python function, `jsonpath.function_extensions.keys`. Now it is a "well-typed" class, `jsonpath.function_extensions.Keys`. See the [filter functions](https://jg-rp.github.io/python-jsonpath/functions/#keys) documentation. + **JSONPath features** - Added the [Keys filter selector](https://jg-rp.github.io/python-jsonpath/syntax/#keys-filter-selector). diff --git a/docs/functions.md b/docs/functions.md index 10504cd..dddf339 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -47,6 +47,34 @@ And `is()` is an alias for `isinstance()`: $.categories[?is(@.length, 'number')] ``` +## `keys()` + +**_New in version 2.0.0_** + +``` +keys(value: object) -> Tuple[str, ...] | Nothing +``` + +Return a list of keys from an object/mapping. If `value` does not have a `keys()` method, the special _Nothing_ value is returned. + +!!! note + + `keys()` is not registered with the default JSONPath environment. The [keys selector](syntax.md#keys-selector) and [keys filter selector](syntax.md#keys-filter-selector) are usually the better choice when strict compliance with the specification is not needed. + + You can register `keys()` with your JSONPath environment like this: + + ```python + from jsonpath import JSONPathEnvironment + from jsonpath import function_extensions + + env = JSONPathEnvironment() + env.function_extensions["keys"] = function_extensions.Keys() + ``` + +``` +$.some[?'thing' in keys(@)] +``` + ## `length()` ```text diff --git a/jsonpath/function_extensions/__init__.py b/jsonpath/function_extensions/__init__.py index cd7e8c9..32848a3 100644 --- a/jsonpath/function_extensions/__init__.py +++ b/jsonpath/function_extensions/__init__.py @@ -4,7 +4,7 @@ from .filter_function import FilterFunction from .count import Count from .is_instance import IsInstance -from .keys import keys +from .keys import Keys from .length import Length from .match import Match from .search import Search @@ -16,7 +16,7 @@ "ExpressionType", "FilterFunction", "IsInstance", - "keys", + "Keys", "Length", "Match", "Search", diff --git a/jsonpath/function_extensions/keys.py b/jsonpath/function_extensions/keys.py index bf9ef8c..5a22644 100644 --- a/jsonpath/function_extensions/keys.py +++ b/jsonpath/function_extensions/keys.py @@ -1,12 +1,31 @@ -"""The built-in `keys` function extension.""" +"""The `keys` JSONPath filter function.""" + from typing import Mapping -from typing import Optional from typing import Tuple +from typing import Union + +from jsonpath.filter import UNDEFINED +from jsonpath.filter import _Undefined + +from .filter_function import ExpressionType +from .filter_function import FilterFunction + + +class Keys(FilterFunction): + """The `keys` JSONPath filter function.""" + + arg_types = [ExpressionType.VALUE] + return_type = ExpressionType.VALUE + def __call__( + self, value: Mapping[str, object] + ) -> Union[Tuple[str, ...], _Undefined]: + """Return a tuple of keys in `value`. -def keys(obj: Mapping[str, object]) -> Optional[Tuple[str, ...]]: - """Return an object's keys, or `None` if the object has no _keys_ method.""" - try: - return tuple(obj.keys()) - except AttributeError: - return None + If `value` does not have a `keys()` method, the special _Nothing_ value + is returned. + """ + try: + return tuple(value.keys()) + except AttributeError: + return UNDEFINED diff --git a/tests/test_keys_function.py b/tests/test_keys_function.py new file mode 100644 index 0000000..afae9b8 --- /dev/null +++ b/tests/test_keys_function.py @@ -0,0 +1,62 @@ +import dataclasses +import operator +from typing import Any +from typing import Mapping +from typing import Sequence +from typing import Union + +import pytest + +from jsonpath import JSONPathEnvironment +from jsonpath import function_extensions + + +@dataclasses.dataclass +class Case: + description: str + path: str + data: Union[Sequence[Any], Mapping[str, Any]] + want: Union[Sequence[Any], Mapping[str, Any]] + + +SOME_OBJECT = object() + +TEST_CASES = [ + Case( + description="value in keys of an object", + path="$.some[?'thing' in keys(@)]", + data={"some": [{"thing": "foo"}]}, + want=[{"thing": "foo"}], + ), + Case( + description="value not in keys of an object", + path="$.some[?'else' in keys(@)]", + data={"some": [{"thing": "foo"}]}, + want=[], + ), + Case( + description="keys of an array", + path="$[?'thing' in keys(@)]", + data={"some": [{"thing": "foo"}]}, + want=[], + ), + Case( + description="keys of an string value", + path="$some[0].thing[?'else' in keys(@)]", + data={"some": [{"thing": "foo"}]}, + want=[], + ), +] + + +@pytest.fixture() +def env() -> JSONPathEnvironment: + _env = JSONPathEnvironment() + _env.function_extensions["keys"] = function_extensions.Keys() + return _env + + +@pytest.mark.parametrize("case", TEST_CASES, ids=operator.attrgetter("description")) +def test_isinstance_function(env: JSONPathEnvironment, case: Case) -> None: + path = env.compile(case.path) + assert path.findall(case.data) == case.want