diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 904f462..5ae0f67 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - python-version: [3.6, 3.7, 3.8, 3.9] + python-version: [3.7, 3.8, 3.9] steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 @@ -31,4 +31,3 @@ jobs: - name: Run type checking run: | mypy pygeofilter - diff --git a/pygeofilter/ast.py b/pygeofilter/ast.py index d9289a1..f5e7401 100644 --- a/pygeofilter/ast.py +++ b/pygeofilter/ast.py @@ -25,9 +25,6 @@ # THE SOFTWARE. # ------------------------------------------------------------------------------ -""" -""" - from enum import Enum from dataclasses import dataclass from typing import List, Optional, ClassVar, Union @@ -693,7 +690,7 @@ def get_template(self) -> str: def indent(text: str, amount: int, ch: str = ' ') -> str: padding = amount * ch - return ''.join(padding+line for line in text.splitlines(True)) + return ''.join(padding + line for line in text.splitlines(True)) def get_repr(node: Node, indent_amount: int = 0, indent_incr: int = 4) -> str: diff --git a/pygeofilter/backends/cql2_json/__init__.py b/pygeofilter/backends/cql2_json/__init__.py new file mode 100644 index 0000000..fb51d8f --- /dev/null +++ b/pygeofilter/backends/cql2_json/__init__.py @@ -0,0 +1,3 @@ +from .evaluate import to_cql2 + +__all__ = ["to_cql2"] diff --git a/pygeofilter/backends/cql2_json/evaluate.py b/pygeofilter/backends/cql2_json/evaluate.py new file mode 100644 index 0000000..9a53105 --- /dev/null +++ b/pygeofilter/backends/cql2_json/evaluate.py @@ -0,0 +1,119 @@ +# ------------------------------------------------------------------------------ +# +# Project: pygeofilter +# Authors: Fabian Schindler , +# David Bitner +# +# ------------------------------------------------------------------------------ +# Copyright (C) 2021 EOX IT Services GmbH +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies of this Software or works derived from this Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# ------------------------------------------------------------------------------ + +from typing import Dict, Optional +from datetime import datetime + +import shapely.geometry + +from ..evaluator import Evaluator, handle +from ... import ast +from ...cql2 import get_op +from ... import values + + +class CQL2Evaluator(Evaluator): + def __init__( + self, + attribute_map: Optional[Dict[str, str]], + function_map: Optional[Dict[str, str]], + ): + self.attribute_map = attribute_map + self.function_map = function_map + + @handle( + ast.Condition, + ast.Comparison, + ast.TemporalPredicate, + ast.SpatialComparisonPredicate, + ast.Arithmetic, + ast.ArrayPredicate, + subclasses=True, + ) + def comparison(self, node, *args): + op = get_op(node) + return {"op": op, "args": [*args]} + + @handle(ast.Between) + def between(self, node, lhs, low, high): + return {"op": "between", "args": [lhs, [low, high]]} + + @handle(ast.Like) + def like(self, node, *subargs): + return {"op": "like", "args": [node.lhs, node.pattern]} + + @handle(ast.IsNull) + def isnull(self, node, arg): + return {"op": "isNull", "args": arg} + + @handle(ast.Function) + def function(self, node, *args): + name = node.name.lower() + if name == "lower": + ret = {"lower": args[0]} + else: + ret = {"function": name, "args": [*args]} + return ret + + @handle(ast.In) + def in_(self, node, lhs, *options): + return {"in": {"value": lhs, "list": options}} + + @handle(ast.Attribute) + def attribute(self, node: ast.Attribute): + return {"property": node.name} + + @handle(values.Interval) + def interval(self, node: values.Interval): + return {"interval": [node.start, node.end]} + + @handle(datetime) + def datetime(self, node: ast.Attribute): + return {"timestamp": node.name} + + @handle(*values.LITERALS) + def literal(self, node): + return node + + @handle(values.Geometry) + def geometry(self, node: values.Geometry): + return shapely.geometry.shape(node).__geo_interface__ + + @handle(values.Envelope) + def envelope(self, node: values.Envelope): + return shapely.geometry.box( + node.x1, node.y1, node.x2, node.y2 + ).__geo_interface__ + + +def to_cql2( + root: ast.Node, + field_mapping: Optional[Dict[str, str]] = None, + function_map: Optional[Dict[str, str]] = None, +) -> str: + return CQL2Evaluator(field_mapping, function_map).evaluate(root) diff --git a/pygeofilter/backends/evaluator.py b/pygeofilter/backends/evaluator.py index 22859dc..0591fed 100644 --- a/pygeofilter/backends/evaluator.py +++ b/pygeofilter/backends/evaluator.py @@ -32,17 +32,15 @@ def get_all_subclasses(*classes: Type) -> List[Type]: - """ Utility function to get all the leaf-classes (classes that don't - have any further sub-classes) from a given list of classes. + """Utility function to get all the leaf-classes (classes that don't + have any further sub-classes) from a given list of classes. """ all_subclasses = [] for cls in classes: subclasses = cls.__subclasses__() if subclasses: - all_subclasses.extend( - get_all_subclasses(*subclasses) - ) + all_subclasses.extend(get_all_subclasses(*subclasses)) else: # directly insert classes that do not have any sub-classes all_subclasses.append(cls) @@ -51,8 +49,8 @@ def get_all_subclasses(*classes: Type) -> List[Type]: def handle(*node_classes: Type, subclasses: bool = False) -> Callable: - """ Function-decorator to mark a class function as a handler for a - given node type. + """Function-decorator to mark a class function as a handler for a + given node type. """ assert node_classes @@ -68,42 +66,45 @@ def inner(func): class EvaluatorMeta(type): - """ Metaclass for the ``Evaluator`` class to create a static map for - all handler methods by their respective handled types. + """Metaclass for the ``Evaluator`` class to create a static map for + all handler methods by their respective handled types. """ + def __init__(cls, name, bases, dct): cls.handler_map = {} for base in bases: - cls.handler_map.update(getattr(base, 'handler_map')) + cls.handler_map.update(getattr(base, "handler_map")) for value in dct.values(): - if hasattr(value, 'handles_classes'): + if hasattr(value, "handles_classes"): for handled_class in value.handles_classes: cls.handler_map[handled_class] = value class Evaluator(metaclass=EvaluatorMeta): - """ Base class for AST evaluators. - """ + """Base class for AST evaluators.""" handler_map: Dict[Type, Callable] def evaluate(self, node: ast.AstType, adopt_result: bool = True) -> Any: - """ Recursive function to evaluate an abstract syntax tree. - For every node in the walked syntax tree, its registered handler - is called with the node as first parameter and all pre-evaluated - child nodes as star-arguments. - When no handler was found for a given node, the ``adopt`` function - is called with the node and its arguments, which by default raises - an ``NotImplementedError``. + """Recursive function to evaluate an abstract syntax tree. + For every node in the walked syntax tree, its registered handler + is called with the node as first parameter and all pre-evaluated + child nodes as star-arguments. + When no handler was found for a given node, the ``adopt`` function + is called with the node and its arguments, which by default raises + an ``NotImplementedError``. """ - if hasattr(node, 'get_sub_nodes'): - sub_args = [ - self.evaluate(sub_node, False) - for sub_node in cast(ast.Node, node).get_sub_nodes() - ] - else: - sub_args = [] + sub_args = [] + if hasattr(node, "get_sub_nodes"): + subnodes = cast(ast.Node, node).get_sub_nodes() + if subnodes: + if isinstance(subnodes, list): + sub_args = [ + self.evaluate(sub_node, False) for sub_node in subnodes + ] + else: + sub_args = [self.evaluate(subnodes, False)] handler = self.handler_map.get(type(node)) if handler is not None: @@ -117,15 +118,15 @@ def evaluate(self, node: ast.AstType, adopt_result: bool = True) -> Any: return result def adopt(self, node, *sub_args): - """ Interface function for a last resort when trying to evaluate a node - and no handler was found. + """Interface function for a last resort when trying to evaluate a node + and no handler was found. """ raise NotImplementedError( - f'Failed to evaluate node of type {type(node)}' + f"Failed to evaluate node of type {type(node)}" ) def adopt_result(self, result: Any) -> Any: - """ Interface function for adopting the final evaluation result if necessary. - Default is no-op. + """Interface function for adopting the final evaluation result if necessary. + Default is no-op. """ return result diff --git a/pygeofilter/cql2.py b/pygeofilter/cql2.py new file mode 100644 index 0000000..0ebb131 --- /dev/null +++ b/pygeofilter/cql2.py @@ -0,0 +1,100 @@ +# Common configurations for cql2 parsers and evaluators. +from typing import Dict, Type, Union +from . import ast + +# https://github.com/opengeospatial/ogcapi-features/tree/master/cql2 + + +COMPARISON_MAP: Dict[str, Type[ast.Node]] = { + "=": ast.Equal, + "eq": ast.Equal, + "<>": ast.NotEqual, + "!=": ast.NotEqual, + "ne": ast.NotEqual, + "<": ast.LessThan, + "lt": ast.LessThan, + "<=": ast.LessEqual, + "lte": ast.LessEqual, + ">": ast.GreaterThan, + "gt": ast.GreaterThan, + ">=": ast.GreaterEqual, + "gte": ast.GreaterEqual, + "like": ast.Like, +} + +SPATIAL_PREDICATES_MAP: Dict[str, Type[ast.SpatialComparisonPredicate]] = { + "s_intersects": ast.GeometryIntersects, + "s_equals": ast.GeometryEquals, + "s_disjoint": ast.GeometryDisjoint, + "s_touches": ast.GeometryTouches, + "s_within": ast.GeometryWithin, + "s_overlaps": ast.GeometryOverlaps, + "s_crosses": ast.GeometryCrosses, + "s_contains": ast.GeometryContains, +} + +TEMPORAL_PREDICATES_MAP: Dict[str, Type[ast.TemporalPredicate]] = { + "t_before": ast.TimeBefore, + "t_after": ast.TimeAfter, + "t_meets": ast.TimeMeets, + "t_metby": ast.TimeMetBy, + "t_overlaps": ast.TimeOverlaps, + "t_overlappedby": ast.TimeOverlappedBy, + "t_begins": ast.TimeBegins, + "t_begunby": ast.TimeBegunBy, + "t_during": ast.TimeDuring, + "t_contains": ast.TimeContains, + "t_ends": ast.TimeEnds, + "t_endedby": ast.TimeEndedBy, + "t_equals": ast.TimeEquals, + "t_intersects": ast.TimeOverlaps, +} + + +ARRAY_PREDICATES_MAP: Dict[str, Type[ast.ArrayPredicate]] = { + "a_equals": ast.ArrayEquals, + "a_contains": ast.ArrayContains, + "a_containedby": ast.ArrayContainedBy, + "a_overlaps": ast.ArrayOverlaps, +} + +ARITHMETIC_MAP: Dict[str, Type[ast.Arithmetic]] = { + "+": ast.Add, + "-": ast.Sub, + "*": ast.Mul, + "/": ast.Div, +} + +CONDITION_MAP: Dict[str, Type[ast.Node]] = { + "and": ast.And, + "or": ast.Or, + "not": ast.Not, + "isNull": ast.IsNull, +} + +BINARY_OP_PREDICATES_MAP: Dict[ + str, + Union[ + Type[ast.Node], + Type[ast.Comparison], + Type[ast.SpatialComparisonPredicate], + Type[ast.TemporalPredicate], + Type[ast.ArrayPredicate], + Type[ast.Arithmetic], + ], +] = { + **COMPARISON_MAP, + **SPATIAL_PREDICATES_MAP, + **TEMPORAL_PREDICATES_MAP, + **ARRAY_PREDICATES_MAP, + **ARITHMETIC_MAP, + **CONDITION_MAP, +} + + +def get_op(node: ast.Node) -> Union[str, None]: + # Get the cql2 operator string from a node. + for k, v in BINARY_OP_PREDICATES_MAP.items(): + if isinstance(node, v): + return k + return None diff --git a/pygeofilter/examples/cql2.ipynb b/pygeofilter/examples/cql2.ipynb new file mode 100644 index 0000000..d13341b --- /dev/null +++ b/pygeofilter/examples/cql2.ipynb @@ -0,0 +1,179 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "fe8453fa", + "metadata": {}, + "outputs": [], + "source": [ + "from pygeofilter.parsers.cql2_json import parse\n", + "from pygeofilter.backends.cql2_json import to_cql2\n", + "import json\n", + "import traceback\n", + "from lark import lark, logger, v_args\n", + "from pygeofilter.cql2 import BINARY_OP_PREDICATES_MAP\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "b960603d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "And(lhs=And(lhs=And(lhs=Equal(lhs=ATTRIBUTE collection, rhs='landsat8_l1tp'), rhs=LessEqual(lhs=ATTRIBUTE gsd, rhs=30)), rhs=LessEqual(lhs=ATTRIBUTE eo:cloud_cover, rhs=10)), rhs=GreaterEqual(lhs=ATTRIBUTE datetime, rhs=datetime.datetime(2021, 4, 8, 4, 39, 23, tzinfo=)))" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from pygeofilter.parsers.cql2_text import parse as cql2_parse\n", + "cql2_parse(\"collection = 'landsat8_l1tp' AND gsd <= 30 AND eo:cloud_cover <= 10 AND datetime >= TIMESTAMP('2021-04-08T04:39:23Z')\")" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "c5f47281", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Example 1\n", + "*******parsed trees match***************\n", + "*******reconstructed json matches*******\n", + "____________________________________________________________\n", + "Example 2\n", + "*******parsed trees match***************\n", + "*******reconstructed json matches*******\n", + "____________________________________________________________\n", + "Example 3\n", + "*******parsed trees match***************\n", + "*******reconstructed json matches*******\n", + "____________________________________________________________\n", + "Example 4\n", + "*******parsed trees match***************\n", + "*******reconstructed json matches*******\n", + "____________________________________________________________\n", + "Example 5\n", + "*******parsed trees match***************\n", + "*******reconstructed json matches*******\n", + "____________________________________________________________\n", + "Example 6\n", + "*******parsed trees match***************\n", + "*******reconstructed json matches*******\n", + "____________________________________________________________\n", + "Example 7\n", + "*******parsed trees match***************\n", + "*******reconstructed json matches*******\n", + "____________________________________________________________\n", + "Example 8\n", + "*******parsed trees match***************\n", + "*******reconstructed json matches*******\n", + "____________________________________________________________\n", + "Example 9\n", + "*******parsed trees match***************\n", + "*******reconstructed json matches*******\n", + "____________________________________________________________\n", + "Example 10\n", + "*******parsed trees match***************\n", + "*******reconstructed json matches*******\n", + "____________________________________________________________\n", + "Example 11\n", + "*******parsed trees match***************\n", + "*******reconstructed json matches*******\n", + "____________________________________________________________\n", + "Example 12\n", + "*******parsed trees match***************\n", + "*******reconstructed json matches*******\n", + "____________________________________________________________\n" + ] + } + ], + "source": [ + "from pygeofilter.parsers.cql2_text import parse as text_parse\n", + "from pygeofilter.parsers.cql2_json import parse as json_parse\n", + "from pygeofilter.backends.cql2_json import to_cql2\n", + "import orjson\n", + "import json\n", + "import pprint\n", + "def pp(j):\n", + " print(orjson.dumps(j))\n", + "with open('tests/parsers/cql2_json/fixtures.json') as f:\n", + " examples = json.load(f)\n", + "\n", + "for k, v in examples.items():\n", + " parsed_text = None\n", + " parsed_json = None\n", + " print (k)\n", + " t=v['text'].replace('filter=','')\n", + " j=v['json']\n", + " # print('\\t' + t)\n", + " # pp(orjson.loads(j))\n", + " # print('*****')\n", + " try:\n", + " parsed_text=text_parse(t)\n", + " parsed_json=json_parse(j)\n", + " if parsed_text == parsed_json:\n", + " print('*******parsed trees match***************')\n", + " else:\n", + " print(parsed_text)\n", + " print('-----')\n", + " print(parsed_json)\n", + " if parsed_json is None or parsed_text is None:\n", + " raise Exception\n", + " if to_cql2(parsed_text) == to_cql2(parsed_json):\n", + " print('*******reconstructed json matches*******')\n", + " else:\n", + " pp(to_cql2(parsed_text))\n", + " print('-----')\n", + " pp(to_cql2(parsed_json))\n", + " except Exception as e:\n", + " print(parsed_text)\n", + " print(parsed_json)\n", + " print(j)\n", + " traceback.print_exc(f\"Error: {e}\")\n", + " pass\n", + " print('____________________________________________________________')\n", + " " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ac0bb004", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "pygeofilter", + "language": "python", + "name": "pygeofilter" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pygeofilter/parsers/cql2_json/parser.py b/pygeofilter/parsers/cql2_json/parser.py index 405aa25..07b0ef8 100644 --- a/pygeofilter/parsers/cql2_json/parser.py +++ b/pygeofilter/parsers/cql2_json/parser.py @@ -1,7 +1,8 @@ # ------------------------------------------------------------------------------ # # Project: pygeofilter -# Authors: Fabian Schindler +# Authors: Fabian Schindler , +# David Bitner # # ------------------------------------------------------------------------------ # Copyright (C) 2021 EOX IT Services GmbH @@ -26,114 +27,70 @@ # ------------------------------------------------------------------------------ from datetime import date, datetime, timedelta -from typing import Dict, List, Type, Union, cast +from typing import List, Union, cast import json from ... import ast from ... import values -from ... util import parse_datetime, parse_date, parse_duration +from ...util import parse_datetime, parse_date, parse_duration +from ...cql2 import BINARY_OP_PREDICATES_MAP # https://github.com/opengeospatial/ogcapi-features/tree/master/cql2 -COMPARISON_MAP: Dict[str, Type[ast.Comparison]] = { - 'eq': ast.Equal, - '=': ast.Equal, - 'ne': ast.NotEqual, - '!=': ast.NotEqual, - 'lt': ast.LessThan, - '<': ast.LessThan, - 'lte': ast.LessEqual, - '<=': ast.LessEqual, - 'gt': ast.GreaterThan, - '>': ast.GreaterThan, - 'gte': ast.GreaterEqual, - '>=': ast.GreaterEqual, -} - -SPATIAL_PREDICATES_MAP: Dict[str, Type[ast.SpatialComparisonPredicate]] = { - 's_intersects': ast.GeometryIntersects, - 's_equals': ast.GeometryEquals, - 's_disjoint': ast.GeometryDisjoint, - 's_touches': ast.GeometryTouches, - 's_within': ast.GeometryWithin, - 's_overlaps': ast.GeometryOverlaps, - 's_crosses': ast.GeometryCrosses, - 's_contains': ast.GeometryContains, -} - -TEMPORAL_PREDICATES_MAP = { - 't_before': ast.TimeBefore, - 't_after': ast.TimeAfter, - 't_meets': ast.TimeMeets, - 't_metby': ast.TimeMetBy, - 't_overlaps': ast.TimeOverlaps, - 't_overlappedby': ast.TimeOverlappedBy, - 't_begins': ast.TimeBegins, - 't_begunby': ast.TimeBegunBy, - 't_during': ast.TimeDuring, - 't_contains': ast.TimeContains, - 't_ends': ast.TimeEnds, - 't_endedby': ast.TimeEndedBy, - 't_equals': ast.TimeEquals, -} - - -ARRAY_PREDICATES_MAP = { - 'a_equals': ast.ArrayEquals, - 'a_contains': ast.ArrayContains, - 'a_containedBy': ast.ArrayContainedBy, - 'a_overlaps': ast.ArrayOverlaps, -} - -ARITHMETIC_MAP = { - '+': ast.Add, - '-': ast.Sub, - '*': ast.Mul, - '/': ast.Div, -} - - JsonType = Union[dict, list, str, float, int, bool, None] -def walk_cql_json(node: JsonType) -> ast.AstType: - if isinstance(node, (str, float, int, bool)): +def walk_cql_json(node: JsonType): + if isinstance( + node, + ( + str, + float, + int, + bool, + datetime, + values.Geometry, + values.Interval, + ast.Node, + ), + ): return node if isinstance(node, list): - return [ - walk_cql_json(sub_node) - for sub_node in node - ] + return [walk_cql_json(sub_node) for sub_node in node] if not isinstance(node, dict): - raise ValueError(f'Invalid type {type(node)}') + raise ValueError(f"Invalid type {type(node)}") + + if "filter-lang" in node and node["filter-lang"] != "cql2-json": + raise Exception(f"Cannot parse {node['filter-lang']} with cql2-json.") + + elif "filter" in node: + return walk_cql_json(node["filter"]) # check if we are dealing with a geometry - if 'type' in node and 'coordinates' in node: + if "type" in node and "coordinates" in node: # TODO: test if node is actually valid return values.Geometry(node) - elif 'bbox' in node: - return values.Envelope(*node['bbox']) + elif "bbox" in node: + return values.Envelope(*node["bbox"]) - elif 'date' in node: - return parse_date(node['date']) + elif "date" in node: + return parse_date(node["date"]) - elif 'timestamp' in node: - return parse_datetime(node['timestamp']) + elif "timestamp" in node: + return parse_datetime(node["timestamp"]) - elif 'interval' in node: + elif "interval" in node: parsed: List[Union[date, datetime, timedelta, None]] = [] - for value in node['interval']: - if value == '..': + for value in node["interval"]: + if value == "..": parsed.append(None) continue try: - parsed.append( - parse_date(value) - ) + parsed.append(parse_date(value)) except ValueError: try: parsed.append(parse_duration(value)) @@ -142,97 +99,80 @@ def walk_cql_json(node: JsonType) -> ast.AstType: return values.Interval(*parsed) - # decode all other nodes - for name, value in node.items(): - if name in ('and', 'or'): - sub_items = walk_cql_json(value) - return (ast.And if name == 'and' else ast.Or).from_items(sub_items) + elif "property" in node: + return ast.Attribute(node["property"]) + + elif "function" in node: + return ast.Function( + node["function"]["name"], + cast( + List[ast.AstType], walk_cql_json(node["function"]["arguments"]) + ), + ) - elif name == 'not': + elif "lower" in node: + return ast.Function( + "lower", [cast(ast.Node, walk_cql_json(node["lower"]))] + ) + + elif "op" in node: + op = node["op"] + args = walk_cql_json(node["args"]) + + if op in ("and", "or"): + return (ast.And if op == "and" else ast.Or).from_items(*args) + + elif op == "not": # allow both arrays and objects, the standard is ambigous in # that regard - if isinstance(value, list): - value = value[0] - return ast.Not(cast(ast.Node, walk_cql_json(value))) - - elif name in COMPARISON_MAP: - return COMPARISON_MAP[name]( - cast(ast.ScalarAstType, walk_cql_json(value[0])), - cast(ast.ScalarAstType, walk_cql_json(value[1])), - ) + if isinstance(args, list): + args = args[0] + return ast.Not(cast(ast.Node, walk_cql_json(args))) + + elif op == "isNull": + return ast.IsNull(cast(ast.Node, walk_cql_json(args)), False) - elif name == 'between': + elif op == "between": return ast.Between( - cast(ast.Node, walk_cql_json(value['value'])), - cast(ast.ScalarAstType, walk_cql_json(value['lower'])), - cast(ast.ScalarAstType, walk_cql_json(value['upper'])), + cast(ast.Node, walk_cql_json(args[0])), + cast(ast.ScalarAstType, walk_cql_json(args[1][0])), + cast(ast.ScalarAstType, walk_cql_json(args[1][1])), not_=False, ) - elif name == 'like': + elif op == "like": return ast.Like( - cast(ast.Node, walk_cql_json(value[0])), - cast(str, value[1]), + cast(ast.Node, walk_cql_json(args[0])), + pattern=cast(str, args[1]), nocase=False, - wildcard='%', - singlechar='.', - escapechar='\\', + wildcard="%", + singlechar=".", + escapechar="\\", not_=False, ) - elif name == 'in': + elif op == "in": return ast.In( - cast(ast.AstType, walk_cql_json(value['value'])), - cast(List[ast.AstType], walk_cql_json(value['list'])), + cast(ast.AstType, walk_cql_json(args[0])), + cast(List[ast.AstType], walk_cql_json(args[1])), not_=False, ) - elif name == 'isNull': + elif op == "isNull": return ast.IsNull( - walk_cql_json(value), + walk_cql_json(args), not_=False, ) - elif name in SPATIAL_PREDICATES_MAP: - return SPATIAL_PREDICATES_MAP[name]( - cast(ast.SpatialAstType, walk_cql_json(value[0])), - cast(ast.SpatialAstType, walk_cql_json(value[1])), - ) - - elif name in TEMPORAL_PREDICATES_MAP: - return TEMPORAL_PREDICATES_MAP[name]( - cast( - ast.TemporalAstType, - walk_cql_json(value[0]) - ), - cast( - ast.TemporalAstType, - walk_cql_json(value[1]) - ), - ) - - elif name in ARRAY_PREDICATES_MAP: - return ARRAY_PREDICATES_MAP[name]( - cast(ast.ArrayAstType, walk_cql_json(value[0])), - cast(ast.ArrayAstType, walk_cql_json(value[1])), - ) - - elif name in ARITHMETIC_MAP: - return ARITHMETIC_MAP[name]( - cast(ast.ScalarAstType, walk_cql_json(value[0])), - cast(ast.ScalarAstType, walk_cql_json(value[1])), - ) - - elif name == 'property': - return ast.Attribute(value) - - elif name == 'function': - return ast.Function( - value['name'], - cast(List[ast.AstType], walk_cql_json(value['arguments'])), + elif op in BINARY_OP_PREDICATES_MAP: + args = [ + cast(ast.Node, walk_cql_json(arg)) for arg in args + ] + return BINARY_OP_PREDICATES_MAP[op]( + *args ) - raise ValueError(f'Unable to parse expression node {node!r}') + raise ValueError(f"Unable to parse expression node {node!r}") def parse(cql: Union[str, dict]) -> ast.AstType: diff --git a/pygeofilter/parsers/cql2_text/__init__.py b/pygeofilter/parsers/cql2_text/__init__.py new file mode 100644 index 0000000..e5afb0e --- /dev/null +++ b/pygeofilter/parsers/cql2_text/__init__.py @@ -0,0 +1,3 @@ +from .parser import parse + +__all__ = ['parse'] diff --git a/pygeofilter/parsers/cql2_text/grammar.lark b/pygeofilter/parsers/cql2_text/grammar.lark new file mode 100644 index 0000000..0bdf722 --- /dev/null +++ b/pygeofilter/parsers/cql2_text/grammar.lark @@ -0,0 +1,151 @@ +// ------------------------------------------------------------------------------ +// +// Project: pygeofilter +// Authors: Fabian Schindler , David Bitner +// +// ------------------------------------------------------------------------------ +// Copyright (C) 2021 EOX IT Services GmbH +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies of this Software or works derived from this Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// ------------------------------------------------------------------------------ + +?start: condition + +?condition: condition_1 + | condition "AND"i condition_1 -> and_ + | condition "OR"i condition_1 -> or_ + +?condition_1: predicate + | "NOT"i predicate -> not_ + | "(" condition ")" + + +?predicate: expression "=" expression -> eq + | expression "eq"i expression -> eq + | expression "<>" expression -> ne + | expression "ne"i expression -> ne + | expression "!=" expression -> ne + | expression "<" expression -> lt + | expression "lt"i expression -> lt + | expression "<=" expression -> lte + | expression "lte"i expression -> lte + | expression ">" expression -> gt + | expression "gt"i expression -> gt + | expression ">=" expression -> gte + | expression "gte"i expression -> gte + | expression "BETWEEN"i expression "AND"i expression -> between + | expression "LIKE"i SINGLE_QUOTED -> like + | expression "IN"i "(" expression ( "," expression )* ")" -> in_ + | expression "IS"i "NULL"i -> null + | "INCLUDE"i -> include + | "EXCLUDE"i -> exclude + | spatial_predicate + | temporal_predicate + + + +?temporal_predicate: expression _binary_temporal_predicate_func expression -> binary_temporal_predicate + +!_binary_temporal_predicate_func: "T_BEFORE"i + | "T_AFTER"i + | "T_MEETS"i + | "T_METBY"i + | "T_OVERLAPS"i + | "T_OVERLAPPEDBY"i + | "T_BEGINS"i + | "T_BEGUNBY"i + | "T_DURING"i + | "T_CONTAINS"i + | "T_ENDS"i + | "T_ENDEDBY"i + | "T_EQUALS"i + | "T_INTERSECTS"i + + +?spatial_predicate: _binary_spatial_predicate_func "(" expression "," expression ")" -> binary_spatial_predicate + | "RELATE" "(" expression "," expression "," SINGLE_QUOTED ")" -> relate_spatial_predicate + | "BBOX" "(" expression "," full_number "," full_number "," full_number "," full_number [ "," SINGLE_QUOTED] ")" -> bbox_spatial_predicate + +!_binary_spatial_predicate_func: "S_INTERSECTS"i + | "S_DISJOINT"i + | "S_CONTAINS"i + | "S_WITHIN"i + | "S_TOUCHES"i + | "S_CROSSES"i + | "S_OVERLAPS"i + | "S_EQUALS"i + + +?expression: sum + +?sum: product + | sum "+" product -> add + | sum "-" product -> sub + +?product: atom + | product "*" atom -> mul + | product "/" atom -> div + +?atom: func + | attribute + | literal + | "-" atom -> neg + | "(" expression ")" + +func.2: attribute "(" expression ("," expression)* ")" -> function + + +?literal: timestamp + | interval + | number + | BOOLEAN + | SINGLE_QUOTED + | ewkt_geometry -> geometry + | envelope + +?full_number: number + | "-" number -> neg + +?number: FLOAT | INT + +envelope: "ENVELOPE"i "(" number number number number ")" + +BOOLEAN: ( "TRUE" | "FALSE" ) + +DOUBLE_QUOTED: "\"" /.*?/ "\"" +SINGLE_QUOTED: "'" /.*?/ "'" + +DATETIME: /[0-9]{4}-?[0-1][0-9]-?[0-3][0-9][T ][0-2][0-9]:?[0-5][0-9]:?[0-5][0-9](\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})?/ +?timestamp: "TIMESTAMP" "(" "'" DATETIME "'" ")" +?interval: "INTERVAL" "(" "'" DATETIME "'" "," "'" DATETIME "'" ")" + + + +attribute: /[a-zA-Z][a-zA-Z_:0-9]+/ + | DOUBLE_QUOTED + + +// NAME: /[a-z_]+/ +%import .wkt.ewkt_geometry + +// %import common.CNAME -> NAME +%import common.INT +%import common.FLOAT +%import common.WS_INLINE +%ignore WS_INLINE diff --git a/pygeofilter/parsers/cql2_text/parser.py b/pygeofilter/parsers/cql2_text/parser.py new file mode 100644 index 0000000..9eec0d2 --- /dev/null +++ b/pygeofilter/parsers/cql2_text/parser.py @@ -0,0 +1,216 @@ +# ------------------------------------------------------------------------------ +# +# Project: pygeofilter +# Authors: Fabian Schindler , +# David Bitner +# +# ------------------------------------------------------------------------------ +# Copyright (C) 2021 EOX IT Services GmbH +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies of this Software or works derived from this Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import os.path +import logging + +from lark import Lark, logger, v_args + +from ... import ast +from ... import values +from ..wkt import WKTTransformer +from ..iso8601 import ISO8601Transformer +from ...cql2 import SPATIAL_PREDICATES_MAP, TEMPORAL_PREDICATES_MAP + +logger.setLevel(logging.DEBUG) + + +@v_args(inline=True) +class CQLTransformer(WKTTransformer, ISO8601Transformer): + def and_(self, *args): + return ast.And.from_items(*args) + + def or_(self, *args): + return ast.Or.from_items(*args) + + def not_(self, node): + return ast.Not(node) + + def eq(self, lhs, rhs): + return ast.Equal(lhs, rhs) + + def ne(self, lhs, rhs): + return ast.NotEqual(lhs, rhs) + + def lt(self, lhs, rhs): + return ast.LessThan(lhs, rhs) + + def lte(self, lhs, rhs): + return ast.LessEqual(lhs, rhs) + + def gt(self, lhs, rhs): + return ast.GreaterThan(lhs, rhs) + + def gte(self, lhs, rhs): + return ast.GreaterEqual(lhs, rhs) + + def between(self, lhs, low, high): + return ast.Between(lhs, low, high, False) + + def not_between(self, lhs, low, high): + return ast.Between(lhs, low, high, True) + + def like(self, node, pattern): + return ast.Like(node, pattern, False, "%", ".", "\\", False) + + def not_like(self, node, pattern): + return ast.Like(node, pattern, False, "%", ".", "\\", True) + + def ilike(self, node, pattern): + return ast.Like(node, pattern, True, "%", ".", "\\", False) + + def not_ilike(self, node, pattern): + return ast.Like(node, pattern, True, "%", ".", "\\", True) + + def in_(self, node, *options): + return ast.In(node, list(options), False) + + def not_in(self, node, *options): + return ast.In(node, list(options), True) + + def null(self, node): + return ast.IsNull(node, False) + + def not_null(self, node): + return ast.IsNull(node, True) + + def exists(self, attribute): + return ast.Exists(attribute, False) + + def does_not_exist(self, attribute): + return ast.Exists(attribute, True) + + def include(self): + return ast.Include(False) + + def exclude(self): + return ast.Include(True) + + def before(self, node, dt): + return ast.TimeBefore(node, dt) + + def before_or_during(self, node, period): + return ast.TimeBeforeOrDuring(node, period) + + def during(self, node, period): + return ast.TimeDuring(node, period) + + def during_or_after(self, node, period): + return ast.TimeDuringOrAfter(node, period) + + def after(self, node, dt): + return ast.TimeAfter(node, dt) + + def binary_spatial_predicate(self, op, lhs, rhs): + op = op.lower() + return SPATIAL_PREDICATES_MAP[op](lhs, rhs) + + def binary_temporal_predicate(self, lhs, op, rhs): + op = op.lower() + return TEMPORAL_PREDICATES_MAP[op](lhs, rhs) + + def relate_spatial_predicate(self, lhs, rhs, pattern): + return ast.Relate(lhs, rhs, pattern) + + def distance_spatial_predicate(self, op, lhs, rhs, distance, units): + cls = ast.DistanceWithin if op == "DWITHIN" else ast.DistanceBeyond + return cls(lhs, rhs, distance, units) + + def distance_units(self, value): + return value + + def bbox_spatial_predicate(self, lhs, minx, miny, maxx, maxy, crs=None): + return ast.BBox(lhs, minx, miny, maxx, maxy, crs) + + def function(self, func_name, *expressions): + name = func_name.name.lower() + if name == "casei": + name = "lower" + return ast.Function(name, list(expressions)) + + def add(self, lhs, rhs): + return ast.Add(lhs, rhs) + + def sub(self, lhs, rhs): + return ast.Sub(lhs, rhs) + + def mul(self, lhs, rhs): + return ast.Mul(lhs, rhs) + + def div(self, lhs, rhs): + return ast.Div(lhs, rhs) + + def neg(self, value): + return -value + + def attribute(self, name): + return ast.Attribute(str(name)) + + def period(self, start, end): + return [start, end] + + def INT(self, value): + return int(value) + + def FLOAT(self, value): + return float(value) + + def boolean(self, value): + return value in ("TRUE", "true", "T", "t", "1") + + def DOUBLE_QUOTED(self, token): + return token[1:-1] + + def SINGLE_QUOTED(self, token): + return token[1:-1] + + def geometry(self, value): + return values.Geometry(value) + + def envelope(self, x1, x2, y1, y2): + return values.Envelope(x1, x2, y1, y2) + + def interval(self, start, end): + return values.Interval(start, end) + + +parser = Lark.open( + "grammar.lark", + rel_to=__file__, + parser="lalr", + debug=True, + transformer=CQLTransformer(), + import_paths=[os.path.dirname(os.path.dirname(__file__))], +) + + +def parse(cql_text): + return parser.parse(cql_text) + + +if __name__ == "__main__": + print(parse("'abc' < 'bce'")) diff --git a/pygeofilter/parsers/cql_json/parser.py b/pygeofilter/parsers/cql_json/parser.py index 2a0ab6d..1e257ea 100644 --- a/pygeofilter/parsers/cql_json/parser.py +++ b/pygeofilter/parsers/cql_json/parser.py @@ -32,6 +32,7 @@ from ...values import Envelope, Geometry from ... import ast from ... import values +from datetime import datetime # https://portal.ogc.org/files/96288 @@ -109,7 +110,7 @@ def walk_cql_json(node: dict, is_temporal: bool = False) -> ast.AstType: if isinstance(node, list): result = [ - walk_cql_json(sub_node, is_temporal) + cast(datetime, walk_cql_json(sub_node, is_temporal)) for sub_node in node ] if is_temporal: diff --git a/pygeofilter/parsers/wkt.lark b/pygeofilter/parsers/wkt.lark index 5611880..a0d08cf 100644 --- a/pygeofilter/parsers/wkt.lark +++ b/pygeofilter/parsers/wkt.lark @@ -51,7 +51,9 @@ coordinate_lists: "(" coordinate_list ")" ( "," "(" coordinate_list ")" )* ?coordinate_list: coordinate_list "," coordinate | coordinate -> coordinate_list_start -coordinate: NUMBER NUMBER [ NUMBER [ NUMBER ] ] +coordinate: SIGNED_NUMBER SIGNED_NUMBER [ SIGNED_NUMBER [ SIGNED_NUMBER ] ] +// NUMBER: /-?\d+\.?\d+/ %import common.NUMBER -%import common.INT \ No newline at end of file +%import common.SIGNED_NUMBER +%import common.INT diff --git a/pygeofilter/parsers/wkt.py b/pygeofilter/parsers/wkt.py index ebefe68..19cb729 100644 --- a/pygeofilter/parsers/wkt.py +++ b/pygeofilter/parsers/wkt.py @@ -101,5 +101,8 @@ def wkt__coordinate_list_start(self, coordinate_list): def wkt__coordinate(self, *components): return components + def wkt__SIGNED_NUMBER(self, value): + return float(value) + def wkt__NUMBER(self, value): return float(value) diff --git a/tests/parsers/cql2_json/fixtures.json b/tests/parsers/cql2_json/fixtures.json new file mode 100644 index 0000000..245a59f --- /dev/null +++ b/tests/parsers/cql2_json/fixtures.json @@ -0,0 +1,50 @@ +{ + "Example 1": { + "text": "filter=id='LC08_L1TP_060247_20180905_20180912_01_T1_L1TP' AND collection='landsat8_l1tp'", + "json": "{\"filter\": {\"op\": \"and\", \"args\": [{\"op\": \"=\", \"args\": [{\"property\": \"id\"}, \"LC08_L1TP_060247_20180905_20180912_01_T1_L1TP\"]}, {\"op\": \"=\", \"args\": [{\"property\": \"collection\"}, \"landsat8_l1tp\"]}]}}" + }, + "Example 2": { + "text": "filter=collection = 'landsat8_l1tp' AND eo:cloud_cover <= 10 AND datetime >= TIMESTAMP('2021-04-08T04:39:23Z') AND S_INTERSECTS(geometry, POLYGON((43.5845 -79.5442, 43.6079 -79.4893, 43.5677 -79.4632, 43.6129 -79.3925, 43.6223 -79.3238, 43.6576 -79.3163, 43.7945 -79.1178, 43.8144 -79.1542, 43.8555 -79.1714, 43.7509 -79.6390, 43.5845 -79.5442)))", + "json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"and\", \"args\": [{\"op\": \"=\", \"args\": [{\"property\": \"collection\"}, \"landsat8_l1tp\"]}, {\"op\": \"<=\", \"args\": [{\"property\": \"eo:cloud_cover\"}, 10]}, {\"op\": \">=\", \"args\": [{\"property\": \"datetime\"}, {\"timestamp\": \"2021-04-08T04:39:23Z\"}]}, {\"op\": \"s_intersects\", \"args\": [{\"property\": \"geometry\"}, {\"type\": \"Polygon\", \"coordinates\": [[[43.5845, -79.5442], [43.6079, -79.4893], [43.5677, -79.4632], [43.6129, -79.3925], [43.6223, -79.3238], [43.6576, -79.3163], [43.7945, -79.1178], [43.8144, -79.1542], [43.8555, -79.1714], [43.7509, -79.639], [43.5845, -79.5442]]]}]}]}}" + }, + "Example 3": { + "text": "filter=sentinel:data_coverage > 50 AND eo:cloud_cover < 10 ", + "json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"and\", \"args\": [{\"op\": \">\", \"args\": [{\"property\": \"sentinel:data_coverage\"}, 50]}, {\"op\": \"<\", \"args\": [{\"property\": \"eo:cloud_cover\"}, 10]}]}}" + }, + "Example 4": { + "text": "filter=sentinel:data_coverage > 50 OR eo:cloud_cover < 10 ", + "json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"or\", \"args\": [{\"op\": \">\", \"args\": [{\"property\": \"sentinel:data_coverage\"}, 50]}, {\"op\": \"<\", \"args\": [{\"property\": \"eo:cloud_cover\"}, 10]}]}}" + }, + "Example 5": { + "text": "filter=prop1 = prop2", + "json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"=\", \"args\": [{\"property\": \"prop1\"}, {\"property\": \"prop2\"}]}}" + }, + "Example 6": { + "text": "filter=datetime T_INTERSECTS INTERVAL('2020-11-11T00:00:00Z', '2020-11-12T00:00:00Z')", + "json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"t_intersects\", \"args\": [{\"property\": \"datetime\"}, {\"interval\": [\"2020-11-11T00:00:00Z\", \"2020-11-12T00:00:00Z\"]}]}}" + }, + "Example 7": { + "text": "filter=S_INTERSECTS(geometry,POLYGON((-77.0824 38.7886,-77.0189 38.7886,-77.0189 38.8351,-77.0824 38.8351,-77.0824 38.7886)))", + "json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"s_intersects\", \"args\": [{\"property\": \"geometry\"}, {\"type\": \"Polygon\", \"coordinates\": [[[-77.0824, 38.7886], [-77.0189, 38.7886], [-77.0189, 38.8351], [-77.0824, 38.8351], [-77.0824, 38.7886]]]}]}}" + }, + "Example 8": { + "text": "filter=S_INTERSECTS(geometry,POLYGON((-77.0824 38.7886,-77.0189 38.7886,-77.0189 38.8351,-77.0824 38.8351,-77.0824 38.7886))) OR S_INTERSECTS(geometry,POLYGON((-79.0935 38.7886,-79.0290 38.7886,-79.0290 38.8351,-79.0935 38.8351,-79.0935 38.7886)))", + "json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"or\", \"args\": [{\"op\": \"s_intersects\", \"args\": [{\"property\": \"geometry\"}, {\"type\": \"Polygon\", \"coordinates\": [[[-77.0824, 38.7886], [-77.0189, 38.7886], [-77.0189, 38.8351], [-77.0824, 38.8351], [-77.0824, 38.7886]]]}]}, {\"op\": \"s_intersects\", \"args\": [{\"property\": \"geometry\"}, {\"type\": \"Polygon\", \"coordinates\": [[[-79.0935, 38.7886], [-79.029, 38.7886], [-79.029, 38.8351], [-79.0935, 38.8351], [-79.0935, 38.7886]]]}]}]}}" + }, + "Example 9": { + "text": "filter=sentinel:data_coverage > 50 OR landsat:coverage_percent < 10 OR (sentinel:data_coverage IS NULL AND landsat:coverage_percent IS NULL)", + "json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"or\", \"args\": [{\"op\": \">\", \"args\": [{\"property\": \"sentinel:data_coverage\"}, 50]}, {\"op\": \"<\", \"args\": [{\"property\": \"landsat:coverage_percent\"}, 10]}, {\"op\": \"and\", \"args\": [{\"op\": \"isNull\", \"args\": {\"property\": \"sentinel:data_coverage\"}}, {\"op\": \"isNull\", \"args\": {\"property\": \"landsat:coverage_percent\"}}]}]}}" + }, + "Example 10": { + "text": "filter=eo:cloud_cover BETWEEN 0 AND 50", + "json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"between\", \"args\": [{\"property\": \"eo:cloud_cover\"}, [0, 50]]}}" + }, + "Example 11": { + "text": "filter=mission LIKE 'sentinel%'", + "json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"like\", \"args\": [{\"property\": \"mission\"}, \"sentinel%\"]}}" + }, + "Example 12": { + "text": "filter=CASEI(provider) = 'coolsat'", + "json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"=\", \"args\": [{\"lower\": {\"property\": \"provider\"}}, \"coolsat\"]}}" + } +} diff --git a/tests/parsers/cql2_json/get_fixtures.py b/tests/parsers/cql2_json/get_fixtures.py new file mode 100644 index 0000000..053a24a --- /dev/null +++ b/tests/parsers/cql2_json/get_fixtures.py @@ -0,0 +1,25 @@ +"""Get fixtures from the spec.""" +import json +import requests +import re + +url = ( + "https://raw.githubusercontent.com/radiantearth/" + "stac-api-spec/dev/fragments/filter/README.md" +) + +fixtures = {} +examples_text = requests.get(url).text +examples_raw = re.findall( + r"### (Example \d+).*?```http" r"(.*?)" r"```.*?```json" r"(.*?)" r"```", + examples_text, + re.S, +) +for example in examples_raw: + fixtures[example[0]] = { + "text": example[1].replace("\n", ""), + "json": json.dumps(json.loads(example[2])), + } + +with open("fixtures.json", "w") as f: + json.dump(fixtures, f, indent=4) diff --git a/tests/parsers/cql2_json/test_cql2_spec_fixtures.py b/tests/parsers/cql2_json/test_cql2_spec_fixtures.py new file mode 100644 index 0000000..e477f58 --- /dev/null +++ b/tests/parsers/cql2_json/test_cql2_spec_fixtures.py @@ -0,0 +1,28 @@ +from pygeofilter.parsers.cql2_text import parse as text_parse +from pygeofilter.parsers.cql2_json import parse as json_parse +from pygeofilter.backends.cql2_json import to_cql2 +import json +import pathlib + +dir = pathlib.Path(__file__).parent.resolve() +fixtures = pathlib.Path(dir, "fixtures.json") + + +def test_fixtures(): + """Test against fixtures from spec documentation. + + Parses both cql2_text and cql2_json from spec + documentation and makes sure AST is the same + and that json when each are converted back to + cql2_json is the same. + """ + with open(fixtures) as f: + examples = json.load(f) + + for _, v in examples.items(): + t = v["text"].replace("filter=", "") + j = v["json"] + parsed_text = text_parse(t) + parsed_json = json_parse(j) + assert parsed_text == parsed_json + assert to_cql2(parsed_text) == to_cql2(parsed_json) diff --git a/tests/parsers/cql2_json/test_parser.py b/tests/parsers/cql2_json/test_parser.py index 8c550e9..1f8f222 100644 --- a/tests/parsers/cql2_json/test_parser.py +++ b/tests/parsers/cql2_json/test_parser.py @@ -37,100 +37,65 @@ def normalize_geom(geometry): - if hasattr(geometry, '__geo_interface__'): + if hasattr(geometry, "__geo_interface__"): geometry = geometry.__geo_interface__ return json.loads(json.dumps(geometry)) def test_attribute_eq_literal(): - result = parse('{ "eq": [{ "property": "attr" }, "A"]}') + result = parse('{ "op": "eq", "args":[{ "property": "attr" }, "A"]}') assert result == ast.Equal( - ast.Attribute('attr'), - 'A', + ast.Attribute("attr"), + "A", ) def test_attribute_lt_literal(): - result = parse('{ "lt": [{ "property": "attr" }, 5]}') + result = parse('{"op": "lt", "args": [{ "property": "attr" }, 5]}') assert result == ast.LessThan( - ast.Attribute('attr'), + ast.Attribute("attr"), 5.0, ) def test_attribute_lte_literal(): - result = parse('{ "lte": [{ "property": "attr" }, 5]}') + result = parse('{ "op": "lte", "args": [{ "property": "attr" }, 5]}') assert result == ast.LessEqual( - ast.Attribute('attr'), + ast.Attribute("attr"), 5.0, ) def test_attribute_gt_literal(): - result = parse('{ "gt": [{ "property": "attr" }, 5]}') + result = parse('{ "op": "gt", "args": [{ "property": "attr" }, 5]}') assert result == ast.GreaterThan( - ast.Attribute('attr'), + ast.Attribute("attr"), 5.0, ) def test_attribute_gte_literal(): - result = parse('{ "gte": [{ "property": "attr" }, 5]}') + result = parse('{"op": "gte", "args":[{ "property": "attr" }, 5]}') assert result == ast.GreaterEqual( - ast.Attribute('attr'), + ast.Attribute("attr"), 5.0, ) -# def test_attribute_ne_literal(): -# result = parse('attr <> 5') -# assert result == ast.ComparisonPredicateNode( -# ast.Attribute('attr'), -# 5, -# ast.ComparisonOp('<>'), -# ) - - def test_attribute_between(): - result = parse({ - "between": { - "value": { - "property": "attr" - }, - "lower": 2, - "upper": 5, - } - }) + result = parse({"op": "between", "args": [{"property": "attr"}, [2, 5]]}) assert result == ast.Between( - ast.Attribute('attr'), + ast.Attribute("attr"), 2, 5, False, ) -# def test_attribute_not_between(): -# result = parse('attr NOT BETWEEN 2 AND 5') -# assert result == ast.BetweenPredicateNode( -# ast.Attribute('attr'), -# 2, -# 5, -# True, -# ) - - def test_attribute_between_negative_positive(): - result = parse({ - "between": { - "value": { - "property": "attr" - }, - "lower": -1, - "upper": 1, - } - }) + result = parse({"op": "between", "args": [{"property": "attr"}, [-1, 1]]}) assert result == ast.Between( - ast.Attribute('attr'), + ast.Attribute("attr"), -1, 1, False, @@ -138,149 +103,108 @@ def test_attribute_between_negative_positive(): def test_string_like(): - result = parse({ - "like": [ - {"property": "attr"}, - "some%", - ] - }) + result = parse( + { + "op": "like", + "args": [ + {"property": "attr"}, + "some%", + ], + } + ) assert result == ast.Like( - ast.Attribute('attr'), - 'some%', + ast.Attribute("attr"), + "some%", nocase=False, not_=False, - wildcard='%', - singlechar='.', - escapechar='\\', - ) - -# def test_string_not_like(): -# result = parse('attr NOT LIKE "some%"') -# assert result == ast.LikePredicateNode( -# ast.Attribute('attr'), -# 'some%', -# nocase=False, -# not_=True, -# wildcard='%', -# singlechar='.', -# escapechar=None, -# ) - - -# def test_string_not_ilike(): -# result = parse('attr NOT ILIKE "some%"') -# assert result == ast.LikePredicateNode( -# ast.Attribute('attr'), -# 'some%', -# nocase=True, -# not_=True, -# wildcard='%', -# singlechar='.', -# escapechar=None, -# ) + wildcard="%", + singlechar=".", + escapechar="\\", + ) def test_attribute_in_list(): - result = parse({ - "in": { - "value": {"property": "attr"}, - "list": [1, 2, 3, 4], + result = parse( + { + "op": "in", + "args": [ + {"property": "attr"}, + [1, 2, 3, 4], + ], } - }) + ) assert result == ast.In( - ast.Attribute('attr'), [ + ast.Attribute("attr"), + [ 1, 2, 3, 4, ], - False + False, ) -# def test_attribute_not_in_list(): -# result = parse('attr NOT IN ("A", "B", \'C\', \'D\')') -# assert result == ast.InPredicateNode( -# ast.Attribute('attr'), [ -# "A", -# "B", -# "C", -# "D", -# ], -# True -# ) - - def test_attribute_is_null(): - result = parse({ - "isNull": {"property": "attr"} - }) - assert result == ast.IsNull( - ast.Attribute('attr'), False - ) - - -# def test_attribute_is_not_null(): -# result = parse('attr IS NOT NULL') -# assert result == ast.NullPredicateNode( -# ast.Attribute('attr'), True -# ) - -# # Temporal predicate + result = parse({"op": "isNull", "args": {"property": "attr"}}) + assert result == ast.IsNull(ast.Attribute("attr"), False) def test_attribute_before(): - result = parse({ - "t_before": [ - {"property": "attr"}, - {"timestamp": "2000-01-01T00:00:01Z"}, - ] - }) + result = parse( + { + "op": "t_before", + "args": [ + {"property": "attr"}, + {"timestamp": "2000-01-01T00:00:01Z"}, + ], + } + ) assert result == ast.TimeBefore( - ast.Attribute('attr'), - datetime( - 2000, 1, 1, 0, 0, 1, - tzinfo=StaticTzInfo('Z', timedelta(0)) - ), + ast.Attribute("attr"), + datetime(2000, 1, 1, 0, 0, 1, tzinfo=StaticTzInfo("Z", timedelta(0))), ) def test_attribute_after_dt_dt(): - result = parse({ - "t_after": [ - {"property": "attr"}, - {"interval": ["2000-01-01T00:00:00Z", "2000-01-01T00:00:01Z"]} - ] - }) + result = parse( + { + "op": "t_after", + "args": [ + {"property": "attr"}, + {"interval": ["2000-01-01T00:00:00Z", "2000-01-01T00:00:01Z"]}, + ], + } + ) assert result == ast.TimeAfter( - ast.Attribute('attr'), + ast.Attribute("attr"), values.Interval( datetime( - 2000, 1, 1, 0, 0, 0, - tzinfo=StaticTzInfo('Z', timedelta(0)) + 2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0)) ), datetime( - 2000, 1, 1, 0, 0, 1, - tzinfo=StaticTzInfo('Z', timedelta(0)) + 2000, 1, 1, 0, 0, 1, tzinfo=StaticTzInfo("Z", timedelta(0)) ), ), ) def test_meets_dt_dr(): - result = parse({ - "t_meets": [ - {"property": "attr"}, - {"interval": ["2000-01-01T00:00:00Z", "PT4S"]} - ] - }) + result = parse( + { + "op": "t_meets", + "args": [ + {"property": "attr"}, + {"interval": ["2000-01-01T00:00:00Z", "PT4S"]}, + ], + } + ) assert result == ast.TimeMeets( - ast.Attribute('attr'), + ast.Attribute("attr"), values.Interval( datetime( - 2000, 1, 1, 0, 0, 0, - tzinfo=StaticTzInfo('Z', timedelta(0)) + 2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0)) ), timedelta(seconds=4), ), @@ -288,56 +212,62 @@ def test_meets_dt_dr(): def test_attribute_metby_dr_dt(): - result = parse({ - "t_metby": [ - {"property": "attr"}, - {"interval": ["PT4S", "2000-01-01T00:00:03Z"]} - ] - }) + result = parse( + { + "op": "t_metby", + "args": [ + {"property": "attr"}, + {"interval": ["PT4S", "2000-01-01T00:00:03Z"]}, + ], + } + ) assert result == ast.TimeMetBy( - ast.Attribute('attr'), + ast.Attribute("attr"), values.Interval( timedelta(seconds=4), datetime( - 2000, 1, 1, 0, 0, 3, - tzinfo=StaticTzInfo('Z', timedelta(0)) + 2000, 1, 1, 0, 0, 3, tzinfo=StaticTzInfo("Z", timedelta(0)) ), ), ) def test_attribute_toverlaps_open_dt(): - result = parse({ - "t_overlaps": [ - {"property": "attr"}, - {"interval": ["..", "2000-01-01T00:00:03Z"]} - ] - }) + result = parse( + { + "op": "t_overlaps", + "args": [ + {"property": "attr"}, + {"interval": ["..", "2000-01-01T00:00:03Z"]}, + ], + } + ) assert result == ast.TimeOverlaps( - ast.Attribute('attr'), + ast.Attribute("attr"), values.Interval( None, datetime( - 2000, 1, 1, 0, 0, 3, - tzinfo=StaticTzInfo('Z', timedelta(0)) + 2000, 1, 1, 0, 0, 3, tzinfo=StaticTzInfo("Z", timedelta(0)) ), ), ) def test_attribute_overlappedby_dt_open(): - result = parse({ - "t_overlappedby": [ - {"property": "attr"}, - {"interval": ["2000-01-01T00:00:03Z", ".."]} - ] - }) + result = parse( + { + "op": "t_overlappedby", + "args": [ + {"property": "attr"}, + {"interval": ["2000-01-01T00:00:03Z", ".."]}, + ], + } + ) assert result == ast.TimeOverlappedBy( - ast.Attribute('attr'), + ast.Attribute("attr"), values.Interval( datetime( - 2000, 1, 1, 0, 0, 3, - tzinfo=StaticTzInfo('Z', timedelta(0)) + 2000, 1, 1, 0, 0, 3, tzinfo=StaticTzInfo("Z", timedelta(0)) ), None, ), @@ -348,53 +278,41 @@ def test_attribute_overlappedby_dt_open(): def test_attribute_aequals(): - result = parse({ - "a_equals": [ - {"property": "arrayattr"}, - [1, 2, 3] - ] - }) + result = parse( + {"op": "a_equals", "args": [{"property": "arrayattr"}, [1, 2, 3]]} + ) assert result == ast.ArrayEquals( - ast.Attribute('arrayattr'), + ast.Attribute("arrayattr"), [1, 2, 3], ) def test_attribute_aoverlaps(): - result = parse({ - "a_overlaps": [ - {"property": "arrayattr"}, - [1, 2, 3] - ] - }) + result = parse( + {"op": "a_overlaps", "args": [{"property": "arrayattr"}, [1, 2, 3]]} + ) assert result == ast.ArrayOverlaps( - ast.Attribute('arrayattr'), + ast.Attribute("arrayattr"), [1, 2, 3], ) def test_attribute_acontains(): - result = parse({ - "a_contains": [ - {"property": "arrayattr"}, - [1, 2, 3] - ] - }) + result = parse( + {"op": "a_contains", "args": [{"property": "arrayattr"}, [1, 2, 3]]} + ) assert result == ast.ArrayContains( - ast.Attribute('arrayattr'), + ast.Attribute("arrayattr"), [1, 2, 3], ) def test_attribute_acontainedby(): - result = parse({ - "a_containedBy": [ - {"property": "arrayattr"}, - [1, 2, 3] - ] - }) + result = parse( + {"op": "a_containedby", "args": [{"property": "arrayattr"}, [1, 2, 3]]} + ) assert result == ast.ArrayContainedBy( - ast.Attribute('arrayattr'), + ast.Attribute("arrayattr"), [1, 2, 3], ) @@ -403,61 +321,66 @@ def test_attribute_acontainedby(): def test_intersects_attr_point(): - result = parse({ - "s_intersects": [ - {"property": "geometry"}, - { - "type": "Point", - "coordinates": [1, 1], - } - ] - }) + result = parse( + { + "op": "s_intersects", + "args": [ + {"property": "geometry"}, + { + "type": "Point", + "coordinates": [1, 1], + }, + ], + } + ) assert result == ast.GeometryIntersects( - ast.Attribute('geometry'), + ast.Attribute("geometry"), values.Geometry( - normalize_geom( - geometry.Point(1, 1).__geo_interface__ - ) + normalize_geom(geometry.Point(1, 1).__geo_interface__) ), ) def test_disjoint_linestring_attr(): - result = parse({ - "s_disjoint": [ - { - "type": "LineString", - "coordinates": [[1, 1], [2, 2]], - "bbox": [1.0, 1.0, 2.0, 2.0] - }, - {"property": "geometry"}, - ] - }) + result = parse( + { + "op": "s_disjoint", + "args": [ + { + "type": "LineString", + "coordinates": [[1, 1], [2, 2]], + "bbox": [1.0, 1.0, 2.0, 2.0], + }, + {"property": "geometry"}, + ], + } + ) assert result == ast.GeometryDisjoint( values.Geometry( normalize_geom( geometry.LineString([(1, 1), (2, 2)]).__geo_interface__ ), ), - ast.Attribute('geometry'), + ast.Attribute("geometry"), ) def test_contains_attr_polygon(): - result = parse({ - "s_contains": [ - {"property": "geometry"}, - { - "type": "Polygon", - "coordinates": [ - [[1, 1], [2, 2], [0, 3], [1, 1]] - ], - 'bbox': [0.0, 1.0, 2.0, 3.0] - }, - ] - }) + result = parse( + { + "op": "s_contains", + "args": [ + {"property": "geometry"}, + { + "type": "Polygon", + "coordinates": [[[1, 1], [2, 2], [0, 3], [1, 1]]], + "bbox": [0.0, 1.0, 2.0, 3.0], + }, + ], + } + ) assert result == ast.GeometryContains( - ast.Attribute('geometry'), + ast.Attribute("geometry"), values.Geometry( normalize_geom( geometry.Polygon( @@ -469,203 +392,127 @@ def test_contains_attr_polygon(): def test_within_multipolygon_attr(): - result = parse({ - "s_within": [ - { - "type": "MultiPolygon", - "coordinates": [ - [[[1, 1], [2, 2], [0, 3], [1, 1]]] - ], - 'bbox': [0.0, 1.0, 2.0, 3.0] - }, - {"property": "geometry"}, - ] - }) + result = parse( + { + "op": "s_within", + "args": [ + { + "type": "MultiPolygon", + "coordinates": [[[[1, 1], [2, 2], [0, 3], [1, 1]]]], + "bbox": [0.0, 1.0, 2.0, 3.0], + }, + {"property": "geometry"}, + ], + } + ) assert result == ast.GeometryWithin( values.Geometry( normalize_geom( - geometry.MultiPolygon([ - geometry.Polygon([(1, 1), (2, 2), (0, 3), (1, 1)]) - ]).__geo_interface__ + geometry.MultiPolygon( + [geometry.Polygon([(1, 1), (2, 2), (0, 3), (1, 1)])] + ).__geo_interface__ ), ), - ast.Attribute('geometry'), + ast.Attribute("geometry"), ) def test_touches_attr_multilinestring(): - result = parse({ - "s_touches": [ - {"property": "geometry"}, - { - "type": "MultiLineString", - "coordinates": [[[1, 1], [2, 2]], [[0, 3], [1, 1]]], - "bbox": [0.0, 1.0, 2.0, 3.0] - }, - ] - }) + result = parse( + { + "op": "s_touches", + "args": [ + {"property": "geometry"}, + { + "type": "MultiLineString", + "coordinates": [[[1, 1], [2, 2]], [[0, 3], [1, 1]]], + "bbox": [0.0, 1.0, 2.0, 3.0], + }, + ], + } + ) assert result == ast.GeometryTouches( - ast.Attribute('geometry'), + ast.Attribute("geometry"), values.Geometry( normalize_geom( - geometry.MultiLineString([ - geometry.LineString([(1, 1), (2, 2)]), - geometry.LineString([(0, 3), (1, 1)]), - ]).__geo_interface__ + geometry.MultiLineString( + [ + geometry.LineString([(1, 1), (2, 2)]), + geometry.LineString([(0, 3), (1, 1)]), + ] + ).__geo_interface__ ), ), ) def test_crosses_attr_multilinestring(): - result = parse({ - "s_crosses": [ - {"property": "geometry"}, - { - "type": "MultiLineString", - "coordinates": [[[1, 1], [2, 2]], [[0, 3], [1, 1]]], - "bbox": [0.0, 1.0, 2.0, 3.0] - }, - ] - }) + result = parse( + { + "op": "s_crosses", + "args": [ + {"property": "geometry"}, + { + "type": "MultiLineString", + "coordinates": [[[1, 1], [2, 2]], [[0, 3], [1, 1]]], + "bbox": [0.0, 1.0, 2.0, 3.0], + }, + ], + } + ) assert result == ast.GeometryCrosses( - ast.Attribute('geometry'), + ast.Attribute("geometry"), values.Geometry( normalize_geom( - geometry.MultiLineString([ - geometry.LineString([(1, 1), (2, 2)]), - geometry.LineString([(0, 3), (1, 1)]), - ]).__geo_interface__ + geometry.MultiLineString( + [ + geometry.LineString([(1, 1), (2, 2)]), + geometry.LineString([(0, 3), (1, 1)]), + ] + ).__geo_interface__ ) ), ) def test_overlaps_attr_multilinestring(): - result = parse({ - "s_overlaps": [ - {"property": "geometry"}, - { - "type": "MultiLineString", - "coordinates": [[[1, 1], [2, 2]], [[0, 3], [1, 1]]], - "bbox": [0.0, 1.0, 2.0, 3.0] - }, - ] - }) + result = parse( + { + "op": "s_overlaps", + "args": [ + {"property": "geometry"}, + { + "type": "MultiLineString", + "coordinates": [[[1, 1], [2, 2]], [[0, 3], [1, 1]]], + "bbox": [0.0, 1.0, 2.0, 3.0], + }, + ], + } + ) assert result == ast.GeometryOverlaps( - ast.Attribute('geometry'), + ast.Attribute("geometry"), values.Geometry( normalize_geom( - geometry.MultiLineString([ - geometry.LineString([(1, 1), (2, 2)]), - geometry.LineString([(0, 3), (1, 1)]), - ]).__geo_interface__ + geometry.MultiLineString( + [ + geometry.LineString([(1, 1), (2, 2)]), + geometry.LineString([(0, 3), (1, 1)]), + ] + ).__geo_interface__ ), ), ) -# POINT(1 1) -# LINESTRING(1 1,2 2) -# MULTIPOLYGON(((1 1,2 2,0 3,1 1)) -# MULTILINESTRING((1 1,2 2),(0 3,1 1)) -# POLYGON((1 1,2 2,0 3,1 1)) - -# def test_equals_attr_geometrycollection(): -# result = parse('OVERLAPS(geometry, )') -# assert result == ast.SpatialPredicateNode( -# ast.Attribute('geometry'), -# ast.LiteralExpression( -# geometry.MultiLineString([ -# geometry.LineString([(1, 1), (2, 2)]), -# geometry.LineString([(0, 3), (1, 1)]), -# ]) -# ), -# 'OVERLAPS' -# ) - - -# relate - -# def test_relate_attr_polygon(): -# result = parse('RELATE(geometry, POLYGON((1 1,2 2,0 3,1 1)), -# "1*T***T**")') -# assert result == ast.SpatialPatternPredicateNode( -# ast.Attribute('geometry'), -# ast.LiteralExpression( -# geometry.Polygon([(1, 1), (2, 2), (0, 3), (1, 1)]) -# ), -# pattern='1*T***T**', -# ) - - -# # dwithin/beyond - -# def test_dwithin_attr_polygon(): -# result = parse('DWITHIN(geometry, POLYGON((1 1,2 2,0 3,1 1)), 5, feet)') -# print(get_repr(result)) -# assert result == ast.SpatialDistancePredicateNode( -# ast.Attribute('geometry'), -# ast.LiteralExpression( -# geometry.Polygon([(1, 1), (2, 2), (0, 3), (1, 1)]) -# ), -# ast.SpatialDistanceOp('DWITHIN'), -# distance=5, -# units='feet', -# ) - - -# def test_beyond_attr_polygon(): -# result = parse( -# 'BEYOND(geometry, POLYGON((1 1,2 2,0 3,1 1)), 5, nautical miles)' -# ) -# print(get_repr(result)) -# assert result == ast.SpatialDistancePredicateNode( -# ast.Attribute('geometry'), -# ast.LiteralExpression( -# geometry.Polygon([(1, 1), (2, 2), (0, 3), (1, 1)]) -# ), -# ast.SpatialDistanceOp('BEYOND'), -# distance=5, -# units='nautical miles', -# ) - - -# BBox prediacte - - -# def test_bbox_simple(): -# result = parse('BBOX(geometry, 1, 2, 3, 4)') -# assert result == ast.BBoxPredicateNode( -# ast.Attribute('geometry'), -# ast.LiteralExpression(1), -# ast.LiteralExpression(2), -# ast.LiteralExpression(3), -# ast.LiteralExpression(4), -# ) - - -# def test_bbox_crs(): -# result = parse('BBOX(geometry, 1, 2, 3, 4, "EPSG:3875")') -# assert result == ast.BBoxPredicateNode( -# ast.Attribute('geometry'), -# ast.LiteralExpression(1), -# ast.LiteralExpression(2), -# ast.LiteralExpression(3), -# ast.LiteralExpression(4), -# 'EPSG:3875', -# ) - - def test_attribute_arithmetic_add(): - result = parse({ - "eq": [ - {"property": "attr"}, - {"+": [5, 2]} - ] - }) + result = parse( + { + "op": "eq", + "args": [{"property": "attr"}, {"op": "+", "args": [5, 2]}], + } + ) assert result == ast.Equal( - ast.Attribute('attr'), + ast.Attribute("attr"), ast.Add( 5, 2, @@ -674,14 +521,14 @@ def test_attribute_arithmetic_add(): def test_attribute_arithmetic_sub(): - result = parse({ - "eq": [ - {"property": "attr"}, - {"-": [5, 2]} - ] - }) + result = parse( + { + "op": "eq", + "args": [{"property": "attr"}, {"op": "-", "args": [5, 2]}], + } + ) assert result == ast.Equal( - ast.Attribute('attr'), + ast.Attribute("attr"), ast.Sub( 5, 2, @@ -690,14 +537,14 @@ def test_attribute_arithmetic_sub(): def test_attribute_arithmetic_mul(): - result = parse({ - "eq": [ - {"property": "attr"}, - {"*": [5, 2]} - ] - }) + result = parse( + { + "op": "eq", + "args": [{"property": "attr"}, {"op": "*", "args": [5, 2]}], + } + ) assert result == ast.Equal( - ast.Attribute('attr'), + ast.Attribute("attr"), ast.Mul( 5, 2, @@ -706,14 +553,14 @@ def test_attribute_arithmetic_mul(): def test_attribute_arithmetic_div(): - result = parse({ - "eq": [ - {"property": "attr"}, - {"/": [5, 2]} - ] - }) + result = parse( + { + "op": "eq", + "args": [{"property": "attr"}, {"op": "/", "args": [5, 2]}], + } + ) assert result == ast.Equal( - ast.Attribute('attr'), + ast.Attribute("attr"), ast.Div( 5, 2, @@ -722,17 +569,23 @@ def test_attribute_arithmetic_div(): def test_attribute_arithmetic_add_mul(): - result = parse({ - "eq": [ - {"property": "attr"}, - {"+": [ - 3, - {"*": [5, 2]}, - ]}, - ], - }) + result = parse( + { + "op": "eq", + "args": [ + {"property": "attr"}, + { + "op": "+", + "args": [ + 3, + {"op": "*", "args": [5, 2]}, + ], + }, + ], + } + ) assert result == ast.Equal( - ast.Attribute('attr'), + ast.Attribute("attr"), ast.Add( 3, ast.Mul( @@ -744,17 +597,23 @@ def test_attribute_arithmetic_add_mul(): def test_attribute_arithmetic_div_sub(): - result = parse({ - "eq": [ - {"property": "attr"}, - {"-": [ - {"/": [3, 5]}, - 2, - ]}, - ], - }) + result = parse( + { + "op": "eq", + "args": [ + {"property": "attr"}, + { + "op": "-", + "args": [ + {"op": "/", "args": [3, 5]}, + 2, + ], + }, + ], + } + ) assert result == ast.Equal( - ast.Attribute('attr'), + ast.Attribute("attr"), ast.Sub( ast.Div( 3, @@ -766,17 +625,23 @@ def test_attribute_arithmetic_div_sub(): def test_attribute_arithmetic_div_sub_bracketted(): - result = parse({ - "eq": [ - {"property": "attr"}, - {"/": [ - 3, - {"-": [5, 2]}, - ]}, - ], - }) + result = parse( + { + "op": "eq", + "args": [ + {"property": "attr"}, + { + "op": "/", + "args": [ + 3, + {"op": "-", "args": [5, 2]}, + ], + }, + ], + } + ) assert result == ast.Equal( - ast.Attribute('attr'), + ast.Attribute("attr"), ast.Div( 3, ast.Sub( @@ -786,72 +651,67 @@ def test_attribute_arithmetic_div_sub_bracketted(): ), ) + # test function expression parsing def test_function_no_arg(): - result = parse({ - "eq": [ - {"property": "attr"}, - { - "function": { - "name": "myfunc", - "arguments": [] - } - } - ] - }) + result = parse( + { + "op": "eq", + "args": [ + {"property": "attr"}, + {"function": {"name": "myfunc", "arguments": []}}, + ], + } + ) assert result == ast.Equal( - ast.Attribute('attr'), - ast.Function( - 'myfunc', [ - ] - ), + ast.Attribute("attr"), + ast.Function("myfunc", []), ) def test_function_single_arg(): - result = parse({ - "eq": [ - {"property": "attr"}, - { - "function": { - "name": "myfunc", - "arguments": [1] - } - } - ] - }) + result = parse( + { + "op": "eq", + "args": [ + {"property": "attr"}, + {"function": {"name": "myfunc", "arguments": [1]}}, + ], + } + ) assert result == ast.Equal( - ast.Attribute('attr'), + ast.Attribute("attr"), ast.Function( - 'myfunc', + "myfunc", [1], ), ) def test_function_attr_string_arg(): - result = parse({ - "eq": [ - {"property": "attr"}, - { - "function": { - "name": "myfunc", - "arguments": [ - {"property": "other_attr"}, - "abc" - ] - } - } - ] - }) + result = parse( + { + "op": "eq", + "args": [ + {"property": "attr"}, + { + "function": { + "name": "myfunc", + "arguments": [{"property": "other_attr"}, "abc"], + } + }, + ], + } + ) assert result == ast.Equal( - ast.Attribute('attr'), + ast.Attribute("attr"), ast.Function( - 'myfunc', [ - ast.Attribute('other_attr'), + "myfunc", + [ + ast.Attribute("other_attr"), "abc", - ] + ], ), )