diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f0df0f..d1d3fc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ These breaking changes apply to Python JSONPath in its default configuration. We **JSONPath function extension changes** +- Added the `startswith(value, prefix)` function extension. `startswith` returns `True` if both arguments are strings and the second argument is a prefix of the first argument. See the [filter functions](https://jg-rp.github.io/python-jsonpath/functions/#startswith) documentation. - 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** diff --git a/docs/functions.md b/docs/functions.md index dddf339..4ddf99f 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -119,6 +119,20 @@ If _pattern_ is a string literal, it will be compiled at compile time, and raise If _pattern_ is a query and the result is not a valid regex, `False` is returned. +## `startswith()` + +**_New in version 2.0.0_** + +``` +startswith(value: str, prefix: str) -> bool +``` + +Return `True` if `value` starts with `prefix`. If `value` or `prefix` are not strings, `False` is returned. + +``` +$[?startswith(@, 'ab')] +``` + ## `typeof()` **_New in version 0.6.0_** diff --git a/jsonpath/env.py b/jsonpath/env.py index 1d0fa49..ee674ed 100644 --- a/jsonpath/env.py +++ b/jsonpath/env.py @@ -454,6 +454,7 @@ def setup_function_extensions(self) -> None: self.function_extensions["is"] = self.function_extensions["isinstance"] self.function_extensions["typeof"] = function_extensions.TypeOf() self.function_extensions["type"] = self.function_extensions["typeof"] + self.function_extensions["startswith"] = function_extensions.StartsWith() def validate_function_extension_signature( self, token: Token, args: List[Any] diff --git a/jsonpath/function_extensions/__init__.py b/jsonpath/function_extensions/__init__.py index 32848a3..28f3b37 100644 --- a/jsonpath/function_extensions/__init__.py +++ b/jsonpath/function_extensions/__init__.py @@ -8,6 +8,7 @@ from .length import Length from .match import Match from .search import Search +from .starts_with import StartsWith from .typeof import TypeOf from .value import Value @@ -20,6 +21,7 @@ "Length", "Match", "Search", + "StartsWith", "TypeOf", "validate", "Value", diff --git a/jsonpath/function_extensions/starts_with.py b/jsonpath/function_extensions/starts_with.py new file mode 100644 index 0000000..224f3af --- /dev/null +++ b/jsonpath/function_extensions/starts_with.py @@ -0,0 +1,21 @@ +"""The `startswith` function extension.""" + +from jsonpath.function_extensions import ExpressionType +from jsonpath.function_extensions import FilterFunction + + +class StartsWith(FilterFunction): + """The `startswith` function extension.""" + + arg_types = [ExpressionType.VALUE, ExpressionType.VALUE] + return_type = ExpressionType.LOGICAL + + def __call__(self, value: object, prefix: object) -> bool: + """Return `True` if `value` starts with `prefix`.""" + if not isinstance(value, str) or not isinstance(prefix, str): + return False + + try: + return value.startswith(prefix) + except AttributeError: + return False diff --git a/tests/test_keys_function.py b/tests/test_keys_function.py index afae9b8..38bbb26 100644 --- a/tests/test_keys_function.py +++ b/tests/test_keys_function.py @@ -19,8 +19,6 @@ class Case: want: Union[Sequence[Any], Mapping[str, Any]] -SOME_OBJECT = object() - TEST_CASES = [ Case( description="value in keys of an object", diff --git a/tests/test_startswith_function.py b/tests/test_startswith_function.py new file mode 100644 index 0000000..ffbaec4 --- /dev/null +++ b/tests/test_startswith_function.py @@ -0,0 +1,51 @@ +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 + + +@dataclasses.dataclass +class Case: + description: str + path: str + data: Union[Sequence[Any], Mapping[str, Any]] + want: Union[Sequence[Any], Mapping[str, Any]] + + +TEST_CASES = [ + Case( + description="current value start with string", + path="$[?startswith(@, 'ab')]", + data={"x": "abc", "y": "abx", "z": "bcd", "-": "ab"}, + want=["abc", "abx", "ab"], + ), + Case( + description="current key start with string", + path="$[?startswith(#, 'ab')]", + data={"abc": 1, "abx": 2, "bcd": 3, "ab": 4}, + want=[1, 2, 4], + ), + Case( + description="value is not a string", + path="$[?startswith(@, 'ab')]", + data={"abc": 1, "abx": 2, "bcd": 3, "ab": 4}, + want=[], + ), +] + + +@pytest.fixture() +def env() -> JSONPathEnvironment: + return JSONPathEnvironment() + + +@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