Skip to content

Commit 0ccf2f4

Browse files
authored
Merge pull request #6 from jg-rp/nodelists
feat: add support for filter functions that operate on node lists
2 parents ff6ca53 + a89157e commit 0ccf2f4

File tree

6 files changed

+84
-30
lines changed

6 files changed

+84
-30
lines changed

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
# Python JSONPath Change Log
22

3-
## Version 0.7.0
3+
## Version 0.7.0 (unreleased)
44

55
**Breaking changes**
66

77
- `JSONPathIndexError` now requires a `token` parameter. It used to be optional.
8+
- Filter expressions that resolve JSON paths (like `SelfPath` and `RootPath`) now return a `NodeList`. The node list must then be explicitly unpacked by `JSONPathEnvironment.compare()` and any filter function that has a `with_node_lists` attribute set to `True`. This is done for the benefit of the `count()` filter function and standards compliance.
89

910
**Features**
1011

1112
- `missing` is now an allowed alias of `undefined` when using the `isinstance()` filter function.
1213

14+
**IETF JSONPath Draft compliance**
15+
16+
- The built-in `count()` filter function is now compliant with the standard, operating on a "nodelist" instead of node values.
17+
1318
## Version 0.6.0
1419

1520
**Breaking changes**

jsonpath/env.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from .filter import UNDEFINED
2525
from .function_extensions import validate
2626
from .lex import Lexer
27+
from .match import NodeList
2728
from .parse import Parser
2829
from .path import CompoundJSONPath
2930
from .path import JSONPath
@@ -338,6 +339,11 @@ def compare(self, left: object, operator: str, right: object) -> bool:
338339
`True` if the comparison between _left_ and _right_, with the
339340
given _operator_, is truthy. `False` otherwise.
340341
"""
342+
if isinstance(left, NodeList):
343+
left = left.values_or_singular()
344+
if isinstance(right, NodeList):
345+
right = right.values_or_singular()
346+
341347
if operator == "&&":
342348
return self.is_truthy(left) and self.is_truthy(right)
343349
if operator == "||":

jsonpath/filter.py

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from typing import TypeVar
1515

1616
from .exceptions import JSONPathTypeError
17+
from .match import NodeList
1718

1819
if TYPE_CHECKING:
1920
from .path import JSONPath
@@ -342,14 +343,12 @@ def evaluate(self, context: FilterContext) -> object: # noqa: PLR0911
342343
return UNDEFINED
343344

344345
try:
345-
matches = self.path.findall(context.current)
346+
matches = NodeList(self.path.finditer(context.current))
346347
except json.JSONDecodeError: # this should never happen
347348
return UNDEFINED
348349

349350
if not matches:
350351
return UNDEFINED
351-
if len(matches) == 1:
352-
return matches[0]
353352
return matches
354353

355354
async def evaluate_async(self, context: FilterContext) -> object: # noqa: PLR0911
@@ -363,14 +362,17 @@ async def evaluate_async(self, context: FilterContext) -> object: # noqa: PLR09
363362
return UNDEFINED
364363

365364
try:
366-
matches = await self.path.findall_async(context.current)
365+
matches = NodeList(
366+
[
367+
match
368+
async for match in await self.path.finditer_async(context.current)
369+
]
370+
)
367371
except json.JSONDecodeError:
368372
return UNDEFINED
369373

370374
if not matches:
371375
return UNDEFINED
372-
if len(matches) == 1:
373-
return matches[0]
374376
return matches
375377

376378

@@ -381,19 +383,17 @@ def __str__(self) -> str:
381383
return str(self.path)
382384

383385
def evaluate(self, context: FilterContext) -> object:
384-
matches = self.path.findall(context.root)
386+
matches = NodeList(self.path.finditer(context.root))
385387
if not matches:
386388
return UNDEFINED
387-
if len(matches) == 1:
388-
return matches[0]
389389
return matches
390390

391391
async def evaluate_async(self, context: FilterContext) -> object:
392-
matches = await self.path.findall_async(context.root)
392+
matches = NodeList(
393+
[match async for match in await self.path.finditer_async(context.root)]
394+
)
393395
if not matches:
394396
return UNDEFINED
395-
if len(matches) == 1:
396-
return matches[0]
397397
return matches
398398

399399

@@ -405,19 +405,20 @@ def __str__(self) -> str:
405405
return "_" + path_repr[1:]
406406

407407
def evaluate(self, context: FilterContext) -> object:
408-
matches = self.path.findall(context.extra_context)
408+
matches = NodeList(self.path.finditer(context.extra_context))
409409
if not matches:
410410
return UNDEFINED
411-
if len(matches) == 1:
412-
return matches[0]
413411
return matches
414412

415413
async def evaluate_async(self, context: FilterContext) -> object:
416-
matches = await self.path.findall_async(context.extra_context)
414+
matches = NodeList(
415+
[
416+
match
417+
async for match in await self.path.finditer_async(context.extra_context)
418+
]
419+
)
417420
if not matches:
418421
return UNDEFINED
419-
if len(matches) == 1:
420-
return matches[0]
421422
return matches
422423

423424

@@ -440,15 +441,25 @@ def evaluate(self, context: FilterContext) -> object:
440441
except KeyError:
441442
return UNDEFINED
442443
args = [arg.evaluate(context) for arg in self.args]
443-
return func(*args)
444+
if getattr(func, "with_node_lists", False):
445+
return func(*args)
446+
return func(*self._unpack_node_lists(args))
444447

445448
async def evaluate_async(self, context: FilterContext) -> object:
446449
try:
447450
func = context.env.function_extensions[self.name]
448451
except KeyError:
449452
return UNDEFINED
450453
args = [await arg.evaluate_async(context) for arg in self.args]
451-
return func(*args)
454+
if getattr(func, "with_node_lists", False):
455+
return func(*args)
456+
return func(*self._unpack_node_lists(args))
457+
458+
def _unpack_node_lists(self, args: List[object]) -> List[object]:
459+
return [
460+
obj.values_or_singular() if isinstance(obj, NodeList) else obj
461+
for obj in args
462+
]
452463

453464

454465
class CurrentKey(FilterExpression):

jsonpath/function_extensions/count.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,27 @@
11
"""The standard `count` function extension."""
2-
from collections.abc import Sized
2+
from __future__ import annotations
3+
34
from typing import TYPE_CHECKING
45
from typing import List
5-
from typing import Optional
66

77
from ..exceptions import JSONPathTypeError
88
from ..filter import Literal
9+
from ..filter import Nil
910

1011
if TYPE_CHECKING:
1112
from ..env import JSONPathEnvironment
13+
from ..match import NodeList
1214
from ..token import Token
1315

1416

1517
class Count:
1618
"""The built-in `count` function."""
1719

18-
def __call__(self, obj: Sized) -> Optional[int]:
19-
"""Return an object's length, or `None` if the object does not have a length."""
20-
try:
21-
return len(obj)
22-
except TypeError:
23-
return None
20+
with_node_lists = True
21+
22+
def __call__(self, node_list: NodeList) -> int:
23+
"""Return the number of nodes in the node list."""
24+
return len(node_list)
2425

2526
def validate(
2627
self,
@@ -35,7 +36,7 @@ def validate(
3536
token=token,
3637
)
3738

38-
if isinstance(args[0], Literal):
39+
if isinstance(args[0], (Literal, Nil)):
3940
raise JSONPathTypeError(
4041
f"{token.value!r} requires a node list, "
4142
f"found {args[0].__class__.__name__}",

jsonpath/match.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,17 @@ def _truncate(val: str, num: int, end: str = "...") -> str:
7676
if len(words) < num:
7777
return " ".join(words)
7878
return " ".join(words[:num]) + end
79+
80+
81+
class NodeList(List[JSONPathMatch]):
82+
"""List of JSONPathMatch objects, analogous to the spec's nodelist."""
83+
84+
def values(self) -> List[object]:
85+
"""Return the values from this node list."""
86+
return [match.obj for match in self]
87+
88+
def values_or_singular(self) -> object:
89+
"""Return the values from this node list."""
90+
if len(self) == 1:
91+
return self[0].obj
92+
return [match.obj for match in self]

tests/compliance.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,23 @@ class Case:
5555
"functions, match, result cannot be compared": "ignore",
5656
"functions, search, result cannot be compared": "ignore",
5757
"functions, value, result must be compared": "ignore",
58+
"whitespace, selectors, space between root and bracket": "flexible whitespace policy", # noqa: E501
59+
"whitespace, selectors, newline between root and bracket": "flexible whitespace policy", # noqa: E501
60+
"whitespace, selectors, tab between root and bracket": "flexible whitespace policy", # noqa: E501
61+
"whitespace, selectors, return between root and bracket": "flexible whitespace policy", # noqa: E501
62+
"whitespace, selectors, space between bracket and bracket": "flexible whitespace policy", # noqa: E501
63+
"whitespace, selectors, space between root and dot": "flexible whitespace policy", # noqa: E501
64+
"whitespace, selectors, newline between root and dot": "flexible whitespace policy", # noqa: E501
65+
"whitespace, selectors, tab between root and dot": "flexible whitespace policy", # noqa: E501
66+
"whitespace, selectors, return between root and dot": "flexible whitespace policy", # noqa: E501
67+
"whitespace, selectors, space between dot and name": "flexible whitespace policy", # noqa: E501
68+
"whitespace, selectors, newline between dot and name": "flexible whitespace policy", # noqa: E501
69+
"whitespace, selectors, tab between dot and name": "flexible whitespace policy", # noqa: E501
70+
"whitespace, selectors, return between dot and name": "flexible whitespace policy", # noqa: E501
71+
"whitespace, selectors, space between recursive descent and name": "flexible whitespace policy", # noqa: E501
72+
"whitespace, selectors, newline between recursive descent and name": "flexible whitespace policy", # noqa: E501
73+
"whitespace, selectors, tab between recursive descent and name": "flexible whitespace policy", # noqa: E501
74+
"whitespace, selectors, return between recursive descent and name": "flexible whitespace policy", # noqa: E501
5875
}
5976

6077

0 commit comments

Comments
 (0)