Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix normalized and canonical paths #11

Merged
merged 2 commits into from
Jan 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
[submodule "tests/cts"]
path = tests/cts
url = git@github.com:jsonpath-standard/jsonpath-compliance-test-suite.git
[submodule "tests/nts"]
path = tests/nts
url = git@github.com:jg-rp/jsonpath-compliance-normalized-paths.git
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Python JSONPath RFC 9535 Change Log

## Version 0.1.4 (unreleased)

**Fixes**

- Fixed normalized paths produced by `JSONPathNode.path()`. Previously we were not handling some escape sequences correctly in name selectors.
- Fixed serialization of `JSONPathQuery` instances. `JSONPathQuery.__str__()` now serialized name selectors and string literals to the canonical format, similar to normalized paths. We're also now minimizing the use of parentheses when serializing logical expressions.

## Version 0.1.3

**Fixes**
Expand Down
2 changes: 1 addition & 1 deletion jsonpath_rfc9535/__about__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.1.3"
__version__ = "0.1.4"
37 changes: 34 additions & 3 deletions jsonpath_rfc9535/filter_expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from __future__ import annotations

import json
from abc import ABC
from abc import abstractmethod
from typing import TYPE_CHECKING
Expand All @@ -16,6 +15,7 @@

from .exceptions import JSONPathTypeError
from .node import JSONPathNodeList
from .serialize import canonical_string

if TYPE_CHECKING:
from .environment import JSONPathEnvironment
Expand Down Expand Up @@ -45,6 +45,12 @@ def evaluate(self, context: FilterContext) -> object:
"""


PRECEDENCE_LOWEST = 1
PRECEDENCE_LOGICAL_OR = 3
PRECEDENCE_LOGICAL_AND = 4
PRECEDENCE_PREFIX = 7


class FilterExpression(Expression):
"""An expression that evaluates to `true` or `false`."""

Expand All @@ -55,7 +61,7 @@ def __init__(self, token: Token, expression: Expression) -> None:
self.expression = expression

def __str__(self) -> str:
return str(self.expression)
return self._canonical_string(self.expression, PRECEDENCE_LOWEST)

def __eq__(self, other: object) -> bool:
return (
Expand All @@ -66,6 +72,31 @@ def evaluate(self, context: FilterContext) -> bool:
"""Evaluate the filter expression in the given _context_."""
return _is_truthy(self.expression.evaluate(context))

def _canonical_string(self, expression: Expression, parent_precedence: int) -> str:
if isinstance(expression, LogicalExpression):
if expression.operator == "&&":
left = self._canonical_string(expression.left, PRECEDENCE_LOGICAL_AND)
right = self._canonical_string(expression.right, PRECEDENCE_LOGICAL_AND)
expr = f"{left} && {right}"
return (
f"({expr})" if parent_precedence >= PRECEDENCE_LOGICAL_AND else expr
)

if expression.operator == "||":
left = self._canonical_string(expression.left, PRECEDENCE_LOGICAL_OR)
right = self._canonical_string(expression.right, PRECEDENCE_LOGICAL_OR)
expr = f"{left} || {right}"
return (
f"({expr})" if parent_precedence >= PRECEDENCE_LOGICAL_OR else expr
)

if isinstance(expression, PrefixExpression):
operand = self._canonical_string(expression.right, PRECEDENCE_PREFIX)
expr = f"!{operand}"
return f"({expr})" if parent_precedence > PRECEDENCE_PREFIX else expr

return str(expression)


LITERAL_T = TypeVar("LITERAL_T")

Expand Down Expand Up @@ -105,7 +136,7 @@ class StringLiteral(FilterExpressionLiteral[str]):
__slots__ = ()

def __str__(self) -> str:
return json.dumps(self.value)
return canonical_string(self.value)


class IntegerLiteral(FilterExpressionLiteral[int]):
Expand Down
5 changes: 4 additions & 1 deletion jsonpath_rfc9535/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from typing import Tuple
from typing import Union

from .serialize import canonical_string

if TYPE_CHECKING:
from .environment import JSONValue

Expand Down Expand Up @@ -39,7 +41,8 @@ def __init__(
def path(self) -> str:
"""Return the normalized path to this node."""
return "$" + "".join(
f"['{p}']" if isinstance(p, str) else f"[{p}]" for p in self.location
f"[{canonical_string(p)}]" if isinstance(p, str) else f"[{p}]"
for p in self.location
)

def new_child(self, value: object, key: Union[int, str]) -> JSONPathNode:
Expand Down
3 changes: 2 additions & 1 deletion jsonpath_rfc9535/selectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from .exceptions import JSONPathIndexError
from .exceptions import JSONPathTypeError
from .filter_expressions import FilterContext
from .serialize import canonical_string

if TYPE_CHECKING:
from .environment import JSONPathEnvironment
Expand Down Expand Up @@ -60,7 +61,7 @@ def __init__(
self.name = name

def __str__(self) -> str:
return repr(self.name)
return canonical_string(self.name)

def __eq__(self, __value: object) -> bool:
return (
Expand Down
9 changes: 9 additions & 0 deletions jsonpath_rfc9535/serialize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Helper functions for serializing compiled JSONPath queries."""

import json


def canonical_string(value: str) -> str:
"""Return _value_ as a canonically formatted string literal."""
single_quoted = json.dumps(value)[1:-1].replace('\\"', '"').replace("'", "\\'")
return f"'{single_quoted}'"
1 change: 1 addition & 0 deletions tests/nts
Submodule nts added at c9288b
51 changes: 51 additions & 0 deletions tests/test_nts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Test Python JSONPath against the Normalized Path Test Suite."""

import json
import operator
from dataclasses import dataclass
from typing import List

import pytest

import jsonpath_rfc9535 as jsonpath
from jsonpath_rfc9535.environment import JSONValue


@dataclass
class NormalizedCase:
name: str
query: str
document: JSONValue
paths: List[str]


def normalized_cases() -> List[NormalizedCase]:
with open("tests/nts/normalized_paths.json", encoding="utf8") as fd:
data = json.load(fd)
return [NormalizedCase(**case) for case in data["tests"]]


@pytest.mark.parametrize("case", normalized_cases(), ids=operator.attrgetter("name"))
def test_nts_normalized_paths(case: NormalizedCase) -> None:
nodes = jsonpath.find(case.query, case.document)
paths = [node.path() for node in nodes]
assert paths == case.paths


@dataclass
class CanonicalCase:
name: str
query: str
canonical: str


def canonical_cases() -> List[CanonicalCase]:
with open("tests/nts/canonical_paths.json", encoding="utf8") as fd:
data = json.load(fd)
return [CanonicalCase(**case) for case in data["tests"]]


@pytest.mark.parametrize("case", canonical_cases(), ids=operator.attrgetter("name"))
def test_nts_canonical_paths(case: CanonicalCase) -> None:
query = jsonpath.compile(case.query)
assert str(query) == case.canonical
14 changes: 7 additions & 7 deletions tests/test_parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ class Case:
Case(
description="filter with string literal",
query="$.some[?(@.thing == 'foo')]",
want="$['some'][?@['thing'] == \"foo\"]",
want="$['some'][?@['thing'] == 'foo']",
),
Case(
description="filter with integer literal",
Expand All @@ -104,32 +104,32 @@ class Case:
Case(
description="filter with logical not",
query="$.some[?(@.thing > 1 && !$.other)]",
want="$['some'][?(@['thing'] > 1 && !$['other'])]",
want="$['some'][?@['thing'] > 1 && !$['other']]",
),
Case(
description="filter with grouped expression",
query="$.some[?(@.thing > 1 && ($.foo || $.bar))]",
want="$['some'][?(@['thing'] > 1 && ($['foo'] || $['bar']))]",
want="$['some'][?@['thing'] > 1 && ($['foo'] || $['bar'])]",
),
Case(
description="comparison to single quoted string literal with escape",
query="$[?@.foo == 'ba\\'r']",
want="$[?@['foo'] == \"ba'r\"]",
want="$[?@['foo'] == 'ba\\'r']",
),
Case(
description="comparison to double quoted string literal with escape",
query='$[?@.foo == "ba\\"r"]',
want='$[?@[\'foo\'] == "ba\\"r"]',
want="$[?@['foo'] == 'ba\"r']",
),
Case(
description="not binds more tightly than or",
query="$[?!@.a || !@.b]",
want="$[?(!@['a'] || !@['b'])]",
want="$[?!@['a'] || !@['b']]",
),
Case(
description="not binds more tightly than and",
query="$[?!@.a && !@.b]",
want="$[?(!@['a'] && !@['b'])]",
want="$[?!@['a'] && !@['b']]",
),
Case(
description="control precedence with parens",
Expand Down
Loading