Skip to content

Commit 291e218

Browse files
committed
feat: add current key/index identifier, closes #5
1 parent 0ee47f9 commit 291e218

File tree

9 files changed

+63
-12
lines changed

9 files changed

+63
-12
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@
44

55
**Breaking changes**
66

7-
- The "extra context" identifier now defaults to `_`. Previously it defaulted to `#`, but it has been decided that `#` is better suited as a "keys" or "properties" identifier.
7+
- The "extra filter context" identifier now defaults to `_`. Previously it defaulted to `#`, but it has been decided that `#` is better suited as a current key/property or index identifier.
88

99
**Features**
1010

1111
- Added a non-standard keys/properties selector (`~`).
1212
- Added a non-standard `typeof()` filter function. `type()` is an alias for `typeof()`.
1313
- Added a non-standard `isinstance()` filter function. `is()` is an alias for `isinstance()`.
14+
- Added a current key/property or index identifier. When filtering a mapping, `#` will hold key associated with the current node (`@`). When filtering a sequence, `#` will hold the current index.
1415

1516
**IETF JSONPath Draft compliance**
1617

docs/syntax.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,9 @@ $..title
120120
$...title
121121
```
122122

123-
### Filters (`[?(EXPRESSION)]`)
123+
### Filters (`[?EXPRESSION]`)
124124

125-
Filters allow you to remove nodes from a selection using a Boolean expression. Within a filter, `@` refers to the current node and `$` refers to the root node in the target document. `@` and `$` can be used to select nodes as part of the expression. Since version 0.3.0, the parentheses are optional, as per the IETF JSONPath draft. These two examples are equivalent.
125+
Filters allow you to remove nodes from a selection using a Boolean expression. When filtering a mapping-like object, `#` references the current key/property and `@` references the current value associated with `#`. When filtering a sequence-like object, `@` references the current item and `#` will hold the item's index in the sequence.
126126

127127
```text
128128
$..products[?(@.price < $.price_cap)]
@@ -187,5 +187,6 @@ And this is a list of features that are uncommon or unique to Python JSONPath.
187187

188188
- `|` is a union operator, where matches from two or more JSONPaths are combined. This is not part of the Python API, but built-in to the JSONPath syntax.
189189
- `&` is an intersection operator, where we exclude matches that don't exist in both left and right paths. This is not part of the Python API, but built-in to the JSONPath syntax.
190+
- `#` is the current key/property or index identifier when filtering a mapping or sequence.
190191
- `_` is a filter context selector. With usage similar to `$` and `@`, `_` exposes arbitrary data from the `filter_context` argument to `findall()` and `finditer()`.
191192
- `~` is a "keys" or "properties" selector.

jsonpath/env.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,10 @@ class attributes `root_token`, `self_token` and `filter_context_token`.
7272
data. Defaults to `"_"`.
7373
intersection_token (str): The pattern used as the intersection operator.
7474
Defaults to `"$"`.
75-
keys_token (str): The pattern used as the "keys" selector. Defaults to `"~"`.
75+
key_token (str): The pattern used to identify the current key or index when
76+
filtering a, mapping or sequence. Defaults to `"#"`.
77+
keys_selector_token (str): The pattern used as the "keys" selector. Defaults to
78+
`"~"`.
7679
lexer_class: The lexer to use when tokenizing path strings.
7780
max_int_index (int): The maximum integer allowed when selecting array items by
7881
index. Defaults to `(2**53) - 1`.
@@ -88,12 +91,13 @@ class attributes `root_token`, `self_token` and `filter_context_token`.
8891

8992
# These should be unescaped strings. `re.escape` will be called
9093
# on them automatically when compiling lexer rules.
94+
filter_context_token = "_"
9195
intersection_token = "&"
92-
keys_token = "~"
96+
key_token = "#"
97+
keys_selector_token = "~"
9398
root_token = "$"
9499
self_token = "@"
95100
union_token = "|"
96-
filter_context_token = "_"
97101

98102
max_int_index = (2**53) - 1
99103
min_int_index = -(2**53) + 1

jsonpath/filter.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,3 +449,20 @@ async def evaluate_async(self, context: FilterContext) -> object:
449449
return UNDEFINED
450450
args = [await arg.evaluate_async(context) for arg in self.args]
451451
return func(*args)
452+
453+
454+
class CurrentKey(FilterExpression):
455+
"""The key/property or index associated with the current object."""
456+
457+
__slots__ = ()
458+
459+
def evaluate(self, context: FilterContext) -> object:
460+
if context.current_key is None:
461+
return UNDEFINED
462+
return context.current_key
463+
464+
async def evaluate_async(self, context: FilterContext) -> object:
465+
return self.evaluate(context)
466+
467+
468+
CURRENT_KEY = CurrentKey()

jsonpath/lex.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from .token import TOKEN_INDEX
3232
from .token import TOKEN_INT
3333
from .token import TOKEN_INTERSECTION
34+
from .token import TOKEN_KEY
3435
from .token import TOKEN_KEYS
3536
from .token import TOKEN_LE
3637
from .token import TOKEN_LG
@@ -145,10 +146,11 @@ def compile_rules(self) -> Pattern[str]:
145146
(TOKEN_OR, self.bool_or_pattern),
146147
(TOKEN_ROOT, re.escape(self.env.root_token)),
147148
(TOKEN_SELF, re.escape(self.env.self_token)),
149+
(TOKEN_KEY, re.escape(self.env.key_token)),
148150
(TOKEN_UNION, re.escape(self.env.union_token)),
149151
(TOKEN_INTERSECTION, re.escape(self.env.intersection_token)),
150152
(TOKEN_FILTER_CONTEXT, re.escape(self.env.filter_context_token)),
151-
(TOKEN_KEYS, re.escape(self.env.keys_token)),
153+
(TOKEN_KEYS, re.escape(self.env.keys_selector_token)),
152154
(TOKEN_IN, r"in"),
153155
(TOKEN_TRUE, r"[Tt]rue"),
154156
(TOKEN_FALSE, r"[Ff]alse"),

jsonpath/parse.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from typing import Union
1313

1414
from .exceptions import JSONPathSyntaxError
15+
from .filter import CURRENT_KEY
1516
from .filter import FALSE
1617
from .filter import NIL
1718
from .filter import TRUE
@@ -58,6 +59,7 @@
5859
from .token import TOKEN_INDEX
5960
from .token import TOKEN_INT
6061
from .token import TOKEN_INTERSECTION
62+
from .token import TOKEN_KEY
6163
from .token import TOKEN_KEYS
6264
from .token import TOKEN_LE
6365
from .token import TOKEN_LG
@@ -198,6 +200,7 @@ def __init__(self, *, env: JSONPathEnvironment) -> None:
198200
TOKEN_FALSE: self.parse_boolean,
199201
TOKEN_FLOAT: self.parse_float_literal,
200202
TOKEN_INT: self.parse_integer_literal,
203+
TOKEN_KEY: self.parse_current_key,
201204
TOKEN_LIST_START: self.parse_list_literal,
202205
TOKEN_LPAREN: self.parse_grouped_expression,
203206
TOKEN_MISSING: self.parse_undefined,
@@ -232,6 +235,7 @@ def __init__(self, *, env: JSONPathEnvironment) -> None:
232235
TOKEN_FALSE: self.parse_boolean,
233236
TOKEN_FLOAT: self.parse_float_literal,
234237
TOKEN_INT: self.parse_integer_literal,
238+
TOKEN_KEY: self.parse_current_key,
235239
TOKEN_NIL: self.parse_nil,
236240
TOKEN_NONE: self.parse_nil,
237241
TOKEN_NULL: self.parse_nil,
@@ -507,6 +511,9 @@ def parse_self_path(self, stream: TokenStream) -> FilterExpression:
507511
JSONPath(env=self.env, selectors=self.parse_path(stream, in_filter=True))
508512
)
509513

514+
def parse_current_key(self, _: TokenStream) -> FilterExpression:
515+
return CURRENT_KEY
516+
510517
def parse_filter_context_path(self, stream: TokenStream) -> FilterExpression:
511518
stream.next_token()
512519
return FilterContextPath(

jsonpath/selectors.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ class KeysSelector(JSONPathSelector):
196196
__slots__ = ()
197197

198198
def __str__(self) -> str:
199-
return f"[{self.env.keys_token}]"
199+
return f"[{self.env.keys_selector_token}]"
200200

201201
def _keys(self, match: JSONPathMatch) -> Iterable[JSONPathMatch]:
202202
if isinstance(match.obj, Mapping):
@@ -205,8 +205,8 @@ def _keys(self, match: JSONPathMatch) -> Iterable[JSONPathMatch]:
205205
filter_context=match.filter_context(),
206206
obj=key,
207207
parent=match,
208-
parts=match.parts + (self.env.keys_token, i),
209-
path=f"{match.path}[{self.env.keys_token}][{i}]",
208+
parts=match.parts + (self.env.keys_selector_token, i),
209+
path=f"{match.path}[{self.env.keys_selector_token}][{i}]",
210210
root=match.root,
211211
)
212212
match.add_child(_match)
@@ -468,7 +468,7 @@ def __str__(self) -> str:
468468
elif isinstance(item, WildSelector):
469469
buf.append("*")
470470
elif isinstance(item, KeysSelector):
471-
buf.append(self.env.keys_token)
471+
buf.append(self.env.keys_selector_token)
472472
else:
473473
buf.append(str(item.index))
474474
return f"[{', '.join(buf)}]"
@@ -516,6 +516,7 @@ def resolve( # noqa: PLR0912
516516
current=val,
517517
root=match.root,
518518
extra_context=match.filter_context(),
519+
current_key=key,
519520
)
520521
try:
521522
if self.expression.evaluate(context):
@@ -541,6 +542,7 @@ def resolve( # noqa: PLR0912
541542
current=obj,
542543
root=match.root,
543544
extra_context=match.filter_context(),
545+
current_key=i,
544546
)
545547
try:
546548
if self.expression.evaluate(context):
@@ -570,6 +572,7 @@ async def resolve_async( # noqa: PLR0912
570572
current=val,
571573
root=match.root,
572574
extra_context=match.filter_context(),
575+
current_key=key,
573576
)
574577

575578
try:
@@ -598,6 +601,7 @@ async def resolve_async( # noqa: PLR0912
598601
current=obj,
599602
root=match.root,
600603
extra_context=match.filter_context(),
604+
current_key=i,
601605
)
602606

603607
try:
@@ -622,7 +626,7 @@ async def resolve_async( # noqa: PLR0912
622626
class FilterContext:
623627
"""A filter expression context."""
624628

625-
__slots__ = ("current", "env", "root", "extra_context")
629+
__slots__ = ("current", "current_key", "env", "root", "extra_context")
626630

627631
def __init__(
628632
self,
@@ -631,11 +635,13 @@ def __init__(
631635
current: object,
632636
root: Union[Sequence[Any], Mapping[str, Any]],
633637
extra_context: Optional[Mapping[str, Any]] = None,
638+
current_key: Union[str, int, None] = None,
634639
) -> None:
635640
self.env = env
636641
self.current = current
637642
self.root = root
638643
self.extra_context = extra_context or {}
644+
self.current_key = current_key
639645

640646
def __str__(self) -> str:
641647
return (

jsonpath/token.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
TOKEN_FILTER_START = sys.intern("FILTER_START")
2020
TOKEN_IDENT = sys.intern("IDENT")
2121
TOKEN_INDEX = sys.intern("IDX")
22+
TOKEN_KEY = sys.intern("KEY")
2223
TOKEN_KEYS = sys.intern("KEYS")
2324
TOKEN_RBRACKET = sys.intern("RBRACKET")
2425
TOKEN_BARE_PROPERTY = sys.intern("BARE_PROPERTY")

tests/test_find.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,18 @@ class Case:
4545
data={"some": ["thing", "else"]},
4646
want=[],
4747
),
48+
Case(
49+
description="match key pattern",
50+
path="$.some[?match(#, 'thing[0-9]+')]",
51+
data={
52+
"some": {
53+
"thing1": {"foo": 1},
54+
"thing2": {"foo": 2},
55+
"other": {"foo": 3},
56+
}
57+
},
58+
want=[{"foo": 1}, {"foo": 2}],
59+
),
4860
]
4961

5062

0 commit comments

Comments
 (0)