From 3aaf33a06a12d6c6f5ed64ed3cf1651ef1ec3782 Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Tue, 9 Jun 2026 14:27:44 +0200 Subject: [PATCH 01/12] ci: pin actions/checkout to v4 instead of deprecated @master MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Using @master is deprecated and a supply-chain risk — it tracks a mutable branch. Pin to @v4 for both the build and publish workflows. --- .github/workflows/main.yml | 2 +- .github/workflows/publish.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3d41e09..faa1e47 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,7 +9,7 @@ jobs: matrix: python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] steps: - - uses: actions/checkout@master + - uses: actions/checkout@v4 - uses: actions/setup-python@v5 name: Setup Python ${{ matrix.python-version }} with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 54402da..ccdc102 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,7 +10,7 @@ jobs: publish: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@master + - uses: actions/checkout@v4 - uses: actions/setup-python@v5 name: Setup Python with: From 38750c574f031cb8e6942dd5d402fa30d6dafc7b Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Tue, 9 Jun 2026 14:27:52 +0200 Subject: [PATCH 02/12] ci: use python -m build instead of setup.py bdist_wheel --universal The --universal flag produces a py2.py3 wheel, which is wrong for a Python 3-only package. Switch to the modern 'build' frontend which respects pyproject.toml and produces the correct wheel tags. --- .github/workflows/publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ccdc102..f459b28 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,9 +16,9 @@ jobs: with: python-version: '3.11' - name: Install build dependency - run: pip3 install wheel setuptools + run: pip3 install build - name: Build package - run: python3 setup.py sdist bdist_wheel --universal + run: python3 -m build - name: Publish package if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@release/v1 From 5f0966418ab80a566ec2f5263856046f3de9a151 Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Tue, 9 Jun 2026 14:27:59 +0200 Subject: [PATCH 03/12] feat: add py.typed marker for PEP 561 compliance Signals to type checkers (mypy, pyright) that pygeofilter ships inline type annotations. Without this marker, downstream consumers cannot use the package's type hints. --- pygeofilter/py.typed | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 pygeofilter/py.typed diff --git a/pygeofilter/py.typed b/pygeofilter/py.typed new file mode 100644 index 0000000..e69de29 From 992734cefc014b5e911ec000e71dac5764ae4ab7 Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Tue, 9 Jun 2026 14:28:11 +0200 Subject: [PATCH 04/12] ci: enable pre-commit checks in CI The pre-commit step was commented out, meaning black, isort, flake8, and mypy were configured but never enforced. Uncomment it so linting is actually validated on every push and PR. Also fixes the pip3 install invocation (was using 'python -m pip3' which doesn't work). --- .github/workflows/main.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index faa1e47..265f8df 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -53,7 +53,7 @@ jobs: - name: Run unit tests run: | pytest - # - name: run pre-commit (code formatting, lint and type checking) - # run: | - # python -m pip3 install pre-commit - # pre-commit run --all-files + - name: Run pre-commit (code formatting, lint and type checking) + run: | + pip3 install pre-commit + pre-commit run --all-files From cab22b69430ae0cc0928bc436b067a59fca9a34c Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Tue, 9 Jun 2026 14:28:27 +0200 Subject: [PATCH 05/12] docs: read version dynamically from pygeofilter.version The docs version was hardcoded to '0.0.3' and never updated. Import __version__ from pygeofilter.version so it stays in sync with the package automatically. --- docs/conf.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 5ba66df..17700fb 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -17,6 +17,7 @@ sys.path.insert(0, os.path.abspath("..")) +from pygeofilter.version import __version__ # noqa: E402 # -- Project information ----------------------------------------------------- @@ -25,9 +26,9 @@ author = "Fabian Schindler" # The short X.Y version -version = "" +version = __version__ # The full version, including alpha/beta/rc tags -release = "0.0.3" +release = __version__ # -- General configuration --------------------------------------------------- From b88f8dabc0c9ca335f13c636238e5885a1f45e8c Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Tue, 9 Jun 2026 14:37:14 +0200 Subject: [PATCH 06/12] refactor: consolidate packaging to PEP 621 pyproject.toml Replace the legacy setup.py + setup.cfg + MANIFEST.in + three requirements*.txt files with a single pyproject.toml using PEP 621 project metadata. Changes: - All project metadata, dependencies, and extras now in pyproject.toml - Dev/test/docs dependencies declared as optional-dependency groups - CI workflow uses 'pip install .[test,dev,...]' instead of requirements files - ReadTheDocs config uses 'pip install .[docs]' with pinned Python 3.12 - Package data (*.lark, py.typed) declared via [tool.setuptools.package-data] - Tool configs (isort, flake8, mypy, pytest) moved to pyproject.toml - Dropped Python 3.8 from classifiers (not tested in CI) Removed: - setup.py, setup.cfg, MANIFEST.in - requirements-test.txt, requirements-dev.txt, docs/requirements.txt --- .github/workflows/main.yml | 4 +- .readthedocs.yaml | 18 ++++--- MANIFEST.in | 5 -- docs/requirements.txt | 2 - pyproject.toml | 102 ++++++++++++++++++++++++++++++++++++- requirements-dev.txt | 6 --- requirements-test.txt | 14 ----- setup.cfg | 17 ------- setup.py | 93 --------------------------------- 9 files changed, 113 insertions(+), 148 deletions(-) delete mode 100644 MANIFEST.in delete mode 100644 docs/requirements.txt delete mode 100644 requirements-dev.txt delete mode 100644 requirements-test.txt delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 265f8df..50ca637 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,10 +19,8 @@ jobs: run: | sudo apt-get update sudo apt-get install -y binutils gdal-bin libgdal-dev libproj-dev libsqlite3-mod-spatialite spatialite-bin - pip3 install -r requirements-test.txt - pip3 install -r requirements-dev.txt pip3 install gdal=="`gdal-config --version`.*" - pip3 install . + pip3 install ".[test,dev,fes,backend-django,backend-sqlalchemy,backend-native,backend-elasticsearch,backend-opensearch]" - name: Configure sysctl limits run: | sudo swapoff -a diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 48ea42c..51fd50c 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -2,15 +2,19 @@ # Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details -# Required version: 2 -# Build documentation in the docs/ directory with Sphinx +build: + os: ubuntu-22.04 + tools: + python: "3.12" + sphinx: - configuration: docs/conf.py + configuration: docs/conf.py -# Optionally set requirements required to build your docs python: - install: - - requirements: docs/requirements.txt - - requirements: requirements-test.txt + install: + - method: pip + path: . + extra_requirements: + - docs diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 7007f9c..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,5 +0,0 @@ -recursive-include pygeofilter *.py *.lark -global-include *.lark -include README.md -include LICENSE -include requirements.txt \ No newline at end of file diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index d6f12f3..0000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -sphinxcontrib-apidoc -m2r2 diff --git a/pyproject.toml b/pyproject.toml index bcc090e..b8405e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,2 +1,102 @@ [build-system] -requires = ["setuptools>=46.4", "wheel"] +requires = ["setuptools>=64"] +build-backend = "setuptools.build_meta" + +[project] +name = "pygeofilter" +description = "pygeofilter is a pure Python parser implementation of OGC filtering standards" +readme = "README.md" +license = "MIT" +requires-python = ">=3.9" +authors = [ + { name = "Fabian Schindler", email = "fabian.schindler@eox.at" }, +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Topic :: Scientific/Engineering :: GIS", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +dynamic = ["version"] +dependencies = [ + "click", + "dateparser", + "lark", + "pygeoif>=1.0.0", +] + +[project.optional-dependencies] +backend-django = ["django"] +backend-sqlalchemy = ["geoalchemy2", "sqlalchemy"] +backend-native = ["shapely"] +backend-elasticsearch = ["elasticsearch", "elasticsearch-dsl"] +backend-opensearch = ["opensearch-py", "opensearch-dsl"] +fes = ["pygml>=0.2"] +dev = [ + "flake8", + "pytest", + "pytest-django", + "mypy<=1.10.0", + "types-dateparser", + "pre-commit", +] +test = [ + "pytest", + "pytest-django", + "django", + "geoalchemy2", + "sqlalchemy", + "geopandas", + "fiona", + "pyproj", + "rtree", + "pygml", + "dateparser", + "lark", + "elasticsearch", + "elasticsearch-dsl", + "opensearch-py", + "opensearch-dsl", +] +docs = [ + "sphinxcontrib-apidoc", + "m2r2", +] + +[project.scripts] +pygeofilter = "pygeofilter.cli:cli" + +[project.urls] +Homepage = "https://github.com/geopython/pygeofilter" +Documentation = "https://pygeofilter.readthedocs.io" +Repository = "https://github.com/geopython/pygeofilter" +Issues = "https://github.com/geopython/pygeofilter/issues" + +[tool.setuptools.dynamic] +version = { attr = "pygeofilter.version.__version__" } + +[tool.setuptools.packages.find] +include = ["pygeofilter*"] + +[tool.setuptools.package-data] +pygeofilter = ["py.typed", "**/*.lark"] + +[tool.isort] +profile = "black" +default_section = "THIRDPARTY" + +[tool.flake8] +ignore = ["E501", "W503", "E203"] +exclude = [".git", "__pycache__", "docs/conf.py", "old", "build", "dist"] +max-complexity = 12 +max-line-length = 80 + +[tool.mypy] +ignore_missing_imports = true + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index b41b82b..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,6 +0,0 @@ -flake8 -pytest -pytest-django -wheel -mypy<=1.10.0 -types-dateparser diff --git a/requirements-test.txt b/requirements-test.txt deleted file mode 100644 index effaeeb..0000000 --- a/requirements-test.txt +++ /dev/null @@ -1,14 +0,0 @@ -django -geoalchemy2 -sqlalchemy -geopandas -fiona -pyproj -rtree -pygml -dateparser -lark -elasticsearch -elasticsearch-dsl -opensearch-py -opensearch-dsl diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 6cab6ff..0000000 --- a/setup.cfg +++ /dev/null @@ -1,17 +0,0 @@ -[metadata] -version = attr: pygeofilter.version.__version__ - -###################################################### -# code formating / lint / type checking configurations -[isort] -profile = black -default_section = THIRDPARTY - -[flake8] -ignore = E501,W503,E203 -exclude = .git,__pycache__,docs/conf.py,old,build,dist -max-complexity = 12 -max-line-length = 80 - -[mypy] -ignore_missing_imports = True diff --git a/setup.py b/setup.py deleted file mode 100644 index f5607ce..0000000 --- a/setup.py +++ /dev/null @@ -1,93 +0,0 @@ -# ------------------------------------------------------------------------------ -# -# Project: pygeofilter -# Authors: Fabian Schindler -# -# ------------------------------------------------------------------------------ -# Copyright (C) 2019 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. -# ------------------------------------------------------------------------------ - -"""Install pygeofilter.""" - -import os -import os.path - -from setuptools import find_packages, setup - -# don't install dependencies when building win readthedocs -on_rtd = os.environ.get("READTHEDOCS") == "True" - -# use README.md for project long_description -with open("README.md") as f: - readme = f.read() - -description = ( - "pygeofilter is a pure Python parser implementation of OGC filtering standards" -) - -setup( - name="pygeofilter", - description=description, - long_description=readme, - long_description_content_type="text/markdown", - author="Fabian Schindler", - author_email="fabian.schindler@eox.at", - url="https://github.com/geopython/pygeofilter", - license="MIT", - packages=find_packages(), - include_package_data=True, - install_requires=( - [ - "click", - "dateparser", - "lark", - "pygeoif>=1.0.0" - ] - if not on_rtd - else [] - ), - extras_require={ - "backend-django": ["django"], - "backend-sqlalchemy": ["geoalchemy2", "sqlalchemy"], - "backend-native": ["shapely"], - "backend-elasticsearch": ["elasticsearch", "elasticsearch-dsl"], - "backend-opensearch": ["opensearch-py", "opensearch-dsl"], - "fes": ["pygml>=0.2"], - }, - classifiers=[ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "Topic :: Scientific/Engineering :: GIS", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - ], - entry_points={ - 'console_scripts': [ - 'pygeofilter=pygeofilter.cli:cli' - ] - }, - tests_require=["pytest"] -) From 3d38f3b02adc4f6ffdb0e7e26eee0add6eca4ba1 Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Tue, 9 Jun 2026 14:37:33 +0200 Subject: [PATCH 07/12] refactor: replace black + isort + flake8 with ruff Ruff is a single tool that replaces all three linters/formatters, runs ~100x faster, and is configured in one place in pyproject.toml. Updates both the pre-commit config and the dev dependencies. --- .pre-commit-config.yaml | 21 +++++---------------- pyproject.toml | 18 +++++++++++------- 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e70fe07..be4a48a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,21 +1,10 @@ repos: - - repo: https://github.com/psf/black - rev: 25.1.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.13 hooks: - - id: black - language_version: python - - - repo: https://github.com/PyCQA/isort - rev: 6.0.1 - hooks: - - id: isort - language_version: python - - - repo: https://github.com/PyCQA/flake8 - rev: 7.2.0 - hooks: - - id: flake8 - language_version: python + - id: ruff + args: [--fix] + - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.15.0 diff --git a/pyproject.toml b/pyproject.toml index b8405e3..f6a56ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ backend-elasticsearch = ["elasticsearch", "elasticsearch-dsl"] backend-opensearch = ["opensearch-py", "opensearch-dsl"] fes = ["pygml>=0.2"] dev = [ - "flake8", + "ruff", "pytest", "pytest-django", "mypy<=1.10.0", @@ -85,15 +85,19 @@ include = ["pygeofilter*"] [tool.setuptools.package-data] pygeofilter = ["py.typed", "**/*.lark"] -[tool.isort] -profile = "black" -default_section = "THIRDPARTY" +[tool.ruff] +target-version = "py39" +line-length = 80 -[tool.flake8] +[tool.ruff.lint] +select = ["E", "F", "W", "I", "C90"] ignore = ["E501", "W503", "E203"] -exclude = [".git", "__pycache__", "docs/conf.py", "old", "build", "dist"] + +[tool.ruff.lint.isort] +known-first-party = ["pygeofilter"] + +[tool.ruff.lint.mccabe] max-complexity = 12 -max-line-length = 80 [tool.mypy] ignore_missing_imports = true From ed7906fecb41642cf20ced9849461734b1735000 Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Tue, 9 Jun 2026 14:37:42 +0200 Subject: [PATCH 08/12] test: add pytest-cov for coverage reporting Adds pytest-cov to test dependencies and configures pytest to automatically report coverage with missing lines on every run. --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index f6a56ad..cac072f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ dev = [ ] test = [ "pytest", + "pytest-cov", "pytest-django", "django", "geoalchemy2", @@ -104,3 +105,4 @@ ignore_missing_imports = true [tool.pytest.ini_options] testpaths = ["tests"] +addopts = ["--cov=pygeofilter", "--cov-report=term-missing"] From 8de5b5c7eff86ae3b26a325ad8894aaeb5f19d8b Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Tue, 9 Jun 2026 14:44:51 +0200 Subject: [PATCH 09/12] fix: remove W503 from ruff ignore list W503 is a flake8-specific rule (line break before binary operator) that doesn't exist in Ruff. Ruff follows Black's formatting which already handles this correctly. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cac072f..88d802d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,7 +92,7 @@ line-length = 80 [tool.ruff.lint] select = ["E", "F", "W", "I", "C90"] -ignore = ["E501", "W503", "E203"] +ignore = ["E501", "E203"] [tool.ruff.lint.isort] known-first-party = ["pygeofilter"] From 97b945be1d2f57ff01ef17a7898d2096c5aaf32f Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Tue, 9 Jun 2026 14:58:52 +0200 Subject: [PATCH 10/12] fix: resolve mypy errors and upgrade to latest mypy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 6 type errors that were hidden by the mypy<=1.10.0 pin: - ast.Like.pattern: narrow type from ScalarAstType to str (it's always a string — patterns can't be numeric) - solr/evaluate.py: convert Version to str before re-wrapping - cli.py: use dict[str, Any] for PARSERS to satisfy variance checks Remove the mypy version pin (was <=1.10.0, now unpinned) and update the pre-commit mirror from v1.15.0 to v2.1.0. All 56 source files now pass mypy cleanly. --- .pre-commit-config.yaml | 2 +- pygeofilter/ast.py | 2 +- pygeofilter/backends/solr/evaluate.py | 2 +- pygeofilter/cli.py | 3 ++- pyproject.toml | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index be4a48a..716ce04 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,7 @@ repos: - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.15.0 + rev: v2.1.0 hooks: - id: mypy language_version: python diff --git a/pygeofilter/ast.py b/pygeofilter/ast.py index 5f65b79..b1f1374 100644 --- a/pygeofilter/ast.py +++ b/pygeofilter/ast.py @@ -225,7 +225,7 @@ class Like(Predicate): """Node class to represent a wildcard sting matching predicate.""" lhs: Node - pattern: ScalarAstType + pattern: str nocase: bool wildcard: str singlechar: str diff --git a/pygeofilter/backends/solr/evaluate.py b/pygeofilter/backends/solr/evaluate.py index 8e37a60..875e6b1 100644 --- a/pygeofilter/backends/solr/evaluate.py +++ b/pygeofilter/backends/solr/evaluate.py @@ -528,7 +528,7 @@ def to_filter( """Shorthand function to convert a pygeofilter AST to an Apache Solr filter structure. """ - return SOLRDSLEvaluator(attribute_map, Version(version) if version else None).evaluate(root) + return SOLRDSLEvaluator(attribute_map, Version(str(version)) if version else None).evaluate(root) def unwrap_query(obj): diff --git a/pygeofilter/cli.py b/pygeofilter/cli.py index 11055ac..b44313c 100644 --- a/pygeofilter/cli.py +++ b/pygeofilter/cli.py @@ -27,6 +27,7 @@ import logging import sys +from typing import Any import click @@ -40,7 +41,7 @@ __all__ = ["__version__"] -PARSERS = { +PARSERS: dict[str, Any] = { 'cql_json': parse_cql_json, 'cql2_json': parse_cql2_json, 'cql2_text': parse_cql2_text, diff --git a/pyproject.toml b/pyproject.toml index 88d802d..e3ca1c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dev = [ "ruff", "pytest", "pytest-django", - "mypy<=1.10.0", + "mypy", "types-dateparser", "pre-commit", ] From a92fc91ea0a0b6f05e6c7aec631179c0fe90523b Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Tue, 9 Jun 2026 15:24:00 +0200 Subject: [PATCH 11/12] chore: run pre-commit locally --- docs/conf.py | 4 +- examples/cql2.ipynb | 46 +-- examples/test-solr-queries.py | 265 +++++++++--------- pygeofilter/ast.py | 4 +- pygeofilter/backends/django/evaluate.py | 4 +- pygeofilter/backends/django/filters.py | 48 +++- .../backends/elasticsearch/evaluate.py | 8 +- pygeofilter/backends/evaluator.py | 8 +- pygeofilter/backends/geopandas/evaluate.py | 4 +- pygeofilter/backends/geopandas/filters.py | 4 +- pygeofilter/backends/native/evaluate.py | 22 +- pygeofilter/backends/opensearch/evaluate.py | 8 +- pygeofilter/backends/optimize.py | 6 +- pygeofilter/backends/oraclesql/evaluate.py | 18 +- pygeofilter/backends/solr/__init__.py | 3 +- pygeofilter/backends/solr/evaluate.py | 108 +++++-- pygeofilter/backends/solr/util.py | 3 +- pygeofilter/backends/sql/evaluate.py | 4 +- pygeofilter/backends/sqlalchemy/evaluate.py | 18 +- pygeofilter/backends/sqlalchemy/filters.py | 7 +- pygeofilter/cli.py | 37 +-- pygeofilter/parsers/cql2_json/parser.py | 14 +- pygeofilter/parsers/cql_json/parser.py | 13 +- pygeofilter/parsers/fes/gml.py | 6 +- pygeofilter/parsers/fes/util.py | 11 +- pygeofilter/parsers/iso8601.py | 4 +- pygeofilter/parsers/jfe/parser.py | 19 +- pygeofilter/values.py | 1 + tests/backends/django/test_django_evaluate.py | 22 +- .../django/testapp/migrations/0001_initial.py | 28 +- tests/backends/django/testapp/models.py | 2 +- tests/backends/django/testapp/tests.py | 19 +- tests/backends/elasticsearch/test_evaluate.py | 14 +- tests/backends/oraclesql/test_evaluate.py | 20 +- tests/backends/solr/test_evaluate.py | 127 +++++++-- tests/backends/sqlalchemy/test_evaluate.py | 141 ++++++++-- tests/native/test_evaluate.py | 14 +- tests/parsers/cql2_json/test_parser.py | 52 +++- tests/parsers/cql2_text/test_parser.py | 48 ++-- tests/parsers/cql_json/test_parser.py | 40 ++- tests/parsers/ecql/test_parser.py | 43 ++- tests/parsers/fes/test_v11.py | 14 +- tests/parsers/fes/test_v20.py | 22 +- tests/parsers/jfe/test_parser.py | 34 ++- tests/test_optimize.py | 14 +- tests/test_sql/test_evaluate.py | 88 ++++-- 46 files changed, 1001 insertions(+), 438 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 17700fb..4d69fa2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -145,7 +145,9 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [(master_doc, "pygeofilter", "pygeofilter Documentation", [author], 1)] +man_pages = [ + (master_doc, "pygeofilter", "pygeofilter Documentation", [author], 1) +] # -- Options for Texinfo output ---------------------------------------------- diff --git a/examples/cql2.ipynb b/examples/cql2.ipynb index d13341b..d35f22a 100644 --- a/examples/cql2.ipynb +++ b/examples/cql2.ipynb @@ -7,12 +7,10 @@ "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" + "\n", + "from pygeofilter.backends.cql2_json import to_cql2" ] }, { @@ -34,7 +32,10 @@ ], "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')\")" + "\n", + "cql2_parse(\n", + " \"collection = 'landsat8_l1tp' AND gsd <= 30 AND eo:cloud_cover <= 10 AND datetime >= TIMESTAMP('2021-04-08T04:39:23Z')\"\n", + ")" ] }, { @@ -99,42 +100,44 @@ } ], "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", + "\n", + "from pygeofilter.parsers.cql2_json import parse as json_parse\n", + "from pygeofilter.parsers.cql2_text import parse as text_parse\n", + "\n", + "\n", "def pp(j):\n", " print(orjson.dumps(j))\n", - "with open('tests/parsers/cql2_json/fixtures.json') as f:\n", + "\n", + "\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(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", + " parsed_text = text_parse(t)\n", + " parsed_json = json_parse(j)\n", " if parsed_text == parsed_json:\n", - " print('*******parsed trees match***************')\n", + " print(\"*******parsed trees match***************\")\n", " else:\n", " print(parsed_text)\n", - " print('-----')\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", + " print(\"*******reconstructed json matches*******\")\n", " else:\n", " pp(to_cql2(parsed_text))\n", - " print('-----')\n", + " print(\"-----\")\n", " pp(to_cql2(parsed_json))\n", " except Exception as e:\n", " print(parsed_text)\n", @@ -142,8 +145,7 @@ " print(j)\n", " traceback.print_exc(f\"Error: {e}\")\n", " pass\n", - " print('____________________________________________________________')\n", - " " + " print(\"____________________________________________________________\")" ] }, { diff --git a/examples/test-solr-queries.py b/examples/test-solr-queries.py index 2732a6d..1f97a63 100644 --- a/examples/test-solr-queries.py +++ b/examples/test-solr-queries.py @@ -30,269 +30,268 @@ from pygeofilter.backends.solr import to_filter from pygeofilter.parsers.ecql import parse - # AND -print('Testing AND') +print("Testing AND") ast = parse("title = 'test' AND description = 'test2'") -print('AST AND: ', ast) +print("AST AND: ", ast) solr_filter = to_filter(ast) -print('SOLR filter AND: ', solr_filter) -print('\n') +print("SOLR filter AND: ", solr_filter) +print("\n") # OR -print('Testing OR') +print("Testing OR") ast = parse("title = 'test' OR description = 'test2'") -print('AST OR: ', ast) +print("AST OR: ", ast) solr_filter = to_filter(ast) -print('SOLR filter OR: ', solr_filter) -print('\n') +print("SOLR filter OR: ", solr_filter) +print("\n") # = -print('Testing Equals =') +print("Testing Equals =") ast = parse("int_attribute = 5") -print('AST =: ', ast) +print("AST =: ", ast) solr_filter = to_filter(ast) -print('SOLR filter =: ', solr_filter) -print('\n') +print("SOLR filter =: ", solr_filter) +print("\n") # <> -print('Testing NOT EQUAL <>') +print("Testing NOT EQUAL <>") ast = parse("int_attribute <> 0.0") -print('AST <>: ', ast) +print("AST <>: ", ast) solr_filter = to_filter(ast) -print('SOLR filter <>: ', solr_filter) -print('\n') +print("SOLR filter <>: ", solr_filter) +print("\n") # < -print('Testing LessThan <') +print("Testing LessThan <") ast = parse("float_attribute < 6") -print('AST <: ', ast) +print("AST <: ", ast) solr_filter = to_filter(ast) -print('SOLR filter <: ', solr_filter) -print('\n') +print("SOLR filter <: ", solr_filter) +print("\n") # > -print('Testing GraterThan >') +print("Testing GraterThan >") ast = parse("float_attribute > 6") -print('AST >: ', ast) +print("AST >: ", ast) solr_filter = to_filter(ast) -print('SOLR filter >: ', solr_filter) -print('\n') +print("SOLR filter >: ", solr_filter) +print("\n") # <= -print('Testing LessEqual <=') +print("Testing LessEqual <=") ast = parse("int_attribute <= 6") -print('AST <=: ', ast) +print("AST <=: ", ast) solr_filter = to_filter(ast) -print('SOLR filter <=: ', solr_filter) -print('\n') +print("SOLR filter <=: ", solr_filter) +print("\n") # >= -print('Testing LessEqual >=') +print("Testing LessEqual >=") ast = parse("float_attribute >= 8") -print('AST >=: ', ast) +print("AST >=: ", ast) solr_filter = to_filter(ast) -print('SOLR filter >=: ', solr_filter) -print('\n') +print("SOLR filter >=: ", solr_filter) +print("\n") # Combination AND -print('Testing Combination AND') +print("Testing Combination AND") ast = parse("int_attribute = 5 AND float_attribute < 6.0") -print('AST Combination AND: ', ast) +print("AST Combination AND: ", ast) solr_filter = to_filter(ast) -print('SOLR filter Combination AND: ', solr_filter) -print('\n') +print("SOLR filter Combination AND: ", solr_filter) +print("\n") # Combination OR -print('Testing Combination OR') +print("Testing Combination OR") ast = parse("int_attribute = 6 OR float_attribute < 6.0") -print('AST Combination OR: ', ast) +print("AST Combination OR: ", ast) solr_filter = to_filter(ast) -print('SOLR filter Combination OR: ', solr_filter) -print('\n') +print("SOLR filter Combination OR: ", solr_filter) +print("\n") # Between -print('Testing BETWEEN') +print("Testing BETWEEN") ast = parse("float_attribute BETWEEN -1 AND 1") -print('AST BETWEEN: ', ast) +print("AST BETWEEN: ", ast) solr_filter = to_filter(ast) -print('SOLR filter BETWEEN: ', solr_filter) -print('\n') +print("SOLR filter BETWEEN: ", solr_filter) +print("\n") # NOT Between -print('Testing NOT BETWEEN') +print("Testing NOT BETWEEN") ast = parse("int_attribute NOT BETWEEN 4 AND 6") -print('AST NOT BETWEEN: ', ast) +print("AST NOT BETWEEN: ", ast) solr_filter = to_filter(ast) -print('SOLR filter NOT BETWEEN: ', solr_filter) -print('\n') +print("SOLR filter NOT BETWEEN: ", solr_filter) +print("\n") # NOT Between -print('Testing NOT BETWEEN') +print("Testing NOT BETWEEN") ast = parse("int_attribute NOT BETWEEN 4 AND 6") -print('AST NOT BETWEEN: ', ast) +print("AST NOT BETWEEN: ", ast) solr_filter = to_filter(ast) -print('SOLR filter NOT BETWEEN: ', solr_filter) -print('\n') +print("SOLR filter NOT BETWEEN: ", solr_filter) +print("\n") # IS_NULL -print('Testing IS_NULL') +print("Testing IS_NULL") ast = parse("maybe_str_attribute IS NULL") -print('AST IS_NULL: ', ast) +print("AST IS_NULL: ", ast) solr_filter = to_filter(ast) -print('SOLR filter IS_NULL: ', solr_filter) -print('\n') +print("SOLR filter IS_NULL: ", solr_filter) +print("\n") # IS_NOT_NULL -print('Testing IS_NOT_NULL') +print("Testing IS_NOT_NULL") ast = parse("maybe_str_attribute IS NOT NULL") -print('AST IS_NOT_NULL: ', ast) +print("AST IS_NOT_NULL: ", ast) solr_filter = to_filter(ast) -print('SOLR filter IS_NOT_NULL: ', solr_filter) -print('\n') +print("SOLR filter IS_NOT_NULL: ", solr_filter) +print("\n") # IS_IN -print('Testing IN') +print("Testing IN") ast = parse("int_attribute IN ( 1, 2, 3, 4, 5 )") -print('AST IN: ', ast) +print("AST IN: ", ast) solr_filter = to_filter(ast) -print('SOLR filter IN: ', solr_filter) -print('\n') +print("SOLR filter IN: ", solr_filter) +print("\n") # IS_NOT_IN -print('Testing NOT IN') +print("Testing NOT IN") ast = parse("int_attribute NOT IN ( 1, 2, 3, 4, 5 )") -print('AST NOT IN: ', ast) +print("AST NOT IN: ", ast) solr_filter = to_filter(ast) -print('SOLR filter NOT IN: ', solr_filter) -print('\n') +print("SOLR filter NOT IN: ", solr_filter) +print("\n") # LIKE -print('Testing LIKE') +print("Testing LIKE") ast = parse("str_attribute LIKE 'this is a test'") -print('AST LIKE: ', ast) +print("AST LIKE: ", ast) solr_filter = to_filter(ast) -print('SOLR filter LIKE: ', solr_filter) -print('\n') +print("SOLR filter LIKE: ", solr_filter) +print("\n") # LIKE % -print('Testing LIKE %') +print("Testing LIKE %") ast = parse("str_attribute LIKE 'this is % test'") -print('AST LIKE %: ', ast) +print("AST LIKE %: ", ast) solr_filter = to_filter(ast) -print('SOLR filter LIKE %: ', solr_filter) -print('\n') +print("SOLR filter LIKE %: ", solr_filter) +print("\n") # NOT LIKE % -print('Testing NOT LIKE %') +print("Testing NOT LIKE %") ast = parse("str_attribute NOT LIKE '% another test'") -print('AST NOT LIKE %: ', ast) +print("AST NOT LIKE %: ", ast) solr_filter = to_filter(ast) -print('SOLR filter NOT LIKE %: ', solr_filter) -print('\n') +print("SOLR filter NOT LIKE %: ", solr_filter) +print("\n") # NOT LIKE . -print('Testing NOT LIKE .') +print("Testing NOT LIKE .") ast = parse("str_attribute NOT LIKE 'this is . test'") -print('AST NOT LIKE .: ', ast) +print("AST NOT LIKE .: ", ast) solr_filter = to_filter(ast) -print('SOLR filter NOT LIKE .: ', solr_filter) -print('\n') +print("SOLR filter NOT LIKE .: ", solr_filter) +print("\n") # ILIKE . -print('Testing ILIKE .') +print("Testing ILIKE .") ast = parse("str_attribute ILIKE 'THIS IS . TEST'") -print('AST ILIKE .: ', ast) +print("AST ILIKE .: ", ast) solr_filter = to_filter(ast) -print('SOLR filter ILIKE .: ', solr_filter) -print('\n') +print("SOLR filter ILIKE .: ", solr_filter) +print("\n") # ILIKE % -print('Testing ILIKE %') +print("Testing ILIKE %") ast = parse("str_attribute ILIKE 'THIS IS % TEST'") -print('AST ILIKE %: ', ast) +print("AST ILIKE %: ", ast) solr_filter = to_filter(ast) -print('SOLR filter ILIKE %: ', solr_filter) -print('\n') +print("SOLR filter ILIKE %: ", solr_filter) +print("\n") # EXISTS -print('Testing EXISTS') +print("Testing EXISTS") ast = parse("extra_attr EXISTS") -print('AST EXISTS: ', ast) +print("AST EXISTS: ", ast) solr_filter = to_filter(ast) -print('SOLR filter EXISTS: ', solr_filter) -print('\n') +print("SOLR filter EXISTS: ", solr_filter) +print("\n") # DOES-NOT-EXIST -print('Testing DOES-NOT-EXIST') +print("Testing DOES-NOT-EXIST") ast = parse("extra_attr DOES-NOT-EXIST") -print('AST DOES-NOT-EXIST: ', ast) +print("AST DOES-NOT-EXIST: ", ast) solr_filter = to_filter(ast) -print('SOLR filter DOES-NOT-EXIST: ', solr_filter) -print('\n') +print("SOLR filter DOES-NOT-EXIST: ", solr_filter) +print("\n") # Testing temporal BEFORE -print('Testing datetime attribute BEFORE') +print("Testing datetime attribute BEFORE") ast = parse("datetime_attribute BEFORE 2000-01-01T00:00:05.00Z") -print('AST BEFORE:', ast) +print("AST BEFORE:", ast) solr_filter = to_filter(ast) -print('datetime attribute BEFORE: ', solr_filter) -print('\n') +print("datetime attribute BEFORE: ", solr_filter) +print("\n") # Testing temporal AFTER -print('Testing datetime attribute AFTER') +print("Testing datetime attribute AFTER") ast = parse("datetime_attribute AFTER 2000-01-01T00:00:05.00Z") -print('AST AFTER:', ast) +print("AST AFTER:", ast) solr_filter = to_filter(ast) -print('datetime attribute AFTER: ', solr_filter) -print('\n') +print("datetime attribute AFTER: ", solr_filter) +print("\n") # Testing temporal AFTER # print('Testing datetime attribute DISJOINT') @@ -311,59 +310,59 @@ # Test spatial Intersects -print('Testing Spatial Intersects') +print("Testing Spatial Intersects") ast = parse("INTERSECTS(geometry, ENVELOPE (0.0 1.0 0.0 1.0))") -print('AST Spatial Intersects:', ast) +print("AST Spatial Intersects:", ast) solr_filter = to_filter(ast) -print('Spatial Intersects: ', solr_filter) -print('\n') +print("Spatial Intersects: ", solr_filter) +print("\n") # Test spatial Disjoint -print('Testing Spatial Disjoint') +print("Testing Spatial Disjoint") ast = parse("DISJOINT(geometry, ENVELOPE (0.0 1.0 0.0 1.0))") -print('AST Spatial Disjoint:', ast) +print("AST Spatial Disjoint:", ast) solr_filter = to_filter(ast) -print('Spatial Disjoint: ', solr_filter) -print('\n') +print("Spatial Disjoint: ", solr_filter) +print("\n") # Test spatial Within -print('Testing Spatial Within') +print("Testing Spatial Within") ast = parse("WITHIN(geometry, ENVELOPE (0.0 1.0 0.0 1.0))") -print('AST Spatial Within:', ast) +print("AST Spatial Within:", ast) solr_filter = to_filter(ast) -print('Spatial Within: ', solr_filter) -print('\n') +print("Spatial Within: ", solr_filter) +print("\n") # Test spatial Contains -print('Testing Spatial Contains') +print("Testing Spatial Contains") ast = parse("CONTAINS(geometry, ENVELOPE (0.0 1.0 0.0 1.0))") -print('AST Spatial Contains:', ast) +print("AST Spatial Contains:", ast) solr_filter = to_filter(ast) -print('Spatial Contains: ', solr_filter) -print('\n') +print("Spatial Contains: ", solr_filter) +print("\n") # Test spatial Equals -print('Testing Spatial Equals') +print("Testing Spatial Equals") ast = parse("EQUALS(geometry, ENVELOPE (0.0 1.0 0.0 1.0))") -print('AST Spatial Equals:', ast) +print("AST Spatial Equals:", ast) solr_filter = to_filter(ast) -print('Spatial Equals: ', solr_filter) -print('\n') +print("Spatial Equals: ", solr_filter) +print("\n") # Test spatial BBOX -print('Testing Spatial BBOX') +print("Testing Spatial BBOX") ast = parse("BBOX(center, 2, 2, 3, 3)") -print('AST Spatial BBOX:', ast) +print("AST Spatial BBOX:", ast) solr_filter = to_filter(ast) -print('Spatial BBOX: ', solr_filter) -print('\n') +print("Spatial BBOX: ", solr_filter) +print("\n") diff --git a/pygeofilter/ast.py b/pygeofilter/ast.py index b1f1374..8f8e031 100644 --- a/pygeofilter/ast.py +++ b/pygeofilter/ast.py @@ -704,7 +704,9 @@ def get_repr(node: Node, indent_amount: int = 0, indent_incr: int = 4) -> str: args.append( "(\n{}\n)".format( indent( - get_repr(sub_node, indent_amount + indent_incr, indent_incr), + get_repr( + sub_node, indent_amount + indent_incr, indent_incr + ), indent_amount + indent_incr, ) ) diff --git a/pygeofilter/backends/django/evaluate.py b/pygeofilter/backends/django/evaluate.py index 3498641..c8de7ef 100644 --- a/pygeofilter/backends/django/evaluate.py +++ b/pygeofilter/backends/django/evaluate.py @@ -116,7 +116,9 @@ def spatial_distance(self, node, lhs, rhs): @handle(ast.BBox) def bbox(self, node, lhs): - return filters.bbox(lhs, node.minx, node.miny, node.maxx, node.maxy, node.crs) + return filters.bbox( + lhs, node.minx, node.miny, node.maxx, node.maxy, node.crs + ) @handle(ast.Attribute) def attribute(self, node): diff --git a/pygeofilter/backends/django/filters.py b/pygeofilter/backends/django/filters.py index 6170d2d..a204218 100644 --- a/pygeofilter/backends/django/filters.py +++ b/pygeofilter/backends/django/filters.py @@ -55,7 +55,14 @@ def negate(sub_filter: Q) -> Q: return ~sub_filter -OP_TO_COMP = {"<": "lt", "<=": "lte", ">": "gt", ">=": "gte", "<>": None, "=": "exact"} +OP_TO_COMP = { + "<": "lt", + "<=": "lte", + ">": "gt", + ">=": "gte", + "<>": None, + "=": "exact", +} INVERT_COMP: Dict[Optional[str], str] = { "lt": "gt", @@ -171,7 +178,8 @@ def like( # special case when choices are given for the field: # compare statically and use 'in' operator to check if contained cmp_av = [ - (a, a.lower() if nocase else a) for a in mapping_choices[lhs.name].keys() + (a, a.lower() if nocase else a) + for a in mapping_choices[lhs.name].keys() ] for idx, part in enumerate(parts): @@ -190,7 +198,11 @@ def like( cmp_av = [a for a in cmp_av if cmp_p in a[1]] q = Q( - **{"%s__in" % lhs.name: [mapping_choices[lhs.name][a[0]] for a in cmp_av]} + **{ + "%s__in" % lhs.name: [ + mapping_choices[lhs.name][a[0]] for a in cmp_av + ] + } ) else: @@ -288,7 +300,13 @@ def temporal(lhs: F, time_or_period: Value, op: str) -> Q: :return: a comparison expression object :rtype: :class:`django.db.models.Q` """ - assert op in ("BEFORE", "BEFORE OR DURING", "DURING", "DURING OR AFTER", "AFTER") + assert op in ( + "BEFORE", + "BEFORE OR DURING", + "DURING", + "DURING OR AFTER", + "AFTER", + ) time_or_period = time_or_period.value low: Union[datetime, timedelta, None] = None high: Union[datetime, timedelta, None] = None @@ -345,7 +363,12 @@ def time_interval( ) elif high == low: - return Q(**{begin_time_field + "__gte": value, end_time_field + "__lte": value}) + return Q( + **{ + begin_time_field + "__gte": value, + end_time_field + "__lte": value, + } + ) else: q = Q() @@ -431,8 +454,9 @@ def spatial( return Q(**{"%s__%s" % (lhs.name, op.lower()): rhs}) -def spatial_relate(lhs: Union[F, Value], rhs: Union[F, Value], pattern: str) -> Q: - +def spatial_relate( + lhs: Union[F, Value], rhs: Union[F, Value], pattern: str +) -> Q: if not isinstance(lhs, F): # TODO: cannot yet invert pattern -> raise raise ValueError(f"Unable to compare non-field {lhs}") @@ -441,7 +465,11 @@ def spatial_relate(lhs: Union[F, Value], rhs: Union[F, Value], pattern: str) -> def spatial_distance( - lhs: Union[F, Value], rhs: Union[F, Value], op: str, distance: float, units: str + lhs: Union[F, Value], + rhs: Union[F, Value], + op: str, + distance: float, + units: str, ) -> Q: if not isinstance(lhs, F): lhs, rhs = rhs, lhs @@ -516,7 +544,9 @@ def literal(value) -> Value: OP_TO_FUNC = {"+": add, "-": sub, "*": mul, "/": truediv} -def arithmetic(lhs: ArithmeticType, rhs: ArithmeticType, op: str) -> ArithmeticType: +def arithmetic( + lhs: ArithmeticType, rhs: ArithmeticType, op: str +) -> ArithmeticType: """Create an arithmetic filter :param lhs: left hand side of the arithmetic expression. either a diff --git a/pygeofilter/backends/elasticsearch/evaluate.py b/pygeofilter/backends/elasticsearch/evaluate.py index 2ae26e6..5ea4aa7 100644 --- a/pygeofilter/backends/elasticsearch/evaluate.py +++ b/pygeofilter/backends/elasticsearch/evaluate.py @@ -219,7 +219,9 @@ def temporal(self, node: ast.TemporalPredicate, lhs, rhs): ast.GeometryWithin, ast.GeometryContains, ) - def spatial_comparison(self, node: ast.SpatialComparisonPredicate, lhs: str, rhs): + def spatial_comparison( + self, node: ast.SpatialComparisonPredicate, lhs: str, rhs + ): """Creates a geo_shape query for the give spatial comparison predicate. """ @@ -243,7 +245,9 @@ def bbox(self, node: ast.BBox, lhs): **{ lhs: { "shape": self.envelope( - values.Envelope(node.minx, node.maxx, node.miny, node.maxy) + values.Envelope( + node.minx, node.maxx, node.miny, node.maxy + ) ), "relation": "intersects", }, diff --git a/pygeofilter/backends/evaluator.py b/pygeofilter/backends/evaluator.py index d0f7970..ca95e7e 100644 --- a/pygeofilter/backends/evaluator.py +++ b/pygeofilter/backends/evaluator.py @@ -100,7 +100,9 @@ def evaluate(self, node: ast.AstType, adopt_result: bool = True) -> Any: 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] + sub_args = [ + self.evaluate(sub_node, False) for sub_node in subnodes + ] else: sub_args = [self.evaluate(subnodes, False)] @@ -119,7 +121,9 @@ def adopt(self, node, *sub_args): """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)}") + raise NotImplementedError( + f"Failed to evaluate node of type {type(node)}" + ) def adopt_result(self, result: Any) -> Any: """Interface function for adopting the final evaluation result diff --git a/pygeofilter/backends/geopandas/evaluate.py b/pygeofilter/backends/geopandas/evaluate.py index 98b33ab..17b236a 100644 --- a/pygeofilter/backends/geopandas/evaluate.py +++ b/pygeofilter/backends/geopandas/evaluate.py @@ -107,7 +107,9 @@ def spatial_operation(self, node, lhs, rhs): @handle(ast.BBox) def bbox(self, node, lhs): - return filters.bbox(lhs, node.minx, node.miny, node.maxx, node.maxy, node.crs) + return filters.bbox( + lhs, node.minx, node.miny, node.maxx, node.maxy, node.crs + ) @handle(ast.Attribute) def attribute(self, node): diff --git a/pygeofilter/backends/geopandas/filters.py b/pygeofilter/backends/geopandas/filters.py index 23feafd..2fe4f71 100644 --- a/pygeofilter/backends/geopandas/filters.py +++ b/pygeofilter/backends/geopandas/filters.py @@ -10,7 +10,9 @@ def combine(sub_filters, combinator: str): """Combine filters using a logical combinator""" assert combinator in ("AND", "OR") op = and_ if combinator == "AND" else or_ - return reduce(lambda acc, q: op(acc, q) if acc is not None else q, sub_filters) + return reduce( + lambda acc, q: op(acc, q) if acc is not None else q, sub_filters + ) def negate(sub_filter): diff --git a/pygeofilter/backends/native/evaluate.py b/pygeofilter/backends/native/evaluate.py index ed5ee49..f865126 100644 --- a/pygeofilter/backends/native/evaluate.py +++ b/pygeofilter/backends/native/evaluate.py @@ -148,7 +148,11 @@ def between(self, node, lhs, low, high): def like(self, node, lhs): maybe_not_inv = "" if node.not_ else "not " regex = like_pattern_to_re( - node.pattern, node.nocase, node.wildcard, node.singlechar, node.escapechar + node.pattern, + node.nocase, + node.wildcard, + node.singlechar, + node.escapechar, ) key = self._add_local(regex) return f"({key}.match({lhs}) is {maybe_not_inv}None)" @@ -192,11 +196,16 @@ def array(self, node, lhs, rhs): @handle(ast.SpatialComparisonPredicate, subclasses=True) def spatial_operation(self, node, lhs, rhs): - return f"(getattr(ensure_spatial({lhs}), " f"{node.op.value.lower()!r})({rhs}))" + return ( + f"(getattr(ensure_spatial({lhs}), " + f"{node.op.value.lower()!r})({rhs}))" + ) @handle(ast.Relate) def spatial_pattern(self, node, lhs, rhs): - return f"(ensure_spatial({lhs}).relate_pattern({rhs}, {node.pattern!r}))" + return ( + f"(ensure_spatial({lhs}).relate_pattern({rhs}, {node.pattern!r}))" + ) @handle(ast.BBox) def bbox(self, node, lhs): @@ -246,7 +255,9 @@ def geometry(self, node): @handle(values.Envelope) def envelope(self, node): key = self._add_local( - shapely.geometry.Polygon.from_bounds(node.x1, node.y1, node.x2, node.y2) + shapely.geometry.Polygon.from_bounds( + node.x1, node.y1, node.x2, node.y2 + ) ) return key @@ -264,7 +275,8 @@ def adopt_result(self, result): } if not set(globals_).isdisjoint(set(self.function_map)): raise ValueError( - f"globals collision {list(globals_)} and " f"{list(self.function_map)}" + f"globals collision {list(globals_)} and " + f"{list(self.function_map)}" ) globals_.update(self.function_map) diff --git a/pygeofilter/backends/opensearch/evaluate.py b/pygeofilter/backends/opensearch/evaluate.py index 40e0e46..d4bd925 100644 --- a/pygeofilter/backends/opensearch/evaluate.py +++ b/pygeofilter/backends/opensearch/evaluate.py @@ -219,7 +219,9 @@ def temporal(self, node: ast.TemporalPredicate, lhs, rhs): ast.GeometryWithin, ast.GeometryContains, ) - def spatial_comparison(self, node: ast.SpatialComparisonPredicate, lhs: str, rhs): + def spatial_comparison( + self, node: ast.SpatialComparisonPredicate, lhs: str, rhs + ): """Creates a geo_shape query for the give spatial comparison predicate. """ @@ -243,7 +245,9 @@ def bbox(self, node: ast.BBox, lhs): **{ lhs: { "shape": self.envelope( - values.Envelope(node.minx, node.maxx, node.miny, node.maxy) + values.Envelope( + node.minx, node.maxx, node.miny, node.maxy + ) ), "relation": "intersects", }, diff --git a/pygeofilter/backends/optimize.py b/pygeofilter/backends/optimize.py index 059f830..382ab3c 100644 --- a/pygeofilter/backends/optimize.py +++ b/pygeofilter/backends/optimize.py @@ -71,7 +71,11 @@ def is_geometry_literal(value): def is_any_literal(value): - return is_literal(value) or is_temporal_literal(value) or is_geometry_literal(value) + return ( + is_literal(value) + or is_temporal_literal(value) + or is_geometry_literal(value) + ) def to_geometry(value): diff --git a/pygeofilter/backends/oraclesql/evaluate.py b/pygeofilter/backends/oraclesql/evaluate.py index 18a8c04..8638d0d 100644 --- a/pygeofilter/backends/oraclesql/evaluate.py +++ b/pygeofilter/backends/oraclesql/evaluate.py @@ -65,7 +65,9 @@ class OracleSQLEvaluator(Evaluator): bind_variables: Dict[str, Any] - def __init__(self, attribute_map: Dict[str, str], function_map: Dict[str, str]): + def __init__( + self, attribute_map: Dict[str, str], function_map: Dict[str, str] + ): self.attribute_map = attribute_map self.function_map = function_map @@ -103,7 +105,9 @@ def between(self, node, lhs, low, high): ) self.b_cnt += 1 else: - sql = f"({lhs} {'NOT ' if node.not_ else ''}BETWEEN " f"{low} AND {high})" + sql = ( + f"({lhs} {'NOT ' if node.not_ else ''}BETWEEN {low} AND {high})" + ) return sql @handle(ast.Like) @@ -168,7 +172,7 @@ def bbox(self, node, lhs): self.b_cnt += 1 else: geom_sql = ( - f"SDO_UTIL.FROM_JSON(geometry => '{geo_json}', " f"srid => {srid})" + f"SDO_UTIL.FROM_JSON(geometry => '{geo_json}', srid => {srid})" ) sql = f"SDO_RELATE({lhs}, {geom_sql}, '{param}') = 'TRUE'" @@ -211,7 +215,9 @@ def geometry(self, node: values.Geometry): ) self.b_cnt += 1 else: - sql = f"SDO_UTIL.FROM_JSON(geometry => '{geo_json}', " f"srid => {srid})" + sql = ( + f"SDO_UTIL.FROM_JSON(geometry => '{geo_json}', srid => {srid})" + ) return sql @handle(values.Envelope) @@ -229,7 +235,9 @@ def envelope(self, node: values.Envelope): ) self.b_cnt += 1 else: - sql = f"SDO_UTIL.FROM_JSON(geometry => '{geo_json}', " f"srid => {srid})" + sql = ( + f"SDO_UTIL.FROM_JSON(geometry => '{geo_json}', srid => {srid})" + ) return sql diff --git a/pygeofilter/backends/solr/__init__.py b/pygeofilter/backends/solr/__init__.py index 090a819..1607256 100644 --- a/pygeofilter/backends/solr/__init__.py +++ b/pygeofilter/backends/solr/__init__.py @@ -25,8 +25,7 @@ # THE SOFTWARE. # ------------------------------------------------------------------------------ -""" Apache Solr backend for pygeofilter -""" +"""Apache Solr backend for pygeofilter""" from .evaluate import to_filter diff --git a/pygeofilter/backends/solr/evaluate.py b/pygeofilter/backends/solr/evaluate.py index 875e6b1..3de0ef8 100644 --- a/pygeofilter/backends/solr/evaluate.py +++ b/pygeofilter/backends/solr/evaluate.py @@ -63,11 +63,20 @@ def _invert_filter_query(filter_query): (e.g., bool dicts), wraps the filter in a bool.must_not structure. """ if isinstance(filter_query, str): - return filter_query[1:] if filter_query.startswith("-") else f"-{filter_query}" - if isinstance(filter_query, dict) and "bool" in filter_query and "must_not" in filter_query["bool"]: + return ( + filter_query[1:] + if filter_query.startswith("-") + else f"-{filter_query}" + ) + if ( + isinstance(filter_query, dict) + and "bool" in filter_query + and "must_not" in filter_query["bool"] + ): return {"bool": {"must": filter_query["bool"]["must_not"]}} return {"bool": {"must_not": [filter_query]}} + def _to_solr_date(value): """Convert input date/datetime to Solr UTC datetime string: YYYY-MM-DDTHH:MM:SSZ. @@ -111,9 +120,10 @@ def _to_solr_date(value): # Solr wants Zulu time with second precision. return dt_utc.strftime("%Y-%m-%dT%H:%M:%SZ") + COMPARISON_OP_MAP = { - ast.ComparisonOp.EQ: "{lhs}:\"{rhs}\"", - ast.ComparisonOp.NE: "-{lhs}:\"{rhs}\"", + ast.ComparisonOp.EQ: '{lhs}:"{rhs}"', + ast.ComparisonOp.NE: '-{lhs}:"{rhs}"', ast.ComparisonOp.GT: "{lhs}:{{{rhs} TO *]", ast.ComparisonOp.GE: "{lhs}:[{rhs} TO *]", ast.ComparisonOp.LT: "{lhs}:[* TO {rhs}}}", @@ -187,10 +197,18 @@ def and_(self, _, lhs, rhs): not be merged into bool.must (they don't work correctly in the query position for Geo3D fields). Non-spatial queries are combined in bool.must as before. """ - lhs_q = lhs.get("query", "*:*") if isinstance(lhs, SolrDSLQuery) else lhs - rhs_q = rhs.get("query", "*:*") if isinstance(rhs, SolrDSLQuery) else rhs - lhs_filters = list(lhs.get("filter", [])) if isinstance(lhs, SolrDSLQuery) else [] - rhs_filters = list(rhs.get("filter", [])) if isinstance(rhs, SolrDSLQuery) else [] + lhs_q = ( + lhs.get("query", "*:*") if isinstance(lhs, SolrDSLQuery) else lhs + ) + rhs_q = ( + rhs.get("query", "*:*") if isinstance(rhs, SolrDSLQuery) else rhs + ) + lhs_filters = ( + list(lhs.get("filter", [])) if isinstance(lhs, SolrDSLQuery) else [] + ) + rhs_filters = ( + list(rhs.get("filter", [])) if isinstance(rhs, SolrDSLQuery) else [] + ) combined_filters = lhs_filters + rhs_filters # Build must list from non-trivial (non-wildcard) query parts @@ -229,7 +247,11 @@ def normalize_clause(clause): return {"bool": {"must": ["*:*"], "must_not": [clause[1:]]}} if isinstance(clause, dict) and "bool" in clause: bool_part = clause["bool"] - if "must_not" in bool_part and "must" not in bool_part and "should" not in bool_part: + if ( + "must_not" in bool_part + and "must" not in bool_part + and "should" not in bool_part + ): normalized = dict(clause) normalized["bool"] = dict(bool_part) normalized["bool"]["must"] = ["*:*"] @@ -266,7 +288,9 @@ def comparison(self, node, lhs, rhs): Creates a range query for comparison operators. """ rhs = _to_solr_date(rhs) - return SolrDSLQuery(f"{COMPARISON_OP_MAP[node.op]}".format(lhs=lhs, rhs=rhs)) + return SolrDSLQuery( + f"{COMPARISON_OP_MAP[node.op]}".format(lhs=lhs, rhs=rhs) + ) @handle(ast.Between) def between(self, node: ast.Between, lhs, low, high): @@ -286,10 +310,12 @@ def in_(self, node, lhs, *options): """ Creates a terms query for `IN` conditions. """ + def _quote(v): if isinstance(v, str): return '"' + v.replace('"', '\\"') + '"' return str(v) + options_str = " OR ".join(_quote(option) for option in options) terms_query = f"{lhs}:({options_str})" if node.not_: @@ -340,22 +366,34 @@ def not_(self, _, sub): result = SolrDSLQuery("*:*") if sub_filters: - result["filter"] = [_invert_filter_query(fq) for fq in sub_filters] + result["filter"] = [ + _invert_filter_query(fq) for fq in sub_filters + ] # A wildcard query contributes no restriction and does not need # query-level negation. if sub_query == "*:*": return result - if isinstance(sub_query, dict) and "bool" in sub_query and "must_not" in sub_query["bool"]: - result["query"] = {"bool": {"must": sub_query["bool"]["must_not"]}} + if ( + isinstance(sub_query, dict) + and "bool" in sub_query + and "must_not" in sub_query["bool"] + ): + result["query"] = { + "bool": {"must": sub_query["bool"]["must_not"]} + } return result result["query"] = {"bool": {"must_not": [sub_query]}} return result # Non-SolrDSLQuery fallback. - if isinstance(sub, dict) and "bool" in sub and "must_not" in sub["bool"]: + if ( + isinstance(sub, dict) + and "bool" in sub + and "must_not" in sub["bool"] + ): return SolrDSLQuery({"bool": {"must": sub["bool"]["must_not"]}}) return SolrDSLQuery({"bool": {"must_not": [sub]}}) @@ -364,7 +402,9 @@ def like(self, node: ast.Like, lhs): """Transforms the provided LIKE pattern to a Solr wildcard pattern. This only works properly on fields that are not tokenized. """ - pattern = like_to_wildcard(node.pattern, node.wildcard, node.singlechar, node.escapechar) + pattern = like_to_wildcard( + node.pattern, node.wildcard, node.singlechar, node.escapechar + ) if "*" in pattern: p = pattern.split("*") if p[0] == "": @@ -393,7 +433,7 @@ def geometry(self, node: values.Geometry): """Geometry values are converted to a Solr spatial query.""" geom_wkt = shape(node).wkt geom = shapely.wkt.loads(geom_wkt) - if geom.geom_type == "Polygon" or geom.geom_type =="MultiPolygon": + if geom.geom_type == "Polygon" or geom.geom_type == "MultiPolygon": # Rectangular polygons (from BBox) must use ENVELOPE format for Geo3D. # WKT polygons with coordinates at ±180/±90 are "coplanar" in 3D space # (poles and antimeridian are degenerate points), causing Solr to reject @@ -408,7 +448,9 @@ def geometry(self, node: values.Geometry): # Global bbox covers the whole Earth — spatial predicate is a no-op. if minx <= -180 and miny <= -90 and maxx >= 180 and maxy >= 90: return None - if (minx <= -179.9999 and maxx >= 179.9999) or (miny <= -89.9999 and maxy >= 89.9999): + if (minx <= -179.9999 and maxx >= 179.9999) or ( + miny <= -89.9999 and maxy >= 89.9999 + ): return f"ENVELOPE({minx}, {maxx}, {maxy}, {miny})" geom = geom.reverse() if not geom.exterior.is_ccw else geom return geom.wkt @@ -436,7 +478,10 @@ def temporal(self, node: ast.TemporalPredicate, lhs, rhs): if isinstance(rhs, (date, datetime)): low = high = rhs.strftime("%Y-%m-%dT%H:%M:%SZ") else: - low, high = rhs[0].strftime("%Y-%m-%dT%H:%M:%SZ"), rhs[1].strftime("%Y-%m-%dT%H:%M:%SZ") + low, high = ( + rhs[0].strftime("%Y-%m-%dT%H:%M:%SZ"), + rhs[1].strftime("%Y-%m-%dT%H:%M:%SZ"), + ) query = None if op == ast.TemporalComparisonOp.DISJOINT: @@ -445,7 +490,10 @@ def temporal(self, node: ast.TemporalPredicate, lhs, rhs): query = f"{lhs}:{{{high} TO *]" elif op == ast.TemporalComparisonOp.BEFORE: query = f"{lhs}:[* TO {low}}}" - elif op == ast.TemporalComparisonOp.TOVERLAPS or op == ast.TemporalComparisonOp.OVERLAPPEDBY: + elif ( + op == ast.TemporalComparisonOp.TOVERLAPS + or op == ast.TemporalComparisonOp.OVERLAPPEDBY + ): query = f"{lhs}:[{low} TO {high}]" elif op == ast.TemporalComparisonOp.BEGINS: query = f"{lhs}:{low}" @@ -470,8 +518,16 @@ def temporal(self, node: ast.TemporalPredicate, lhs, rhs): return SolrDSLQuery(query) - @handle(ast.GeometryIntersects, ast.GeometryDisjoint, ast.GeometryWithin, ast.GeometryContains, ast.GeometryEquals) - def spatial_comparison(self, node: ast.SpatialComparisonPredicate, lhs: str, rhs): + @handle( + ast.GeometryIntersects, + ast.GeometryDisjoint, + ast.GeometryWithin, + ast.GeometryContains, + ast.GeometryEquals, + ) + def spatial_comparison( + self, node: ast.SpatialComparisonPredicate, lhs: str, rhs + ): """Creates a spatial query for the given spatial comparison predicate. Spatial {!field ...} queries MUST go into the Solr filter[] array, not the @@ -492,9 +548,11 @@ def bbox(self, node: ast.BBox, lhs): """Performs a spatial query for the given bounding box. Ignores CRS parameter, as it is not supported by Solr. """ - bbox = self.envelope(values.Envelope(node.minx, node.maxx, node.miny, node.maxy)) + bbox = self.envelope( + values.Envelope(node.minx, node.maxx, node.miny, node.maxy) + ) query = f"{{!field f={lhs} v='Intersects({bbox})'}}" - return SolrDSLQuery('*:*', filters=[query]) + return SolrDSLQuery("*:*", filters=[query]) # @handle(ast.Arithmetic, subclasses=True) # def arithmetic(self, node: ast.Arithmetic, lhs, rhs): @@ -528,7 +586,9 @@ def to_filter( """Shorthand function to convert a pygeofilter AST to an Apache Solr filter structure. """ - return SOLRDSLEvaluator(attribute_map, Version(str(version)) if version else None).evaluate(root) + return SOLRDSLEvaluator( + attribute_map, Version(str(version)) if version else None + ).evaluate(root) def unwrap_query(obj): diff --git a/pygeofilter/backends/solr/util.py b/pygeofilter/backends/solr/util.py index a468249..2c935ee 100644 --- a/pygeofilter/backends/solr/util.py +++ b/pygeofilter/backends/solr/util.py @@ -25,8 +25,7 @@ # THE SOFTWARE. # ------------------------------------------------------------------------------ -""" General utilities for the Apache Solr backend. -""" +"""General utilities for the Apache Solr backend.""" import re diff --git a/pygeofilter/backends/sql/evaluate.py b/pygeofilter/backends/sql/evaluate.py index cf813a8..d364efe 100644 --- a/pygeofilter/backends/sql/evaluate.py +++ b/pygeofilter/backends/sql/evaluate.py @@ -160,7 +160,9 @@ def geometry(self, node: values.Geometry): @handle(values.Envelope) def envelope(self, node: values.Envelope): - wkb_hex = shapely.geometry.box(node.x1, node.y1, node.x2, node.y2).wkb_hex + wkb_hex = shapely.geometry.box( + node.x1, node.y1, node.x2, node.y2 + ).wkb_hex return f"ST_GeomFromWKB(x'{wkb_hex}')" diff --git a/pygeofilter/backends/sqlalchemy/evaluate.py b/pygeofilter/backends/sqlalchemy/evaluate.py index af1536d..4b4f5b1 100644 --- a/pygeofilter/backends/sqlalchemy/evaluate.py +++ b/pygeofilter/backends/sqlalchemy/evaluate.py @@ -34,8 +34,10 @@ def between(self, node, lhs, low, high): @handle(ast.Like) def like(self, node, lhs): - if (isinstance(node.pattern, ast.Function)): - node.pattern = filters.function_map[node.pattern.name](*node.pattern.arguments) + if isinstance(node.pattern, ast.Function): + node.pattern = filters.function_map[node.pattern.name]( + *node.pattern.arguments + ) return filters.like( lhs, node.pattern, @@ -104,11 +106,15 @@ def spatial_distance(self, node, lhs, rhs): @handle(ast.BBox) def bbox(self, node, lhs): - return filters.bbox(lhs, node.minx, node.miny, node.maxx, node.maxy, node.crs) + return filters.bbox( + lhs, node.minx, node.miny, node.maxx, node.maxy, node.crs + ) @handle(ast.Attribute) def attribute(self, node): - return filters.attribute(node.name, self.field_mapping, self.undefined_as_null) + return filters.attribute( + node.name, self.field_mapping, self.undefined_as_null + ) @handle(ast.Arithmetic, subclasses=True) def arithmetic(self, node, lhs, rhs): @@ -146,4 +152,6 @@ def to_filter(ast, field_mapping={}, undefined_as_null=None): :type ast: :class:`Node` :returns: a SQLAlchemy query object """ - return SQLAlchemyFilterEvaluator(field_mapping, undefined_as_null).evaluate(ast) + return SQLAlchemyFilterEvaluator(field_mapping, undefined_as_null).evaluate( + ast + ) diff --git a/pygeofilter/backends/sqlalchemy/filters.py b/pygeofilter/backends/sqlalchemy/filters.py index c99c68d..14f4b77 100644 --- a/pygeofilter/backends/sqlalchemy/filters.py +++ b/pygeofilter/backends/sqlalchemy/filters.py @@ -27,16 +27,15 @@ def parse_geometry(geom: dict): wkt = shape(geom).wkt return func.ST_GeomFromEWKT(f"SRID={srid};{wkt}") + # TODO: map functions -function_map = { - "lower": func.lower -} +function_map = {"lower": func.lower} + # ------------------------------------------------------------------------------ # Filters # ------------------------------------------------------------------------------ class Operator: - OPERATORS: Dict[str, Callable] = { "is_null": lambda f, a=None: f.is_(None), "is_not_null": lambda f, a=None: f.isnot(None), diff --git a/pygeofilter/cli.py b/pygeofilter/cli.py index b44313c..a13b5f7 100644 --- a/pygeofilter/cli.py +++ b/pygeofilter/cli.py @@ -31,9 +31,9 @@ import click -from .parsers.cql_json.parser import parse as parse_cql_json from .parsers.cql2_json.parser import parse as parse_cql2_json from .parsers.cql2_text.parser import parse as parse_cql2_text +from .parsers.cql_json.parser import parse as parse_cql_json from .parsers.ecql.parser import parse as parse_ecql from .parsers.fes.parser import parse as parse_fes from .parsers.jfe.parser import parse as parse_jfe @@ -42,27 +42,32 @@ __all__ = ["__version__"] PARSERS: dict[str, Any] = { - 'cql_json': parse_cql_json, - 'cql2_json': parse_cql2_json, - 'cql2_text': parse_cql2_text, - 'ecql': parse_ecql, - 'fes': parse_fes, - 'jfe': parse_jfe + "cql_json": parse_cql_json, + "cql2_json": parse_cql2_json, + "cql2_text": parse_cql2_text, + "ecql": parse_ecql, + "fes": parse_fes, + "jfe": parse_jfe, } def CLI_OPTION_VERBOSITY(f): """Setup click logging output""" + def callback(ctx, param, value): if value is not None: - logging.basicConfig(stream=sys.stdout, - level=getattr(logging, value)) + logging.basicConfig( + stream=sys.stdout, level=getattr(logging, value) + ) return True - return click.option('--verbosity', '-v', - type=click.Choice(['ERROR', 'WARNING', 'INFO', 'DEBUG']), - help='Verbosity', - callback=callback)(f) + return click.option( + "--verbosity", + "-v", + type=click.Choice(["ERROR", "WARNING", "INFO", "DEBUG"]), + help="Verbosity", + callback=callback, + )(f) @click.group() @@ -73,13 +78,13 @@ def cli(): @cli.command() @click.pass_context -@click.argument('parser', type=click.Choice(PARSERS.keys())) -@click.argument('query') +@click.argument("parser", type=click.Choice(PARSERS.keys())) +@click.argument("query") @CLI_OPTION_VERBOSITY def parse(ctx, parser, query, verbosity): """Parse a query into an abstract syntax tree""" - click.echo(f'Parsing {parser} query into AST') + click.echo(f"Parsing {parser} query into AST") try: click.echo(PARSERS[parser](query)) except Exception as err: diff --git a/pygeofilter/parsers/cql2_json/parser.py b/pygeofilter/parsers/cql2_json/parser.py index e3c2e9a..5686947 100644 --- a/pygeofilter/parsers/cql2_json/parser.py +++ b/pygeofilter/parsers/cql2_json/parser.py @@ -106,11 +106,15 @@ def walk_cql_json(node: JsonType): # noqa: C901 elif "function" in node: return ast.Function( node["function"]["name"], - cast(List[ast.AstType], walk_cql_json(node["function"]["arguments"])), + cast( + List[ast.AstType], walk_cql_json(node["function"]["arguments"]) + ), ) elif "lower" in node: - return ast.Function("lower", [cast(ast.Node, walk_cql_json(node["lower"]))]) + return ast.Function( + "lower", [cast(ast.Node, walk_cql_json(node["lower"]))] + ) elif "op" in node: op = node["op"] @@ -157,9 +161,11 @@ def walk_cql_json(node: JsonType): # noqa: C901 cast(List[ast.AstType], walk_cql_json(args[1])), not_=False, ) - + elif op == "casei": - return ast.Function("lower", [cast(ast.Node, walk_cql_json(args[0]))]) + return ast.Function( + "lower", [cast(ast.Node, walk_cql_json(args[0]))] + ) elif op in BINARY_OP_PREDICATES_MAP: args = [cast(ast.Node, walk_cql_json(arg)) for arg in args] diff --git a/pygeofilter/parsers/cql_json/parser.py b/pygeofilter/parsers/cql_json/parser.py index cb824c9..b0d969e 100644 --- a/pygeofilter/parsers/cql_json/parser.py +++ b/pygeofilter/parsers/cql_json/parser.py @@ -109,7 +109,8 @@ def walk_cql_json(node: dict, is_temporal: bool = False) -> ast.AstType: # noqa if isinstance(node, list): result = [ - cast(datetime, walk_cql_json(sub_node, is_temporal)) for sub_node in node + cast(datetime, walk_cql_json(sub_node, is_temporal)) + for sub_node in node ] if is_temporal: return values.Interval(*result) @@ -186,8 +187,14 @@ def walk_cql_json(node: dict, is_temporal: bool = False) -> ast.AstType: # noqa elif name in TEMPORAL_PREDICATES_MAP: return TEMPORAL_PREDICATES_MAP[name]( - cast(ast.TemporalAstType, walk_cql_json(value[0], is_temporal=True)), - cast(ast.TemporalAstType, walk_cql_json(value[1], is_temporal=True)), + cast( + ast.TemporalAstType, + walk_cql_json(value[0], is_temporal=True), + ), + cast( + ast.TemporalAstType, + walk_cql_json(value[1], is_temporal=True), + ), ) elif name in ARRAY_PREDICATES_MAP: diff --git a/pygeofilter/parsers/fes/gml.py b/pygeofilter/parsers/fes/gml.py index 7735420..6c65e6a 100644 --- a/pygeofilter/parsers/fes/gml.py +++ b/pygeofilter/parsers/fes/gml.py @@ -21,10 +21,12 @@ def _parse_time_instant(node: Element, nsmap: Dict[str, str]) -> datetime: def _parse_time_period(node: Element, nsmap: Dict[str, str]) -> values.Interval: begin = node.xpath( - "gml:begin/gml:TimeInstant/gml:timePosition|gml:beginPosition", namespaces=nsmap + "gml:begin/gml:TimeInstant/gml:timePosition|gml:beginPosition", + namespaces=nsmap, )[0] end = node.xpath( - "gml:end/gml:TimeInstant/gml:timePosition|gml:endPosition", namespaces=nsmap + "gml:end/gml:TimeInstant/gml:timePosition|gml:endPosition", + namespaces=nsmap, )[0] return values.Interval( _parse_time_position(begin, nsmap), diff --git a/pygeofilter/parsers/fes/util.py b/pygeofilter/parsers/fes/util.py index afefbf1..8f14680 100644 --- a/pygeofilter/parsers/fes/util.py +++ b/pygeofilter/parsers/fes/util.py @@ -19,7 +19,9 @@ class Missing: def handle( - *tags: str, namespace: Union[str, Type[Missing]] = Missing, subiter: bool = True + *tags: str, + namespace: Union[str, Type[Missing]] = Missing, + subiter: bool = True, ) -> Callable: """Function-decorator to mark a class function as a handler for a given node type. @@ -68,7 +70,8 @@ def __init__(cls, name, bases, dct): namespace = value.namespace if namespace is Missing: namespace = ( - getattr(cls_, "namespace", None) or cls_namespace + getattr(cls_, "namespace", None) + or cls_namespace ) if namespace: if isinstance(namespace, (list, tuple)): @@ -114,7 +117,9 @@ def _evaluate_node(self, node: etree._Element) -> ast.Node: raise NodeParsingError(f"Cannot parse XML tag {node.tag}") if parse_func.subiter: - sub_nodes = [self._evaluate_node(child) for child in node.iterchildren()] + sub_nodes = [ + self._evaluate_node(child) for child in node.iterchildren() + ] return parse_func(self, node, *sub_nodes) else: return parse_func(self, node) diff --git a/pygeofilter/parsers/iso8601.py b/pygeofilter/parsers/iso8601.py index 04a0027..8c3f822 100644 --- a/pygeofilter/parsers/iso8601.py +++ b/pygeofilter/parsers/iso8601.py @@ -27,7 +27,7 @@ from lark import Transformer, v_args -from ..util import parse_datetime, parse_duration, parse_date +from ..util import parse_date, parse_datetime, parse_duration @v_args(meta=False, inline=True) @@ -37,6 +37,6 @@ def DATETIME(self, dt): def DURATION(self, duration): return parse_duration(duration) - + def DATE(self, date): return parse_date(date) diff --git a/pygeofilter/parsers/jfe/parser.py b/pygeofilter/parsers/jfe/parser.py index e1da995..e19e442 100644 --- a/pygeofilter/parsers/jfe/parser.py +++ b/pygeofilter/parsers/jfe/parser.py @@ -133,26 +133,35 @@ def _parse_node(node: Union[list, dict]) -> ParseResult: # noqa: C901 ) elif op in SPATIAL_PREDICATES_MAP: - return SPATIAL_PREDICATES_MAP[op](*cast(List[ast.SpatialAstType], arguments)) + return SPATIAL_PREDICATES_MAP[op]( + *cast(List[ast.SpatialAstType], arguments) + ) elif op in TEMPORAL_PREDICATES_MAP: # parse strings to datetimes dt_args = [ - parse_datetime(arg) if isinstance(arg, str) else arg for arg in arguments + parse_datetime(arg) if isinstance(arg, str) else arg + for arg in arguments ] if len(arguments) == 3: - if isinstance(dt_args[0], datetime) and isinstance(dt_args[1], datetime): + if isinstance(dt_args[0], datetime) and isinstance( + dt_args[1], datetime + ): dt_args = [ values.Interval(dt_args[0], dt_args[1]), dt_args[2], ] - if isinstance(dt_args[1], datetime) and isinstance(dt_args[2], datetime): + if isinstance(dt_args[1], datetime) and isinstance( + dt_args[2], datetime + ): dt_args = [ dt_args[0], values.Interval(dt_args[1], dt_args[2]), ] - return TEMPORAL_PREDICATES_MAP[op](*cast(List[ast.TemporalAstType], dt_args)) + return TEMPORAL_PREDICATES_MAP[op]( + *cast(List[ast.TemporalAstType], dt_args) + ) # special property getters elif op in ["id", "geometry"]: diff --git a/pygeofilter/values.py b/pygeofilter/values.py index a3b1e7e..141f43e 100644 --- a/pygeofilter/values.py +++ b/pygeofilter/values.py @@ -44,6 +44,7 @@ def __geo_interface__(self): def __eq__(self, o: object) -> bool: return shape(self).__geo_interface__ == shape(o).__geo_interface__ + @dataclass class Envelope: x1: float diff --git a/tests/backends/django/test_django_evaluate.py b/tests/backends/django/test_django_evaluate.py index 5df0dda..ef41641 100644 --- a/tests/backends/django/test_django_evaluate.py +++ b/tests/backends/django/test_django_evaluate.py @@ -42,7 +42,9 @@ def evaluate(cql_expr, expected_ids, model_type=None): qs = model_type.objects.filter(filters) - assert expected_ids == type(expected_ids)(qs.values_list("identifier", flat=True)) + assert expected_ids == type(expected_ids)( + qs.values_list("identifier", flat=True) + ) # common comparisons @@ -276,21 +278,21 @@ def test_before_or_during_dt_dt(): @pytest.mark.django_db def test_before_or_during_dt_td(): evaluate( - "datetimeAttribute BEFORE OR DURING " "2000-01-01T00:00:00Z / PT4S", ("A",) + "datetimeAttribute BEFORE OR DURING 2000-01-01T00:00:00Z / PT4S", ("A",) ) @pytest.mark.django_db def test_before_or_during_td_dt(): evaluate( - "datetimeAttribute BEFORE OR DURING " "PT4S / 2000-01-01T00:00:03Z", ("A",) + "datetimeAttribute BEFORE OR DURING PT4S / 2000-01-01T00:00:03Z", ("A",) ) @pytest.mark.django_db def test_during_td_dt(): evaluate( - "datetimeAttribute BEFORE OR DURING " "PT4S / 2000-01-01T00:00:03Z", ("A",) + "datetimeAttribute BEFORE OR DURING PT4S / 2000-01-01T00:00:03Z", ("A",) ) @@ -341,12 +343,16 @@ def test_intersects_linestring__inv(): @pytest.mark.django_db def test_intersects_multilinestring(): - evaluate("INTERSECTS(geometry, MULTILINESTRING((0 0, 1 1), (2 1, 1 2)))", ("A",)) + evaluate( + "INTERSECTS(geometry, MULTILINESTRING((0 0, 1 1), (2 1, 1 2)))", ("A",) + ) @pytest.mark.django_db def test_intersects_multilinestring_inv(): - evaluate("INTERSECTS(MULTILINESTRING((0 0, 1 1), (2 1, 1 2)), geometry)", ("A",)) + evaluate( + "INTERSECTS(MULTILINESTRING((0 0, 1 1), (2 1, 1 2)), geometry)", ("A",) + ) @pytest.mark.django_db @@ -461,12 +467,12 @@ def test_arith_field_plus_2_inv(): @pytest.mark.django_db def test_arith_field_plus_field(): - evaluate("intMetaAttribute = " "floatMetaAttribute + intAttribute", ("A",)) + evaluate("intMetaAttribute = floatMetaAttribute + intAttribute", ("A",)) @pytest.mark.django_db def test_arith_field_plus_field_inv(): - evaluate("floatMetaAttribute + intAttribute" "= intMetaAttribute", ("A",)) + evaluate("floatMetaAttribute + intAttribute= intMetaAttribute", ("A",)) @pytest.mark.django_db diff --git a/tests/backends/django/testapp/migrations/0001_initial.py b/tests/backends/django/testapp/migrations/0001_initial.py index c276e3f..6033f1d 100644 --- a/tests/backends/django/testapp/migrations/0001_initial.py +++ b/tests/backends/django/testapp/migrations/0001_initial.py @@ -8,7 +8,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [] @@ -29,7 +28,9 @@ class Migration(migrations.Migration): ("identifier", models.CharField(max_length=256, unique=True)), ( "geometry", - django.contrib.gis.db.models.fields.GeometryField(srid=4326), + django.contrib.gis.db.models.fields.GeometryField( + srid=4326 + ), ), ("float_attribute", models.FloatField(blank=True, null=True)), ("int_attribute", models.IntegerField(blank=True, null=True)), @@ -37,11 +38,16 @@ class Migration(migrations.Migration): "str_attribute", models.CharField(blank=True, max_length=256, null=True), ), - ("datetime_attribute", models.DateTimeField(blank=True, null=True)), + ( + "datetime_attribute", + models.DateTimeField(blank=True, null=True), + ), ( "choice_attribute", models.PositiveSmallIntegerField( - blank=True, choices=[(1, "A"), (2, "B"), (3, "C")], null=True + blank=True, + choices=[(1, "A"), (2, "B"), (3, "C")], + null=True, ), ), ], @@ -58,8 +64,14 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("float_meta_attribute", models.FloatField(blank=True, null=True)), - ("int_meta_attribute", models.IntegerField(blank=True, null=True)), + ( + "float_meta_attribute", + models.FloatField(blank=True, null=True), + ), + ( + "int_meta_attribute", + models.IntegerField(blank=True, null=True), + ), ( "str_meta_attribute", models.CharField(blank=True, max_length=256, null=True), @@ -71,7 +83,9 @@ class Migration(migrations.Migration): ( "choice_meta_attribute", models.PositiveSmallIntegerField( - blank=True, choices=[(1, "X"), (2, "Y"), (3, "Z")], null=True + blank=True, + choices=[(1, "X"), (2, "Y"), (3, "Z")], + null=True, ), ), ( diff --git a/tests/backends/django/testapp/models.py b/tests/backends/django/testapp/models.py index 462bed1..5185ca7 100644 --- a/tests/backends/django/testapp/models.py +++ b/tests/backends/django/testapp/models.py @@ -45,7 +45,7 @@ class Record(models.Model): (1, "ASCENDING"), (2, "DESCENDING"), ], - **optional + **optional, ) diff --git a/tests/backends/django/testapp/tests.py b/tests/backends/django/testapp/tests.py index 251e178..4a43e55 100644 --- a/tests/backends/django/testapp/tests.py +++ b/tests/backends/django/testapp/tests.py @@ -47,7 +47,8 @@ def evaluate(self, cql_expr, expected_ids, model_type=None): qs = model_type.objects.filter(filters) self.assertEqual( - expected_ids, type(expected_ids)(qs.values_list("identifier", flat=True)) + expected_ids, + type(expected_ids)(qs.values_list("identifier", flat=True)), ) # common comparisons @@ -189,17 +190,20 @@ def test_before_or_during_dt_dt(self): def test_before_or_during_dt_td(self): self.evaluate( - "datetimeAttribute BEFORE OR DURING " "2000-01-01T00:00:00Z / PT4S", ("A",) + "datetimeAttribute BEFORE OR DURING 2000-01-01T00:00:00Z / PT4S", + ("A",), ) def test_before_or_during_td_dt(self): self.evaluate( - "datetimeAttribute BEFORE OR DURING " "PT4S / 2000-01-01T00:00:03Z", ("A",) + "datetimeAttribute BEFORE OR DURING PT4S / 2000-01-01T00:00:03Z", + ("A",), ) def test_during_td_dt(self): self.evaluate( - "datetimeAttribute BEFORE OR DURING " "PT4S / 2000-01-01T00:00:03Z", ("A",) + "datetimeAttribute BEFORE OR DURING PT4S / 2000-01-01T00:00:03Z", + ("A",), ) # TODO: test DURING OR AFTER / AFTER @@ -220,7 +224,8 @@ def test_intersects_linestring(self): def test_intersects_multilinestring(self): self.evaluate( - "INTERSECTS(geometry, MULTILINESTRING((0 0, 1 1), (2 1, 1 2)))", ("A",) + "INTERSECTS(geometry, MULTILINESTRING((0 0, 1 1), (2 1, 1 2)))", + ("A",), ) def test_intersects_polygon(self): @@ -264,7 +269,9 @@ def test_arith_field_plus_2(self): self.evaluate("intMetaAttribute = 10 + floatMetaAttribute", ("A", "B")) def test_arith_field_plus_field(self): - self.evaluate("intMetaAttribute = " "floatMetaAttribute + intAttribute", ("A",)) + self.evaluate( + "intMetaAttribute = floatMetaAttribute + intAttribute", ("A",) + ) def test_arith_field_plus_mul_1(self): self.evaluate("intMetaAttribute = intAttribute * 1.5 + 5", ("A",)) diff --git a/tests/backends/elasticsearch/test_evaluate.py b/tests/backends/elasticsearch/test_evaluate.py index b17a40d..5f63a48 100644 --- a/tests/backends/elasticsearch/test_evaluate.py +++ b/tests/backends/elasticsearch/test_evaluate.py @@ -1,5 +1,7 @@ # pylint: disable=W0621,C0114,C0115,C0116 +from os import environ + import pytest from elasticsearch_dsl import ( Date, @@ -22,7 +24,7 @@ from pygeofilter.backends.elasticsearch import to_filter from pygeofilter.parsers.ecql import parse from pygeofilter.util import parse_datetime -from os import environ + class Wildcard(Field): name = "wildcard" @@ -54,10 +56,12 @@ class Index: @pytest.fixture(autouse=True, scope="session") def connection(): connections.create_connection( - hosts=["http://{}:{}".format( - environ.get("PYGEOFILTER_ELASTIC_HOST", "localhost"), - environ.get("PYGEOFILTER_ELASTIC_PORT", "9200"), - )], + hosts=[ + "http://{}:{}".format( + environ.get("PYGEOFILTER_ELASTIC_HOST", "localhost"), + environ.get("PYGEOFILTER_ELASTIC_PORT", "9200"), + ) + ], ) diff --git a/tests/backends/oraclesql/test_evaluate.py b/tests/backends/oraclesql/test_evaluate.py index af55e0b..0c31f47 100644 --- a/tests/backends/oraclesql/test_evaluate.py +++ b/tests/backends/oraclesql/test_evaluate.py @@ -54,12 +54,16 @@ def test_between_with_binds(): where, binds = to_sql_where_with_bind_variables( parse("int_attr NOT BETWEEN 4 AND 6"), FIELD_MAPPING, FUNCTION_MAP ) - assert where == "(int_attr NOT BETWEEN :int_attr_low_0 AND :int_attr_high_0)" + assert ( + where == "(int_attr NOT BETWEEN :int_attr_low_0 AND :int_attr_high_0)" + ) assert binds == {"int_attr_low_0": 4, "int_attr_high_0": 6} def test_like(): - where = to_sql_where(parse("str_attr LIKE 'foo%'"), FIELD_MAPPING, FUNCTION_MAP) + where = to_sql_where( + parse("str_attr LIKE 'foo%'"), FIELD_MAPPING, FUNCTION_MAP + ) assert where == "str_attr LIKE 'foo%' ESCAPE '\\'" @@ -82,7 +86,9 @@ def test_combination_with_binds(): where, binds = to_sql_where_with_bind_variables( parse("int_attr = 5 AND float_attr < 6.0"), FIELD_MAPPING, FUNCTION_MAP ) - assert where == "((int_attr = :int_attr_0) AND (float_attr < :float_attr_1))" + assert ( + where == "((int_attr = :int_attr_0) AND (float_attr < :float_attr_1))" + ) assert binds == {"int_attr_0": 5, "float_attr_1": 6.0} @@ -123,7 +129,9 @@ def test_spatial_with_binds(): def test_bbox(): where = to_sql_where( - parse("BBOX(point_attr,-140.99778,41.6751050889,-52.6480987209,83.23324)"), + parse( + "BBOX(point_attr,-140.99778,41.6751050889,-52.6480987209,83.23324)" + ), FIELD_MAPPING, FUNCTION_MAP, ) @@ -144,7 +152,9 @@ def test_bbox(): def test_bbox_with_binds(): where, binds = to_sql_where_with_bind_variables( - parse("BBOX(point_attr,-140.99778,41.6751050889,-52.6480987209,83.23324)"), + parse( + "BBOX(point_attr,-140.99778,41.6751050889,-52.6480987209,83.23324)" + ), FIELD_MAPPING, FUNCTION_MAP, ) diff --git a/tests/backends/solr/test_evaluate.py b/tests/backends/solr/test_evaluate.py index 3bbeb52..aedc46f 100644 --- a/tests/backends/solr/test_evaluate.py +++ b/tests/backends/solr/test_evaluate.py @@ -38,8 +38,10 @@ from pygeofilter.parsers.ecql import parse from pygeofilter.util import parse_datetime -PORT=8983 -SOLR_BASE_URL = f"http://localhost:{PORT}/solr/test" # replace with your Solr URL +PORT = 8983 +SOLR_BASE_URL = ( + f"http://localhost:{PORT}/solr/test" # replace with your Solr URL +) HEADERS = { "Content-type": "application/json", } @@ -103,7 +105,9 @@ def prepare(): for field_type in field_types: data = json.dumps({"add-field-type": field_type}) requests.post( - f"http://localhost:{PORT}/api/cores/test/schema", headers={"Content-type": "application/json"}, data=data + f"http://localhost:{PORT}/api/cores/test/schema", + headers={"Content-type": "application/json"}, + data=data, ) # Define the fields to be added @@ -115,7 +119,11 @@ def prepare(): {"name": "str_attribute", "type": "text_general"}, {"name": "center", "type": "location"}, {"name": "geometry_jts", "type": "spatial_jts", "multiValued": "false"}, - {"name": "geometry_geo3d", "type": "spatial_geo3d", "multiValued": "false"}, + { + "name": "geometry_geo3d", + "type": "spatial_geo3d", + "multiValued": "false", + }, {"name": "daterange_attribute", "type": "date_range"}, ] @@ -130,13 +138,17 @@ def prepare(): index = "ok" yield index print("cleaning up") - requests.get(SOLR_BASE_URL + "/admin/cores?action=UNLOAD&core=test&deleteIndex=true") + requests.get( + SOLR_BASE_URL + "/admin/cores?action=UNLOAD&core=test&deleteIndex=true" + ) @pytest.fixture(autouse=True, scope="session") def index(prepare): # Add test documents - response = requests.post(SOLR_BASE_URL + "/update", data=json.dumps(INPUT_DOCS), headers=HEADERS) + response = requests.post( + SOLR_BASE_URL + "/update", data=json.dumps(INPUT_DOCS), headers=HEADERS + ) print(response.json()) # Commit index res = requests.get(SOLR_BASE_URL + "/update?commit=true") @@ -149,7 +161,9 @@ def data(index): data = { "query": "id:A", # Query } - response = requests.get(SOLR_BASE_URL + "/query", data=json.dumps(data), headers=HEADERS) + response = requests.get( + SOLR_BASE_URL + "/query", data=json.dumps(data), headers=HEADERS + ) response_json = response.json() if response_json["responseHeader"]["status"] == 0: # Print the response @@ -241,22 +255,34 @@ def test_combination_like_not(data): result = filter_(parse("NOT str_attribute LIKE 'another'")) assert len(result) == 1 and result[0]["id"] is data[0]["id"] - result = filter_(parse("str_attribute LIKE 'test' AND NOT str_attribute LIKE 'another'")) + result = filter_( + parse("str_attribute LIKE 'test' AND NOT str_attribute LIKE 'another'") + ) assert len(result) == 1 and result[0]["id"] is data[0]["id"] - result = filter_(parse("NOT str_attribute LIKE 'another' AND str_attribute LIKE 'test'")) + result = filter_( + parse("NOT str_attribute LIKE 'another' AND str_attribute LIKE 'test'") + ) assert len(result) == 1 and result[0]["id"] is data[0]["id"] - result = filter_(parse("NOT str_attribute LIKE 'test' AND str_attribute LIKE 'another'")) + result = filter_( + parse("NOT str_attribute LIKE 'test' AND str_attribute LIKE 'another'") + ) assert len(result) == 0 - result = filter_(parse("str_attribute LIKE 'test' OR NOT str_attribute LIKE 'another'")) + result = filter_( + parse("str_attribute LIKE 'test' OR NOT str_attribute LIKE 'another'") + ) assert len(result) == 2 - result = filter_(parse("str_attribute LIKE 'test' OR NOT str_attribute LIKE 'another'")) + result = filter_( + parse("str_attribute LIKE 'test' OR NOT str_attribute LIKE 'another'") + ) assert len(result) == 2 - result = filter_(parse("NOT str_attribute LIKE 'another' OR str_attribute LIKE 'test'")) + result = filter_( + parse("NOT str_attribute LIKE 'another' OR str_attribute LIKE 'test'") + ) assert len(result) == 2 @@ -334,13 +360,22 @@ def test_dsl_query_obj(): assert q == {"query": "text:ice", "filter": ["collection:ice"]} q.add_filter("status:active") - assert q == {"query": "text:ice", "filter": ["collection:ice", "status:active"]} + assert q == { + "query": "text:ice", + "filter": ["collection:ice", "status:active"], + } q = SolrDSLQuery(filters=["collection:ice", "int_field:[3 TO 10]"]) - assert q == {"query": "*:*", "filter": ["collection:ice", "int_field:[3 TO 10]"]} + assert q == { + "query": "*:*", + "filter": ["collection:ice", "int_field:[3 TO 10]"], + } q.add_filter("status:active") - assert q == {"query": "*:*", "filter": ["collection:ice", "int_field:[3 TO 10]", "status:active"]} + assert q == { + "query": "*:*", + "filter": ["collection:ice", "int_field:[3 TO 10]", "status:active"], + } def test_attribute_mapping_fallback(): @@ -358,15 +393,23 @@ def test_attribute_mapping_fallback(): def test_spatial_disjoint_uses_negated_intersects_filter(): - query = to_filter(parse("DISJOINT(geometry_jts, POLYGON((0 0, 1 0, 1 1, 0 1, 0 0)))")) + query = to_filter( + parse("DISJOINT(geometry_jts, POLYGON((0 0, 1 0, 1 1, 0 1, 0 0)))") + ) assert query["query"] == "*:*" - assert query["filter"] == ["-{!field f=geometry_jts v='Intersects(POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0)))'}"] + assert query["filter"] == [ + "-{!field f=geometry_jts v='Intersects(POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0)))'}" + ] def test_not_disjoint_inverts_filter_to_intersects(): - query = to_filter(parse("NOT DISJOINT(geometry_jts, POLYGON((0 0, 1 0, 1 1, 0 1, 0 0)))")) + query = to_filter( + parse("NOT DISJOINT(geometry_jts, POLYGON((0 0, 1 0, 1 1, 0 1, 0 0)))") + ) assert query["query"] == "*:*" - assert query["filter"] == ["{!field f=geometry_jts v='Intersects(POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0)))'}"] + assert query["filter"] == [ + "{!field f=geometry_jts v='Intersects(POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0)))'}" + ] def test_equality_keeps_non_date_string_terms(): @@ -381,7 +424,9 @@ def test_equality_normalizes_date_literals(): def test_or_keeps_spatial_in_filter_context(): query = to_filter( - parse("INTERSECTS(geometry_jts, POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))) OR str_attribute LIKE 'this is a test'") + parse( + "INTERSECTS(geometry_jts, POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))) OR str_attribute LIKE 'this is a test'" + ) ) assert query["query"] == "*:*" assert query["filter"] == [ @@ -435,16 +480,24 @@ def test_or_keeps_spatial_in_filter_context(): def test_spatial_and_text(data): - ast = parse("INTERSECTS(geometry_jts, ENVELOPE (0.0 1.0 0.0 1.0)) AND str_attribute LIKE 'this is a test'") + ast = parse( + "INTERSECTS(geometry_jts, ENVELOPE (0.0 1.0 0.0 1.0)) AND str_attribute LIKE 'this is a test'" + ) result = filter_(ast) assert len(result) == 1 def test_spatial(data): - result = filter_(parse("INTERSECTS(geometry_jts, ENVELOPE (0.0 1.0 0.0 1.0))")) + result = filter_( + parse("INTERSECTS(geometry_jts, ENVELOPE (0.0 1.0 0.0 1.0))") + ) assert len(result) == 1 and result[0]["id"] is data[0]["id"] - result = filter_(parse("INTERSECTS(geometry_jts, POLYGON((0.0 0.0, 1.0 0.0, 1.0 1.0, 0.0 1.0, 0.0 0.0)))")) + result = filter_( + parse( + "INTERSECTS(geometry_jts, POLYGON((0.0 0.0, 1.0 0.0, 1.0 1.0, 0.0 1.0, 0.0 0.0)))" + ) + ) assert len(result) == 1 and result[0]["id"] is data[0]["id"] result = filter_( @@ -452,23 +505,39 @@ def test_spatial(data): ) assert len(result) == 1 and result[0]["id"] is data[0]["id"] - result = filter_(parse("DISJOINT(geometry_jts, POLYGON((0.0 0.0, 1.0 0.0, 1.0 1.0, 0.0 1.0, 0.0 0.0)))")) + result = filter_( + parse( + "DISJOINT(geometry_jts, POLYGON((0.0 0.0, 1.0 0.0, 1.0 1.0, 0.0 1.0, 0.0 0.0)))" + ) + ) assert len(result) == 1 and result[0]["id"] is data[1]["id"] - result = filter_(parse("NOT DISJOINT(geometry_jts, POLYGON((0.0 0.0, 1.0 0.0, 1.0 1.0, 0.0 1.0, 0.0 0.0)))")) + result = filter_( + parse( + "NOT DISJOINT(geometry_jts, POLYGON((0.0 0.0, 1.0 0.0, 1.0 1.0, 0.0 1.0, 0.0 0.0)))" + ) + ) assert len(result) == 1 and result[0]["id"] is data[0]["id"] - result = filter_(parse("NOT INTERSECTS(geometry_jts, POLYGON((0.0 0.0, 1.0 0.0, 1.0 1.0, 0.0 1.0, 0.0 0.0)))")) + result = filter_( + parse( + "NOT INTERSECTS(geometry_jts, POLYGON((0.0 0.0, 1.0 0.0, 1.0 1.0, 0.0 1.0, 0.0 0.0)))" + ) + ) assert len(result) == 1 and result[0]["id"] is data[1]["id"] result = filter_( - parse("DISJOINT(geometry_jts, POLYGON((0.0 0.0, 1.0 0.0, 1.0 1.0, 0.0 1.0, 0.0 0.0))) OR str_attribute LIKE 'this is a test'") + parse( + "DISJOINT(geometry_jts, POLYGON((0.0 0.0, 1.0 0.0, 1.0 1.0, 0.0 1.0, 0.0 0.0))) OR str_attribute LIKE 'this is a test'" + ) ) assert len(result) == 2 def test_spatial_geo3d(data): - result = filter_(parse("INTERSECTS(geometry_geo3d, ENVELOPE (0.0 1.0 0.0 1.0))")) + result = filter_( + parse("INTERSECTS(geometry_geo3d, ENVELOPE (0.0 1.0 0.0 1.0))") + ) assert len(result) == 1 and result[0]["id"] is data[0]["id"] result = filter_( diff --git a/tests/backends/sqlalchemy/test_evaluate.py b/tests/backends/sqlalchemy/test_evaluate.py index d990308..45c6106 100644 --- a/tests/backends/sqlalchemy/test_evaluate.py +++ b/tests/backends/sqlalchemy/test_evaluate.py @@ -18,9 +18,9 @@ from sqlalchemy.sql import func, select from pygeofilter.backends.sqlalchemy.evaluate import to_filter -from pygeofilter.parsers.ecql import parse as parse_ecql -from pygeofilter.parsers.cql2_text import parse as parse_cql_text from pygeofilter.parsers.cql2_json import parse as parse_cql2_json +from pygeofilter.parsers.cql2_text import parse as parse_cql_text +from pygeofilter.parsers.ecql import parse as parse_ecql Base = declarative_base() @@ -146,7 +146,9 @@ def setup_database(connection): connection.commit() seed_database( - scoped_session(sessionmaker(autocommit=False, autoflush=False, bind=connection)) + scoped_session( + sessionmaker(autocommit=False, autoflush=False, bind=connection) + ) ) yield @@ -163,7 +165,9 @@ def db_session(setup_database, connection): transaction.rollback() -def evaluate(session, cql_expr, expected_ids, filter_option=None, parser=parse_ecql): +def evaluate( + session, cql_expr, expected_ids, filter_option=None, parser=parse_ecql +): ast = parser(cql_expr) filters = to_filter(ast, FIELD_MAPPING, filter_option) @@ -274,11 +278,18 @@ def test_not_like_endswith(db_session): def test_not_ilike_endswith(db_session): evaluate(db_session, "strMetaAttribute NOT ILIKE '%b'", ("A",)) + def test_not_eq(db_session): - evaluate(db_session, "NOT(strAttribute = 'AAA')", ("B", ), None, parse_cql_text) + evaluate( + db_session, "NOT(strAttribute = 'AAA')", ("B",), None, parse_cql_text + ) + def test_not_gt(db_session): - evaluate(db_session, "NOT(floatAttribute > 1)", ("A", ), None, parse_cql_text) + evaluate( + db_session, "NOT(floatAttribute > 1)", ("A",), None, parse_cql_text + ) + # (NOT) IN @@ -301,30 +312,81 @@ def test_string_null(db_session): def test_string_not_null(db_session): evaluate(db_session, "intAttribute IS NOT NULL", ("A",)) + # CASEI def test_casei_equals(db_session): - evaluate(db_session, "CASEI(strAttribute) = CASEI('aaa')", ("A",), None, parse_cql_text) + evaluate( + db_session, + "CASEI(strAttribute) = CASEI('aaa')", + ("A",), + None, + parse_cql_text, + ) + def test_casei_like(db_session): - evaluate(db_session, "CASEI(strAttribute) LIKE CASEI('aaa')", ("A",), None, parse_cql_text) + evaluate( + db_session, + "CASEI(strAttribute) LIKE CASEI('aaa')", + ("A",), + None, + parse_cql_text, + ) + def test_casei_notlike(db_session): - evaluate(db_session, "CASEI(strAttribute) NOT LIKE CASEI('aaa')", ("B", ), None, parse_cql_text) + evaluate( + db_session, + "CASEI(strAttribute) NOT LIKE CASEI('aaa')", + ("B",), + None, + parse_cql_text, + ) + def test_casei_in(db_session): - evaluate(db_session, "CASEI(strAttribute) IN (CASEI('aaa'), CASEI('bbb'))", ("A", "B", ), None, parse_cql_text) + evaluate( + db_session, + "CASEI(strAttribute) IN (CASEI('aaa'), CASEI('bbb'))", + ( + "A", + "B", + ), + None, + parse_cql_text, + ) + def test_casei_json_like(db_session): - evaluate(db_session, '{"op": "like", "args": [ {"op": "casei", "args": [{"property": "strAttribute"}]}, {"op": "casei", "args": ["AAA"]} ] }', ("A", ), None, parse_cql2_json) + evaluate( + db_session, + '{"op": "like", "args": [ {"op": "casei", "args": [{"property": "strAttribute"}]}, {"op": "casei", "args": ["AAA"]} ] }', + ("A",), + None, + parse_cql2_json, + ) + # temporal predicates + def test_date_gte(db_session): - evaluate(db_session, "datetimeAttribute >= DATE('2000-01-01')", ("A", "B",), None, parse_cql_text) + evaluate( + db_session, + "datetimeAttribute >= DATE('2000-01-01')", + ( + "A", + "B", + ), + None, + parse_cql_text, + ) def test_before(db_session): - evaluate(db_session, "datetimeAttribute BEFORE 2000-01-01T00:00:01Z", ("A",)) + evaluate( + db_session, "datetimeAttribute BEFORE 2000-01-01T00:00:01Z", ("A",) + ) def test_before_or_during_dt_dt(db_session): @@ -339,7 +401,7 @@ def test_before_or_during_dt_dt(db_session): def test_before_or_during_dt_td(db_session): evaluate( db_session, - "datetimeAttribute BEFORE OR DURING " "2000-01-01T00:00:00Z / PT4S", + "datetimeAttribute BEFORE OR DURING 2000-01-01T00:00:00Z / PT4S", ("A",), ) @@ -347,7 +409,7 @@ def test_before_or_during_dt_td(db_session): def test_before_or_during_td_dt(db_session): evaluate( db_session, - "datetimeAttribute BEFORE OR DURING " "PT4S / 2000-01-01T00:00:03Z", + "datetimeAttribute BEFORE OR DURING PT4S / 2000-01-01T00:00:03Z", ("A",), ) @@ -355,7 +417,7 @@ def test_before_or_during_td_dt(db_session): def test_during_td_dt(db_session): evaluate( db_session, - "datetimeAttribute BEFORE OR DURING " "PT4S / 2000-01-01T00:00:03Z", + "datetimeAttribute BEFORE OR DURING PT4S / 2000-01-01T00:00:03Z", ("A",), ) @@ -372,7 +434,9 @@ def test_intersects_mulitipoint_1(db_session): def test_intersects_mulitipoint_2(db_session): - evaluate(db_session, "INTERSECTS(geometry, MULTIPOINT((0 0), (1 1)))", ("A",)) + evaluate( + db_session, "INTERSECTS(geometry, MULTIPOINT((0 0), (1 1)))", ("A",) + ) def test_intersects_linestring(db_session): @@ -421,14 +485,39 @@ def test_intersects_envelope(db_session): def test_bbox(db_session): evaluate(db_session, "BBOX(geometry, 0, 0, 1, 1, '4326')", ("A",)) + def test_bbox_cql2(db_session): - evaluate(db_session, "S_INTERSECTS(geometry, BBOX(0, 0, 1, 1))", ("A",), None, parse_cql_text) + evaluate( + db_session, + "S_INTERSECTS(geometry, BBOX(0, 0, 1, 1))", + ("A",), + None, + parse_cql_text, + ) + def test_empty_bbox_cql2(db_session): - evaluate(db_session, "S_INTERSECTS(geometry, BBOX(178, 88, 181, 91))", (), None, parse_cql_text) + evaluate( + db_session, + "S_INTERSECTS(geometry, BBOX(178, 88, 181, 91))", + (), + None, + parse_cql_text, + ) + def test_global_bbox_cql2(db_session): - evaluate(db_session, "S_INTERSECTS(geometry,BBOX(-180,-90,180,90))", ("A", "B",), None, parse_cql_text) + evaluate( + db_session, + "S_INTERSECTS(geometry,BBOX(-180,-90,180,90))", + ( + "A", + "B", + ), + None, + parse_cql_text, + ) + # arithmethic expressions @@ -438,16 +527,22 @@ def test_arith_simple_plus(db_session): def test_arith_field_plus_1(db_session): - evaluate(db_session, "intMetaAttribute = floatMetaAttribute + 10", ("A", "B")) + evaluate( + db_session, "intMetaAttribute = floatMetaAttribute + 10", ("A", "B") + ) def test_arith_field_plus_2(db_session): - evaluate(db_session, "intMetaAttribute = 10 + floatMetaAttribute", ("A", "B")) + evaluate( + db_session, "intMetaAttribute = 10 + floatMetaAttribute", ("A", "B") + ) def test_arith_field_plus_field(db_session): evaluate( - db_session, "intMetaAttribute = " "floatMetaAttribute + intAttribute", ("A",) + db_session, + "intMetaAttribute = floatMetaAttribute + intAttribute", + ("A",), ) diff --git a/tests/native/test_evaluate.py b/tests/native/test_evaluate.py index c9867b9..ff2d29d 100644 --- a/tests/native/test_evaluate.py +++ b/tests/native/test_evaluate.py @@ -107,9 +107,9 @@ def data_json(): def filter_json(ast, data): attr_map = {"point_attr": "geometry", "*": "properties.*"} - filter_expr = NativeEvaluator(math.__dict__, attr_map, use_getattr=False).evaluate( - ast - ) + filter_expr = NativeEvaluator( + math.__dict__, attr_map, use_getattr=False + ).evaluate(ast) return [record for record in data if filter_expr(record)] @@ -277,12 +277,16 @@ def test_temporal(data): result = filter_(parse("date_attr BEFORE 2010-01-08T00:00:00.00Z"), data) assert len(result) == 1 and result[0] is data[0] - result = filter_(parse("date_attr AFTER 2010-01-08T00:00:00.00+01:00"), data) + result = filter_( + parse("date_attr AFTER 2010-01-08T00:00:00.00+01:00"), data + ) assert len(result) == 1 and result[0] is data[1] def test_temporal_json(data_json): - result = filter_json(parse("date_attr BEFORE 2010-01-08T00:00:00.00Z"), data_json) + result = filter_json( + parse("date_attr BEFORE 2010-01-08T00:00:00.00Z"), data_json + ) assert len(result) == 1 and result[0] is data_json[0] result = filter_json( diff --git a/tests/parsers/cql2_json/test_parser.py b/tests/parsers/cql2_json/test_parser.py index 01f2474..dd55fc9 100644 --- a/tests/parsers/cql2_json/test_parser.py +++ b/tests/parsers/cql2_json/test_parser.py @@ -148,16 +148,21 @@ def test_attribute_is_null(): result = parse({"op": "isNull", "args": [{"property": "attr"}]}) assert result == ast.IsNull(ast.Attribute("attr"), False) + def test_attribute_casei(): result = parse('{"op": "casei", "args": [{"property": "attr"}]}') assert result == ast.Function("lower", [ast.Attribute("attr")]) + def test_literal_casei(): result = parse('{"op": "casei", "args": ["literal"]}') assert result == ast.Function("lower", ["literal"]) + def test_like_casei(): - result = parse('{"op": "like", "args": [ {"op": "casei", "args": [{"property": "stringattr"}]}, {"op": "casei", "args": ["AAA"]} ] }') + result = parse( + '{"op": "like", "args": [ {"op": "casei", "args": [{"property": "stringattr"}]}, {"op": "casei", "args": ["AAA"]} ] }' + ) assert result == ast.Like( ast.Function("lower", [ast.Attribute("stringattr")]), ast.Function("lower", ["AAA"]), @@ -168,6 +173,7 @@ def test_like_casei(): escapechar="\\", ) + def test_attribute_before(): result = parse( { @@ -212,8 +218,12 @@ def test_attribute_after_dt_dt(): assert result == ast.TimeAfter( ast.Attribute("attr"), values.Interval( - datetime(2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0))), - datetime(2000, 1, 1, 0, 0, 1, tzinfo=StaticTzInfo("Z", timedelta(0))), + datetime( + 2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0)) + ), + datetime( + 2000, 1, 1, 0, 0, 1, tzinfo=StaticTzInfo("Z", timedelta(0)) + ), ), ) @@ -231,7 +241,9 @@ def test_meets_dt_dr(): assert result == ast.TimeMeets( ast.Attribute("attr"), values.Interval( - datetime(2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0))), + datetime( + 2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0)) + ), timedelta(seconds=4), ), ) @@ -251,7 +263,9 @@ def test_attribute_metby_dr_dt(): ast.Attribute("attr"), values.Interval( timedelta(seconds=4), - datetime(2000, 1, 1, 0, 0, 3, tzinfo=StaticTzInfo("Z", timedelta(0))), + datetime( + 2000, 1, 1, 0, 0, 3, tzinfo=StaticTzInfo("Z", timedelta(0)) + ), ), ) @@ -270,7 +284,9 @@ def test_attribute_toverlaps_open_dt(): ast.Attribute("attr"), values.Interval( None, - datetime(2000, 1, 1, 0, 0, 3, tzinfo=StaticTzInfo("Z", timedelta(0))), + datetime( + 2000, 1, 1, 0, 0, 3, tzinfo=StaticTzInfo("Z", timedelta(0)) + ), ), ) @@ -288,7 +304,9 @@ def test_attribute_overlappedby_dt_open(): assert result == ast.TimeOverlappedBy( ast.Attribute("attr"), values.Interval( - datetime(2000, 1, 1, 0, 0, 3, tzinfo=StaticTzInfo("Z", timedelta(0))), + datetime( + 2000, 1, 1, 0, 0, 3, tzinfo=StaticTzInfo("Z", timedelta(0)) + ), None, ), ) @@ -298,7 +316,9 @@ def test_attribute_overlappedby_dt_open(): def test_attribute_aequals(): - result = parse({"op": "a_equals", "args": [{"property": "arrayattr"}, [1, 2, 3]]}) + result = parse( + {"op": "a_equals", "args": [{"property": "arrayattr"}, [1, 2, 3]]} + ) assert result == ast.ArrayEquals( ast.Attribute("arrayattr"), [1, 2, 3], @@ -306,7 +326,9 @@ def test_attribute_aequals(): def test_attribute_aoverlaps(): - result = parse({"op": "a_overlaps", "args": [{"property": "arrayattr"}, [1, 2, 3]]}) + result = parse( + {"op": "a_overlaps", "args": [{"property": "arrayattr"}, [1, 2, 3]]} + ) assert result == ast.ArrayOverlaps( ast.Attribute("arrayattr"), [1, 2, 3], @@ -314,7 +336,9 @@ def test_attribute_aoverlaps(): def test_attribute_acontains(): - result = parse({"op": "a_contains", "args": [{"property": "arrayattr"}, [1, 2, 3]]}) + result = parse( + {"op": "a_contains", "args": [{"property": "arrayattr"}, [1, 2, 3]]} + ) assert result == ast.ArrayContains( ast.Attribute("arrayattr"), [1, 2, 3], @@ -369,7 +393,9 @@ def test_disjoint_linestring_attr(): ) assert result == ast.GeometryDisjoint( values.Geometry( - normalize_geom(geometry.LineString([(1, 1), (2, 2)]).__geo_interface__), + normalize_geom( + geometry.LineString([(1, 1), (2, 2)]).__geo_interface__ + ), ), ast.Attribute("geometry"), ) @@ -393,7 +419,9 @@ def test_contains_attr_polygon(): ast.Attribute("geometry"), values.Geometry( normalize_geom( - geometry.Polygon([(1, 1), (2, 2), (0, 3), (1, 1)]).__geo_interface__ + geometry.Polygon( + [(1, 1), (2, 2), (0, 3), (1, 1)] + ).__geo_interface__ ), ), ) diff --git a/tests/parsers/cql2_text/test_parser.py b/tests/parsers/cql2_text/test_parser.py index 7d4c38e..5bceec7 100644 --- a/tests/parsers/cql2_text/test_parser.py +++ b/tests/parsers/cql2_text/test_parser.py @@ -1,24 +1,26 @@ -from datetime import datetime, timedelta, date +from datetime import date, datetime, timedelta from dateparser.timezone_parser import StaticTzInfo from pygeofilter import ast, values from pygeofilter.parsers.cql2_text import parse + def test_intersect_bbox(): result = parse("S_INTERSECTS(geometry,BBOX(-180,-90,180,90))") assert result == ast.GeometryIntersects( - ast.Attribute("geometry"), - values.Envelope(-180, 180, -90, 90) + ast.Attribute("geometry"), values.Envelope(-180, 180, -90, 90) ) + def test_intersect_point(): result = parse("S_INTERSECTS(geometry,POINT(7.02 49.92))") assert result == ast.GeometryIntersects( ast.Attribute("geometry"), - values.Geometry({'type': 'Point', 'coordinates': (7.02, 49.92)}) + values.Geometry({"type": "Point", "coordinates": (7.02, 49.92)}), ) + def test_attribute_eq_true_uppercase(): result = parse("attr = TRUE") assert result == ast.Equal( @@ -214,6 +216,7 @@ def test_attribute_before(): datetime(2000, 1, 1, 0, 0, 1, tzinfo=StaticTzInfo("Z", timedelta(0))), ) + def test_attribute_lt_date(): result = parse("attr < DATE('2000-01-01')") assert result == ast.LessThan( @@ -221,6 +224,7 @@ def test_attribute_lt_date(): date(2000, 1, 1), ) + def test_attribute_t_intersects(): # Using INTERVAL function with properly quoted timestamps result = parse( @@ -229,8 +233,12 @@ def test_attribute_t_intersects(): assert result == ast.TimeOverlaps( ast.Attribute("attr"), values.Interval( - datetime(2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0))), - datetime(2000, 1, 1, 0, 0, 1, tzinfo=StaticTzInfo("Z", timedelta(0))), + datetime( + 2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0)) + ), + datetime( + 2000, 1, 1, 0, 0, 1, tzinfo=StaticTzInfo("Z", timedelta(0)) + ), ), ) @@ -242,8 +250,12 @@ def test_attribute_tintersects_dt_dr(): assert result == ast.TimeOverlaps( ast.Attribute("attr"), values.Interval( - datetime(2000, 1, 1, 0, 0, 3, tzinfo=StaticTzInfo("Z", timedelta(0))), - datetime(2000, 1, 1, 0, 0, 4, tzinfo=StaticTzInfo("Z", timedelta(0))), + datetime( + 2000, 1, 1, 0, 0, 3, tzinfo=StaticTzInfo("Z", timedelta(0)) + ), + datetime( + 2000, 1, 1, 0, 0, 4, tzinfo=StaticTzInfo("Z", timedelta(0)) + ), ), ) @@ -409,7 +421,9 @@ def test_complex_expression(): assert isinstance(result, ast.And) assert isinstance(result.lhs, ast.And) assert isinstance(result.lhs.lhs, ast.And) - assert result.lhs.lhs.lhs == ast.Equal(ast.Attribute("collection"), "landsat8_l1tp") + assert result.lhs.lhs.lhs == ast.Equal( + ast.Attribute("collection"), "landsat8_l1tp" + ) assert result.lhs.lhs.rhs == ast.LessEqual(ast.Attribute("gsd"), 30) assert result.lhs.rhs == ast.LessEqual(ast.Attribute("eo:cloud_cover"), 10) # The exact datetime comparison depends on implementation details @@ -450,6 +464,7 @@ def test_casei_like(): assert result.pattern.name == "lower" assert result.pattern.arguments == ["coolsat"] + def test_casei_notlike(): result = parse("CASEI(provider) NOT LIKE CASEI('coolsat')") # Assuming CASEI maps to 'lower' in the implementation @@ -460,20 +475,17 @@ def test_casei_notlike(): assert result.pattern.name == "lower" assert result.pattern.arguments == ["coolsat"] + def test_not_gt(): result = parse("NOT(attr > 2)") - assert result == ast.Not( - ast.GreaterThan(ast.Attribute("attr"), 2) - ) + assert result == ast.Not(ast.GreaterThan(ast.Attribute("attr"), 2)) + def test_not_lt(): result = parse("NOT(attr < 2)") - assert result == ast.Not( - ast.LessThan(ast.Attribute("attr"), 2) - ) + assert result == ast.Not(ast.LessThan(ast.Attribute("attr"), 2)) + def test_not_eq(): result = parse("NOT(attr = 2)") - assert result == ast.Not( - ast.Equal(ast.Attribute("attr"), 2) - ) + assert result == ast.Not(ast.Equal(ast.Attribute("attr"), 2)) diff --git a/tests/parsers/cql_json/test_parser.py b/tests/parsers/cql_json/test_parser.py index 7712a88..5d45142 100644 --- a/tests/parsers/cql_json/test_parser.py +++ b/tests/parsers/cql_json/test_parser.py @@ -284,30 +284,42 @@ def test_attribute_after_dt_dt(): assert result == ast.TimeAfter( ast.Attribute("attr"), values.Interval( - datetime(2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0))), - datetime(2000, 1, 1, 0, 0, 1, tzinfo=StaticTzInfo("Z", timedelta(0))), + datetime( + 2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0)) + ), + datetime( + 2000, 1, 1, 0, 0, 1, tzinfo=StaticTzInfo("Z", timedelta(0)) + ), ), ) def test_meets_dt_dr(): - result = parse({"meets": [{"property": "attr"}, ["2000-01-01T00:00:00Z", "PT4S"]]}) + result = parse( + {"meets": [{"property": "attr"}, ["2000-01-01T00:00:00Z", "PT4S"]]} + ) assert result == ast.TimeMeets( ast.Attribute("attr"), values.Interval( - datetime(2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0))), + datetime( + 2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0)) + ), timedelta(seconds=4), ), ) def test_attribute_metby_dr_dt(): - result = parse({"metby": [{"property": "attr"}, ["PT4S", "2000-01-01T00:00:03Z"]]}) + result = parse( + {"metby": [{"property": "attr"}, ["PT4S", "2000-01-01T00:00:03Z"]]} + ) assert result == ast.TimeMetBy( ast.Attribute("attr"), values.Interval( timedelta(seconds=4), - datetime(2000, 1, 1, 0, 0, 3, tzinfo=StaticTzInfo("Z", timedelta(0))), + datetime( + 2000, 1, 1, 0, 0, 3, tzinfo=StaticTzInfo("Z", timedelta(0)) + ), ), ) @@ -320,7 +332,9 @@ def test_attribute_toverlaps_open_dt(): ast.Attribute("attr"), values.Interval( None, - datetime(2000, 1, 1, 0, 0, 3, tzinfo=StaticTzInfo("Z", timedelta(0))), + datetime( + 2000, 1, 1, 0, 0, 3, tzinfo=StaticTzInfo("Z", timedelta(0)) + ), ), ) @@ -332,7 +346,9 @@ def test_attribute_overlappedby_dt_open(): assert result == ast.TimeOverlappedBy( ast.Attribute("attr"), values.Interval( - datetime(2000, 1, 1, 0, 0, 3, tzinfo=StaticTzInfo("Z", timedelta(0))), + datetime( + 2000, 1, 1, 0, 0, 3, tzinfo=StaticTzInfo("Z", timedelta(0)) + ), None, ), ) @@ -409,7 +425,9 @@ def test_disjoint_linestring_attr(): ) assert result == ast.GeometryDisjoint( values.Geometry( - normalize_geom(geometry.LineString([(1, 1), (2, 2)]).__geo_interface__), + normalize_geom( + geometry.LineString([(1, 1), (2, 2)]).__geo_interface__ + ), ), ast.Attribute("geometry"), ) @@ -432,7 +450,9 @@ def test_contains_attr_polygon(): ast.Attribute("geometry"), values.Geometry( normalize_geom( - geometry.Polygon([(1, 1), (2, 2), (0, 3), (1, 1)]).__geo_interface__ + geometry.Polygon( + [(1, 1), (2, 2), (0, 3), (1, 1)] + ).__geo_interface__ ), ), ) diff --git a/tests/parsers/ecql/test_parser.py b/tests/parsers/ecql/test_parser.py index 5266b23..b00e980 100644 --- a/tests/parsers/ecql/test_parser.py +++ b/tests/parsers/ecql/test_parser.py @@ -250,12 +250,18 @@ def test_attribute_before(): def test_attribute_before_or_during_dt_dt(): - result = parse("attr BEFORE OR DURING 2000-01-01T00:00:00Z / 2000-01-01T00:00:01Z") + result = parse( + "attr BEFORE OR DURING 2000-01-01T00:00:00Z / 2000-01-01T00:00:01Z" + ) assert result == ast.TimeBeforeOrDuring( ast.Attribute("attr"), values.Interval( - datetime(2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0))), - datetime(2000, 1, 1, 0, 0, 1, tzinfo=StaticTzInfo("Z", timedelta(0))), + datetime( + 2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0)) + ), + datetime( + 2000, 1, 1, 0, 0, 1, tzinfo=StaticTzInfo("Z", timedelta(0)) + ), ), ) @@ -265,7 +271,9 @@ def test_attribute_before_or_during_dt_dr(): assert result == ast.TimeBeforeOrDuring( ast.Attribute("attr"), values.Interval( - datetime(2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0))), + datetime( + 2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0)) + ), timedelta(seconds=4), ), ) @@ -277,7 +285,9 @@ def test_attribute_before_or_during_dr_dt(): ast.Attribute("attr"), values.Interval( timedelta(seconds=4), - datetime(2000, 1, 1, 0, 0, 3, tzinfo=StaticTzInfo("Z", timedelta(0))), + datetime( + 2000, 1, 1, 0, 0, 3, tzinfo=StaticTzInfo("Z", timedelta(0)) + ), ), ) @@ -308,7 +318,9 @@ def test_contains_attr_polygon(): assert result == ast.GeometryContains( ast.Attribute("geometry"), values.Geometry( - geometry.Polygon([(1, 1), (2, 2), (0, 3), (1, 1)]).__geo_interface__, + geometry.Polygon( + [(1, 1), (2, 2), (0, 3), (1, 1)] + ).__geo_interface__, ), ) @@ -367,7 +379,8 @@ def test_overlaps_attr_multilinestring(): def test_intersects_attr_point_ewkt(): result = parse("INTERSECTS(geometry, SRID=4326;POINT(1 1))") assert ( - result.rhs.geometry["crs"]["properties"]["name"] == "urn:ogc:def:crs:EPSG::4326" + result.rhs.geometry["crs"]["properties"]["name"] + == "urn:ogc:def:crs:EPSG::4326" ) assert result == ast.GeometryIntersects( ast.Attribute("geometry"), @@ -404,7 +417,9 @@ def test_relate_attr_polygon(): assert result == ast.Relate( ast.Attribute("geometry"), values.Geometry( - geometry.Polygon([(1, 1), (2, 2), (0, 3), (1, 1)]).__geo_interface__, + geometry.Polygon( + [(1, 1), (2, 2), (0, 3), (1, 1)] + ).__geo_interface__, ), pattern="1*T***T**", ) @@ -418,7 +433,9 @@ def test_dwithin_attr_polygon(): assert result == ast.DistanceWithin( ast.Attribute("geometry"), values.Geometry( - geometry.Polygon([(1, 1), (2, 2), (0, 3), (1, 1)]).__geo_interface__, + geometry.Polygon( + [(1, 1), (2, 2), (0, 3), (1, 1)] + ).__geo_interface__, ), distance=5, units="feet", @@ -426,11 +443,15 @@ def test_dwithin_attr_polygon(): def test_beyond_attr_polygon(): - result = parse("BEYOND(geometry, POLYGON((1 1,2 2,0 3,1 1)), 5, nautical miles)") + result = parse( + "BEYOND(geometry, POLYGON((1 1,2 2,0 3,1 1)), 5, nautical miles)" + ) assert result == ast.DistanceBeyond( ast.Attribute("geometry"), values.Geometry( - geometry.Polygon([(1, 1), (2, 2), (0, 3), (1, 1)]).__geo_interface__, + geometry.Polygon( + [(1, 1), (2, 2), (0, 3), (1, 1)] + ).__geo_interface__, ), distance=5, units="nautical miles", diff --git a/tests/parsers/fes/test_v11.py b/tests/parsers/fes/test_v11.py index d44c419..821889a 100644 --- a/tests/parsers/fes/test_v11.py +++ b/tests/parsers/fes/test_v11.py @@ -387,8 +387,18 @@ def test_geom_overlaps(): [(0.2, 0.2), (0.5, 0.2), (0.2, 0.5), (0.2, 0.2)], ], [ - [(10.0, 10.0), (11.0, 10.0), (10.0, 11.0), (10.0, 10.0)], - [(10.2, 10.2), (10.5, 10.2), (10.2, 10.5), (10.2, 10.2)], + [ + (10.0, 10.0), + (11.0, 10.0), + (10.0, 11.0), + (10.0, 10.0), + ], + [ + (10.2, 10.2), + (10.5, 10.2), + (10.2, 10.5), + (10.2, 10.2), + ], ], ], } diff --git a/tests/parsers/fes/test_v20.py b/tests/parsers/fes/test_v20.py index ee06314..4bfaf49 100644 --- a/tests/parsers/fes/test_v20.py +++ b/tests/parsers/fes/test_v20.py @@ -391,8 +391,18 @@ def test_geom_overlaps(): [(0.2, 0.2), (0.5, 0.2), (0.2, 0.5), (0.2, 0.2)], ], [ - [(10.0, 10.0), (11.0, 10.0), (10.0, 11.0), (10.0, 10.0)], - [(10.2, 10.2), (10.5, 10.2), (10.2, 10.5), (10.2, 10.2)], + [ + (10.0, 10.0), + (11.0, 10.0), + (10.0, 11.0), + (10.0, 10.0), + ], + [ + (10.2, 10.2), + (10.5, 10.2), + (10.2, 10.5), + (10.2, 10.2), + ], ], ], } @@ -574,7 +584,11 @@ def test_begins(): assert result == ast.TimeBegins( ast.Attribute("attr"), values.Interval( - datetime(2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0))), - datetime(2001, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0))), + datetime( + 2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0)) + ), + datetime( + 2001, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0)) + ), ), ) diff --git a/tests/parsers/jfe/test_parser.py b/tests/parsers/jfe/test_parser.py index 21a4c65..aafa6c0 100644 --- a/tests/parsers/jfe/test_parser.py +++ b/tests/parsers/jfe/test_parser.py @@ -144,28 +144,46 @@ def test_attribute_before(): def test_attribute_after_dt_dt(): result = parse( - ["after", ["get", "attr"], "2000-01-01T00:00:00Z", "2000-01-01T00:00:01Z"] + [ + "after", + ["get", "attr"], + "2000-01-01T00:00:00Z", + "2000-01-01T00:00:01Z", + ] ) assert result == ast.TimeAfter( ast.Attribute("attr"), values.Interval( - datetime(2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0))), - datetime(2000, 1, 1, 0, 0, 1, tzinfo=StaticTzInfo("Z", timedelta(0))), + datetime( + 2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0)) + ), + datetime( + 2000, 1, 1, 0, 0, 1, tzinfo=StaticTzInfo("Z", timedelta(0)) + ), ), ) def test_attribute_during_dt_dt(): result = parse( - ["during", ["get", "attr"], "2000-01-01T00:00:00Z", "2000-01-01T00:00:01Z"] + [ + "during", + ["get", "attr"], + "2000-01-01T00:00:00Z", + "2000-01-01T00:00:01Z", + ] ) assert result == ast.TimeDuring( ast.Attribute("attr"), values.Interval( - datetime(2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0))), - datetime(2000, 1, 1, 0, 0, 1, tzinfo=StaticTzInfo("Z", timedelta(0))), + datetime( + 2000, 1, 1, 0, 0, 0, tzinfo=StaticTzInfo("Z", timedelta(0)) + ), + datetime( + 2000, 1, 1, 0, 0, 1, tzinfo=StaticTzInfo("Z", timedelta(0)) + ), ), ) @@ -233,7 +251,9 @@ def test_logical_all(): def test_logical_any(): - result = parse(["any", ["<", ["get", "height"], 50], ["!", ["get", "occupied"]]]) + result = parse( + ["any", ["<", ["get", "height"], 50], ["!", ["get", "occupied"]]] + ) assert result == ast.Or( ast.LessThan( ast.Attribute("height"), diff --git a/tests/test_optimize.py b/tests/test_optimize.py index 1a73b2d..0303b6d 100644 --- a/tests/test_optimize.py +++ b/tests/test_optimize.py @@ -54,7 +54,8 @@ def test_combination(): # can' reduce result = optimize(parse("attr = 1 AND other = 2")) assert result == ast.And( - ast.Equal(ast.Attribute("attr"), 1), ast.Equal(ast.Attribute("other"), 2) + ast.Equal(ast.Attribute("attr"), 1), + ast.Equal(ast.Attribute("other"), 2), ) # reduce AND to an INCLUDE if both sides evaluate to true @@ -122,7 +123,9 @@ def test_like(): # allow reduction result = optimize(parse("'This is a test' LIKE 'This is %' AND attr = 1")) assert result == ast.Equal(ast.Attribute("attr"), 1) - result = optimize(parse("'This is a test' LIKE 'This is . test' AND attr = 1")) + result = optimize( + parse("'This is a test' LIKE 'This is . test' AND attr = 1") + ) assert result == ast.Equal(ast.Attribute("attr"), 1) # don't reduction when an attribute is referenced @@ -215,10 +218,13 @@ def myadder(a, b): # can't optimize a function referencing an attribute result = optimize(parse("attr = myadder(other, 2)"), {"myadder": myadder}) assert result == ast.Equal( - ast.Attribute("attr"), ast.Function("myadder", [ast.Attribute("other"), 2]) + ast.Attribute("attr"), + ast.Function("myadder", [ast.Attribute("other"), 2]), ) # can't optimize a function with a nested reference to an attribute - result = optimize(parse("attr = myadder(other + 2, 2)"), {"myadder": myadder}) + result = optimize( + parse("attr = myadder(other + 2, 2)"), {"myadder": myadder} + ) assert result == ast.Equal( ast.Attribute("attr"), ast.Function("myadder", [ast.Add(ast.Attribute("other"), 2), 2]), diff --git a/tests/test_sql/test_evaluate.py b/tests/test_sql/test_evaluate.py index ee7fe3c..665b728 100644 --- a/tests/test_sql/test_evaluate.py +++ b/tests/test_sql/test_evaluate.py @@ -113,55 +113,83 @@ def filter_(ast, data): def test_comparison(data): result = filter_(parse("int_attr = 5"), data) - assert result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + assert ( + result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + ) result = filter_(parse("int_attr < 6"), data) - assert result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + assert ( + result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + ) result = filter_(parse("int_attr > 6"), data) - assert result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 1 + assert ( + result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 1 + ) result = filter_(parse("int_attr <= 5"), data) - assert result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + assert ( + result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + ) result = filter_(parse("int_attr >= 8"), data) - assert result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 1 + assert ( + result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 1 + ) result = filter_(parse("int_attr <> 5"), data) - assert result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 1 + assert ( + result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 1 + ) def test_combination(data): result = filter_(parse("int_attr = 5 AND float_attr < 6.0"), data) - assert result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + assert ( + result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + ) result = filter_(parse("int_attr = 5 AND float_attr < 6.0"), data) - assert result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + assert ( + result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + ) def test_between(data): result = filter_(parse("float_attr BETWEEN 4 AND 6"), data) - assert result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + assert ( + result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + ) result = filter_(parse("int_attr NOT BETWEEN 4 AND 6"), data) - assert result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 1 + assert ( + result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 1 + ) def test_like(data): result = filter_(parse("str_attr LIKE 'this is . test'"), data) - assert result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + assert ( + result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + ) result = filter_(parse("str_attr LIKE 'this is % test'"), data) assert result.GetFeatureCount() == 2 result = filter_(parse("str_attr NOT LIKE '% another test'"), data) - assert result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + assert ( + result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + ) result = filter_(parse("str_attr NOT LIKE 'this is . test'"), data) - assert result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 1 + assert ( + result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 1 + ) result = filter_(parse("str_attr ILIKE 'THIS IS . TEST'"), data) - assert result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + assert ( + result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + ) result = filter_(parse("str_attr ILIKE 'THIS IS % TEST'"), data) assert result.GetFeatureCount() == 2 @@ -169,18 +197,26 @@ def test_like(data): def test_in(data): result = filter_(parse("int_attr IN ( 1, 2, 3, 4, 5 )"), data) - assert result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + assert ( + result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + ) result = filter_(parse("int_attr NOT IN ( 1, 2, 3, 4, 5 )"), data) - assert result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 1 + assert ( + result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 1 + ) def test_null(data): result = filter_(parse("maybe_str_attr IS NULL"), data) - assert result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + assert ( + result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + ) result = filter_(parse("maybe_str_attr IS NOT NULL"), data) - assert result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 1 + assert ( + result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 1 + ) # TODO: possible? @@ -211,13 +247,17 @@ def test_spatial(data): parse("INTERSECTS(point_attr, ENVELOPE (0 1 0 1))"), data, ) - assert result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + assert ( + result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + ) result = filter_( parse("EQUALS(point_attr, POINT(2 2))"), data, ) - assert result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 1 + assert ( + result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 1 + ) def test_arithmetic(data): @@ -231,7 +271,9 @@ def test_arithmetic(data): parse("int_attr = 5 + 20 / 2 - 10"), data, ) - assert result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + assert ( + result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + ) def test_function(data): @@ -239,4 +281,6 @@ def test_function(data): parse("sin(float_attr) BETWEEN -0.75 AND -0.70"), data, ) - assert result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + assert ( + result.GetFeatureCount() == 1 and result.GetFeature(0).GetField(0) == 0 + ) From 6cb947d75eed92e0feccf963a98e2e1f064e7d50 Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Tue, 9 Jun 2026 15:42:10 +0200 Subject: [PATCH 12/12] chore: drop Python 3.9 support (EOL October 2025) Remove 3.9 from CI matrix, classifiers, requires-python, and ruff target-version. Minimum supported version is now Python 3.10. --- .github/workflows/main.yml | 2 +- pyproject.toml | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 50ca637..effc720 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 diff --git a/pyproject.toml b/pyproject.toml index e3ca1c4..5964a84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "pygeofilter" description = "pygeofilter is a pure Python parser implementation of OGC filtering standards" readme = "README.md" license = "MIT" -requires-python = ">=3.9" +requires-python = ">=3.10" authors = [ { name = "Fabian Schindler", email = "fabian.schindler@eox.at" }, ] @@ -15,7 +15,6 @@ classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Topic :: Scientific/Engineering :: GIS", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -87,7 +86,7 @@ include = ["pygeofilter*"] pygeofilter = ["py.typed", "**/*.lark"] [tool.ruff] -target-version = "py39" +target-version = "py310" line-length = 80 [tool.ruff.lint]