├── .github └── workflows │ ├── main.yml │ ├── publish.yml │ └── release-please.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile-3.9 ├── LICENSE ├── MANIFEST.in ├── README.md ├── SECURITY.md ├── debian ├── changelog ├── compat ├── control ├── copyright ├── rules └── source │ └── format ├── docs ├── .gitignore ├── Makefile ├── conf.py ├── contributing.rst ├── index.rst ├── license.rst ├── make.bat └── requirements.txt ├── examples ├── cql2.ipynb └── test-solr-queries.py ├── pygeofilter ├── __init__.py ├── ast.py ├── backends │ ├── __init__.py │ ├── cql2_json │ │ ├── __init__.py │ │ └── evaluate.py │ ├── django │ │ ├── __init__.py │ │ ├── evaluate.py │ │ └── filters.py │ ├── elasticsearch │ │ ├── __init__.py │ │ ├── evaluate.py │ │ └── util.py │ ├── evaluator.py │ ├── geopandas │ │ ├── __init__.py │ │ ├── evaluate.py │ │ └── filters.py │ ├── native │ │ ├── __init__.py │ │ └── evaluate.py │ ├── opensearch │ │ ├── __init__.py │ │ ├── evaluate.py │ │ └── util.py │ ├── optimize.py │ ├── oraclesql │ │ ├── __init__.py │ │ └── evaluate.py │ ├── solr │ │ ├── __init__.py │ │ ├── evaluate.py │ │ └── util.py │ ├── sql │ │ ├── __init__.py │ │ └── evaluate.py │ └── sqlalchemy │ │ ├── README.md │ │ ├── __init__.py │ │ ├── evaluate.py │ │ └── filters.py ├── cli.py ├── cql2.py ├── parsers │ ├── __init__.py │ ├── cql2_json │ │ ├── __init__.py │ │ └── parser.py │ ├── cql2_text │ │ ├── __init__.py │ │ ├── grammar.lark │ │ └── parser.py │ ├── cql_json │ │ ├── __init__.py │ │ └── parser.py │ ├── ecql │ │ ├── __init__.py │ │ ├── grammar.lark │ │ └── parser.py │ ├── fes │ │ ├── __init__.py │ │ ├── base.py │ │ ├── gml.py │ │ ├── parser.py │ │ ├── util.py │ │ ├── v11.py │ │ └── v20.py │ ├── iso8601.lark │ ├── iso8601.py │ ├── jfe │ │ ├── __init__.py │ │ └── parser.py │ ├── wkt.lark │ └── wkt.py ├── util.py ├── values.py └── version.py ├── pyproject.toml ├── requirements-dev.txt ├── requirements-test.txt ├── setup.cfg ├── setup.py └── tests ├── backends ├── __init__.py ├── django │ ├── conftest.py │ ├── test_django_evaluate.py │ └── testapp │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── apps.py │ │ ├── fixtures │ │ └── test.json │ │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ │ ├── models.py │ │ ├── tests.py │ │ └── views.py ├── elasticsearch │ ├── __init__.py │ ├── test_evaluate.py │ └── test_util.py ├── opensearch │ ├── __init__.py │ └── test_evaluate.py ├── oraclesql │ ├── __init__.py │ └── test_evaluate.py ├── solr │ ├── __init__.py │ ├── test_evaluate.py │ └── test_util.py └── sqlalchemy │ ├── __init__.py │ ├── test_evaluate.py │ └── test_filters.py ├── native ├── __init__.py └── test_evaluate.py ├── parsers ├── __init__.py ├── cql2_json │ ├── __init__.py │ ├── fixtures.json │ ├── get_fixtures.py │ ├── test_cql2_spec_fixtures.py │ └── test_parser.py ├── cql2_text │ └── test_parser.py ├── cql_json │ ├── __init__.py │ └── test_parser.py ├── ecql │ ├── __init__.py │ └── test_parser.py ├── fes │ ├── __init__.py │ ├── test_v11.py │ └── test_v20.py └── jfe │ ├── __init__.py │ └── test_parser.py ├── test_geopandas ├── __init__.py └── test_evaluate.py ├── test_optimize.py ├── test_sql ├── __init__.py └── test_evaluate.py └── test_utils.py /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: build ⚙️ 2 | 3 | on: [ push, pull_request ] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-24.04 8 | strategy: 9 | matrix: 10 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] 11 | steps: 12 | - uses: actions/checkout@master 13 | - uses: actions/setup-python@v5 14 | name: Setup Python ${{ matrix.python-version }} 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | cache: pip 18 | - name: Install requirements 19 | run: | 20 | sudo apt-get update 21 | sudo apt-get install -y binutils gdal-bin libgdal-dev libproj-dev libsqlite3-mod-spatialite spatialite-bin 22 | pip3 install -r requirements-test.txt 23 | pip3 install -r requirements-dev.txt 24 | pip3 install gdal=="`gdal-config --version`.*" 25 | pip3 install . 26 | - name: Configure sysctl limits 27 | run: | 28 | sudo swapoff -a 29 | sudo sysctl -w vm.swappiness=1 30 | sudo sysctl -w fs.file-max=262144 31 | sudo sysctl -w vm.max_map_count=262144 32 | - name: Install and run Elasticsearch 📦 33 | uses: getong/elasticsearch-action@v1.2 34 | with: 35 | elasticsearch version: '8.2.2' 36 | host port: 9200 37 | container port: 9200 38 | host node port: 9300 39 | node port: 9300 40 | discovery type: 'single-node' 41 | - name: Install and run Solr 📦 42 | uses: OSGeo/solr-action@main 43 | with: 44 | solr_version: 9.8.1 45 | host_port: 8983 46 | container_port: 8983 47 | - name: Install and run OpenSearch 📦 48 | uses: esmarkowski/opensearch-github-action@v1.0.0 49 | with: 50 | version: 2.18.0 51 | security-disabled: true 52 | port: 9209 53 | - name: Run unit tests 54 | run: | 55 | pytest 56 | # - name: run pre-commit (code formatting, lint and type checking) 57 | # run: | 58 | # python -m pip3 install pre-commit 59 | # pre-commit run --all-files 60 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - release-* 7 | - 'v*' 8 | 9 | jobs: 10 | publish: 11 | runs-on: ubuntu-24.04 12 | steps: 13 | - uses: actions/checkout@master 14 | - uses: actions/setup-python@v5 15 | name: Setup Python 16 | with: 17 | python-version: '3.11' 18 | - name: Install build dependency 19 | run: pip3 install wheel setuptools 20 | - name: Build package 21 | run: python3 setup.py sdist bdist_wheel --universal 22 | - name: Publish package 23 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 24 | uses: pypa/gh-action-pypi-publish@release/v1 25 | with: 26 | user: __token__ 27 | password: ${{ secrets.PYPI_API_TOKEN }} 28 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | name: release-please 6 | jobs: 7 | release-please: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: googleapis/release-please-action@v4 11 | with: 12 | token: ${{ secrets.PAT_WORKFLOW }} 13 | release-type: python 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | .doctrees 132 | 133 | .vscode 134 | .idea 135 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 25.1.0 4 | hooks: 5 | - id: black 6 | language_version: python 7 | 8 | - repo: https://github.com/PyCQA/isort 9 | rev: 6.0.1 10 | hooks: 11 | - id: isort 12 | language_version: python 13 | 14 | - repo: https://github.com/PyCQA/flake8 15 | rev: 7.2.0 16 | hooks: 17 | - id: flake8 18 | language_version: python 19 | 20 | - repo: https://github.com/pre-commit/mirrors-mypy 21 | rev: v1.15.0 22 | hooks: 23 | - id: mypy 24 | language_version: python 25 | args: [--install-types, --non-interactive] 26 | # N.B.: Mypy is... a bit fragile. 27 | # ref: https://github.com/python/mypy/issues/4008 28 | # The issue is that we have too many evaluate.py or parser.py and mypy believe they are all the same 29 | # when run within pre-commit 30 | files: ^pygeofilter* 31 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/conf.py 11 | 12 | # Optionally set requirements required to build your docs 13 | python: 14 | install: 15 | - requirements: docs/requirements.txt 16 | - requirements: requirements-test.txt 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.3.1](https://github.com/geopython/pygeofilter/compare/v0.3.0...v0.3.1) (2024-12-31) 4 | 5 | 6 | ### Bug Fixes 7 | 8 | * **CI:** using separate file for tracking version to help with release-please action ([1c28b7c](https://github.com/geopython/pygeofilter/commit/1c28b7c45415ecedabd01570b114902f1d8f9310)) 9 | 10 | ## [0.3.0](https://github.com/geopython/pygeofilter/compare/v0.2.4...v0.3.0) (2024-12-30) 11 | 12 | 13 | ### Features 14 | 15 | * add support for OpenSearch backend ([#111](https://github.com/geopython/pygeofilter/pull/111)) 16 | * Update lark ([#110](https://github.com/geopython/pygeofilter/pull/110)) 17 | 18 | 19 | ### Bug Fixes 20 | 21 | * Handle boolean in ecql like cql_text ([#108](https://github.com/geopython/pygeofilter/pull/108)) 22 | * Fix compatibility with i386 ([#107](https://github.com/geopython/pygeofilter/pull/107)) 23 | * add FES parser import shortcut as other filter languages ([#102](https://github.com/geopython/pygeofilter/pull/102)) 24 | 25 | 26 | ### Miscellaneous Chores 27 | 28 | * release 0.3.0 ([48de1f1](https://github.com/geopython/pygeofilter/commit/48de1f128c4956a99d6760487146636122e119a3)) 29 | 30 | ## [0.2.4](https://github.com/geopython/pygeofilter/compare/v0.2.3...v0.2.4) (2024-07-10) 31 | 32 | 33 | ### Bug Fixes 34 | 35 | * bumping version to 0.2.4 ([21bc095](https://github.com/geopython/pygeofilter/commit/21bc0957c84244b7d39dbe164f00d143d952c684)) 36 | 37 | ## [0.2.3](https://github.com/geopython/pygeofilter/compare/v0.2.2...v0.2.3) (2024-07-10) 38 | 39 | 40 | ### Bug Fixes 41 | 42 | * adding dependency for publishing packages ([249926e](https://github.com/geopython/pygeofilter/commit/249926ef2ebe264b616ce0f039a8b0e1b8626dda)) 43 | 44 | ## [0.2.2](https://github.com/geopython/pygeofilter/compare/v0.2.1...v0.2.2) (2024-07-10) 45 | 46 | 47 | ### Bug Fixes 48 | 49 | * [#85](https://github.com/geopython/pygeofilter/issues/85) ([2f1a38f](https://github.com/geopython/pygeofilter/commit/2f1a38f8bc9dfe2ebf5c318c6121d7f51029a9cf)) 50 | * Addresses [#95](https://github.com/geopython/pygeofilter/issues/95). ([d51dbb0](https://github.com/geopython/pygeofilter/commit/d51dbb0eb7a1066bd97b81cffe99da11ebf3cba4)) 51 | * Addresses [#95](https://github.com/geopython/pygeofilter/issues/95). ([2a51990](https://github.com/geopython/pygeofilter/commit/2a519904c4ac408fabb39459104efcc3e09f3a40)) 52 | * Bump pre-commit dependencies ([90f4aaa](https://github.com/geopython/pygeofilter/commit/90f4aaaafe873c69b0ccd91e897a9ff218ef5110)) 53 | * Bump pre-commit dependencies ([64f7f96](https://github.com/geopython/pygeofilter/commit/64f7f962476665d4ae4eed750099a6c887ad21ca)) 54 | * Bump pre-commit dependencies ([11f1f9a](https://github.com/geopython/pygeofilter/commit/11f1f9ab71811da758aa67b13aeb2f0cce7aaa10)) 55 | * Enable custom handling of undefined field attr in to_filter ([23f172c](https://github.com/geopython/pygeofilter/commit/23f172cf1dd1ddb19791a761f128b001e887b361)) 56 | * Enable custom handling of undefined field attr in to_filter ([f0c7e9f](https://github.com/geopython/pygeofilter/commit/f0c7e9f36d55d80e1d17917a627ae5547c80363c)) 57 | * Enable custom handling of undefined field attr in to_filter ([d829c6b](https://github.com/geopython/pygeofilter/commit/d829c6be5254a45689d8bcdb52b28b8a5ed3b5b2)) 58 | * Support prefixed attribute names in cql2-text and ecql parsing ([dbe4e9e](https://github.com/geopython/pygeofilter/commit/dbe4e9e5c0c48698f312e1cc023a43ea78391f60)) 59 | * Support prefixed attribute names in cql2-text and ecql parsing ([5318c6b](https://github.com/geopython/pygeofilter/commit/5318c6bcf6e2620d39c8bc52fa13cc40e02274ac)) 60 | * Support prefixed attribute names in cql2-text and ecql parsing ([122a5a6](https://github.com/geopython/pygeofilter/commit/122a5a6c5ba746a51bf9eb36a5d9617201d19123)) 61 | * Updating release-please to v4 ([11757ec](https://github.com/geopython/pygeofilter/commit/11757eca4a7ba71fbca575636117b6eb8b3c9e53)) 62 | 63 | ### [0.2.1](https://www.github.com/geopython/pygeofilter/compare/v0.2.0...v0.2.1) (2023-02-16) 64 | 65 | 66 | ### Bug Fixes 67 | 68 | * dt naivety ([08fb5f5](https://www.github.com/geopython/pygeofilter/commit/08fb5f5f8b0a5ee39443a6233d558bbacadb5acb)) 69 | * order of date/datetime checking in native evaluator ([d37d7c8](https://www.github.com/geopython/pygeofilter/commit/d37d7c8cb483fdb9ff53ff9f871d5a8f85a227e1)) 70 | * pinning sqlalchemy to version < 2.0.0 ([6e67239](https://www.github.com/geopython/pygeofilter/commit/6e67239eb1af9a77599bbbc8cee211c9f906d95e)) 71 | * timezone handling for dates ([6c0e5c1](https://www.github.com/geopython/pygeofilter/commit/6c0e5c17ce5dde2dc541ccd6411c55d2a86e52ec)) 72 | 73 | ## [0.2.0](https://www.github.com/geopython/pygeofilter/compare/v0.1.2...v0.2.0) (2022-10-17) 74 | 75 | 76 | ### Features 77 | 78 | * adding initial elasticsearch implmentation ([2ccfa02](https://www.github.com/geopython/pygeofilter/commit/2ccfa02d5fcf1ee1f3be76f5cf375ace2556fa6c)) 79 | 80 | ### [0.1.2](https://www.github.com/geopython/pygeofilter/compare/v0.1.1...v0.1.2) (2022-04-21) 81 | 82 | 83 | ### Bug Fixes 84 | 85 | * Allowing intervals to actually contain subnodes ([83b7c63](https://www.github.com/geopython/pygeofilter/commit/83b7c63ad9233a9ed600f061d3b8e074291dcb8c)) 86 | 87 | ### [0.1.1](https://www.github.com/geopython/pygeofilter/compare/v0.1.0...v0.1.1) (2022-02-08) 88 | 89 | 90 | ### Bug Fixes 91 | 92 | * Fixing compatibility issues with Python 3.6 type checking ([ad7ddd7](https://www.github.com/geopython/pygeofilter/commit/ad7ddd7a332f838fa284e1493f0d3cc15036ad95)) 93 | * Improved typing ([2272b3b](https://www.github.com/geopython/pygeofilter/commit/2272b3b9371ff90fe5cbc9b8f84cbf6bb5cca76a)) 94 | * Improving structure of CI for type checking ([fb755a3](https://www.github.com/geopython/pygeofilter/commit/fb755a3859baf3a07f57938da2259b5c3fb74575)) 95 | * Improving typing ([6c3584b](https://www.github.com/geopython/pygeofilter/commit/6c3584b3961fe90cc07f08f6cc8f2256112850f3)) 96 | * Improving typing on CQL2 JSON ([e0747aa](https://www.github.com/geopython/pygeofilter/commit/e0747aa2d0dbcaedd49bd9bcf30e702da68aaa37)) 97 | * more concise type checking ([87e46a2](https://www.github.com/geopython/pygeofilter/commit/87e46a2c325fb5f1c1c92408369efdf263f387db)) 98 | * mypy dependency installation (using --non-interactive) ([84a1175](https://www.github.com/geopython/pygeofilter/commit/84a11752c48773650a063a767eb97a1fa149b0ac)) 99 | * Split up Django spatial filters ([484e0b3](https://www.github.com/geopython/pygeofilter/commit/484e0b3db483db76b6456593a33ee8598f72813d)) 100 | 101 | ## [0.1.0](https://www.github.com/geopython/pygeofilter/compare/v0.1.0...v0.1.0) (2021-11-18) 102 | 103 | 104 | ### Features 105 | 106 | * Fixing release-please package name ([2b666fc](https://www.github.com/geopython/pygeofilter/commit/2b666fc5b09c2ff15fa954f035a342542aa3577f)) 107 | 108 | 109 | ### Miscellaneous Chores 110 | 111 | * release 0.1.0 ([d5e4971](https://www.github.com/geopython/pygeofilter/commit/d5e49718f7f2c7936649217b286ebad42b168a23)) 112 | 113 | ## 0.1.0 (2021-11-18) 114 | 115 | 116 | ### Features 117 | 118 | * Merge pull request [#34](https://www.github.com/geopython/pygeofilter/issues/34) from geopython/cql2_json ([5d439b2](https://www.github.com/geopython/pygeofilter/commit/5d439b277e12b883f3132d4972d2979a8aefd92e)) 119 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to pygeofilter 2 | 3 | We welcome contributions to pygeofilter, in the form of issues, bug fixes, documentation or suggestions for enhancements. This document sets out our guidelines and best practices for such contributions. 4 | 5 | It's based on the [Contributing to pygeoapi](https://github.com/geopython/pygeoapi/blob/master/CONTRIBUTING.md) guide which is based on the [Contributing to Open Source Projects 6 | Guide](https://contribution-guide-org.readthedocs.io/). 7 | 8 | pygeofilter has the following modes of contribution: 9 | 10 | - GitHub Commit Access 11 | - GitHub Pull Requests 12 | 13 | ## Code of Conduct 14 | 15 | Contributors to this project are expected to act respectfully toward others in accordance with the [OSGeo Code of Conduct](https://www.osgeo.org/code_of_conduct). 16 | 17 | ## Submitting Bugs 18 | 19 | ### Due Diligence 20 | 21 | Before submitting a bug, please do the following: 22 | 23 | * Perform __basic troubleshooting__ steps: 24 | 25 | * __Make sure you're on the latest version.__ If you're not on the most 26 | recent version, your problem may have been solved already! Upgrading is 27 | always the best first step. 28 | * [__Search the issue 29 | tracker__](https://github.com/geopython/pygeofilter/issues) 30 | to make sure it's not a known issue. 31 | 32 | ### What to put in your bug report 33 | 34 | Make sure your report gets the attention it deserves: bug reports with missing information may be ignored or punted back to you, delaying a fix. The below constitutes a bare minimum; more info is almost always better: 35 | 36 | * __What version of Python are you using?__ For example, are you using Python 3.8+, PyPy 2.0? 37 | * __What operating system are you using?__ Windows (7, 8, 10, 32-bit, 64-bit), Mac OS X, (10.7.4, 10.9.0), GNU/Linux (which distribution, which version?) Again, more detail is better. 38 | * __Which version or versions of the software are you using?__ Ideally, you've followed the advice above and are on the latest version, but please confirm this. 39 | * __How can the we recreate your problem?__ Imagine that we have never used pygeofilter before and have downloaded it for the first time. Exactly what steps do we need to take to reproduce your problem? 40 | 41 | ## Contributions and Licensing 42 | 43 | ### Contributor License Agreement 44 | 45 | Your contribution will be under our [license](https://github.com/geopython/pygeofilter/blob/main/LICENSE) as per [GitHub's terms of service](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license). 46 | 47 | ### GitHub Commit Access 48 | 49 | * Proposals to provide developers with GitHub commit access shall be raised on the pygeofilter [discussions page](https://github.com/geopython/pygeofilter/discussions). Committers shall be added by the project admin. 50 | * Removal of commit access shall be handled in the same manner. 51 | 52 | ### GitHub Pull Requests 53 | 54 | * Pull requests may include copyright in the source code header by the contributor if the contribution is significant or the contributor wants to claim copyright on their contribution. 55 | * All contributors shall be listed at https://github.com/geopython/pygeofilter/graphs/contributors 56 | * Unclaimed copyright, by default, is assigned to the main copyright holders as specified in https://github.com/geopython/pygeofilter/blob/main/LICENSE 57 | 58 | ### Version Control Branching 59 | 60 | * Always __make a new branch__ for your work, no matter how small. This makes it easy for others to take just that one set of changes from your repository, in case you have multiple unrelated changes floating around. 61 | 62 | * __Don't submit unrelated changes in the same branch/pull request!__ If it is not possible to review your changes quickly and easily, we may reject your request. 63 | 64 | * __Base your new branch off of the appropriate branch__ on the main repository: 65 | 66 | * In general the released version of pygeofilter is based on the ``main`` (default) branch whereas development work is done under other non-default branches. Unless you are sure that your issue affects a non-default branch, __base your branch off the ``main`` one__. 67 | 68 | * Note that depending on how long it takes for the dev team to merge your 69 | patch, the copy of ``main`` you worked off of may get out of date! 70 | * If you find yourself 'bumping' a pull request that's been sidelined for a while, __make sure you rebase or merge to latest ``main``__ to ensure a speedier resolution. 71 | 72 | ### Documentation 73 | 74 | * documentation is managed in `docs/`, in reStructuredText format 75 | * [Sphinx](https://www.sphinx-doc.org) is used to generate the documentation 76 | * See the [reStructuredText Primer](https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html) on rST markup and syntax 77 | 78 | 79 | ### Code Formatting 80 | 81 | * __Please follow the coding conventions and style used in the pygeofilter repository.__ 82 | * pygeofilter follows the [PEP-8](http://www.python.org/dev/peps/pep-0008/) guidelines 83 | * 80 characters 84 | * spaces, not tabs 85 | * pygeofilter, instead of PyGeoFilter, pygeoFilter, etc. 86 | 87 | 88 | #### **pre-commit** 89 | The project is using [`pre-commit`](https://pre-commit.com) to automatically run code formatting and type checking on new commits. Please install `pre-commit` and enable it on your environment before pushing new commits. 90 | 91 | ```bash 92 | # Install pre-commit 93 | pip3 install pre-commit 94 | 95 | # Enable pre-commit 96 | cd /pygeofilter 97 | pre-commit install 98 | 99 | # Optional - run pre-commit manually 100 | pre-commit run --all-files 101 | ``` 102 | 103 | ## Suggesting Enhancements 104 | 105 | We welcome suggestions for enhancements, but reserve the right to reject them if they do not follow future plans for pygeofilter. 106 | -------------------------------------------------------------------------------- /Dockerfile-3.9: -------------------------------------------------------------------------------- 1 | FROM python:3.9-buster 2 | 3 | LABEL description="Test executor" 4 | 5 | ENV DEBIAN_FRONTEND noninteractive 6 | RUN apt-get update --fix-missing \ 7 | && apt-get install -y --no-install-recommends \ 8 | binutils \ 9 | libproj-dev \ 10 | gdal-bin \ 11 | libsqlite3-mod-spatialite \ 12 | spatialite-bin \ 13 | && rm -rf /var/lib/apt/lists/* 14 | 15 | RUN mkdir /app 16 | WORKDIR /app 17 | 18 | COPY requirements-test.txt . 19 | COPY requirements-dev.txt . 20 | RUN pip3 install -r requirements-test.txt 21 | RUN pip3 install -r requirements-dev.txt 22 | 23 | COPY pygeofilter pygeofilter 24 | COPY tests tests 25 | COPY README.md . 26 | COPY setup.py . 27 | RUN pip3 install -e . 28 | 29 | RUN chmod +x tests/execute-tests.sh 30 | 31 | CMD ["tests/execute-tests.sh"] 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 geopython 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include pygeofilter *.py *.lark 2 | global-include *.lark 3 | include README.md 4 | include LICENSE 5 | include requirements.txt -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # pygeofilter Security Policy 2 | 3 | ## Reporting 4 | 5 | Security/vulnerability reports **should not** be submitted through GitHub issues or public discussions, but instead please send your report 6 | to **geopython-security nospam @ lists.osgeo.org** - (remove the blanks and 'nospam'). 7 | 8 | ## Supported Versions 9 | 10 | The pygeofilter developer team will release patches for security vulnerabilities for the following versions: 11 | 12 | | Version | Supported | 13 | | ------- | ------------------ | 14 | | latest stable version | :white_check_mark: | 15 | | previous versions | :x: | 16 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | pygeofilter (0.0.3-0~focal0) focal; urgency=low 2 | 3 | * Initial packaging. 4 | 5 | -- Angelos Tzotsos Tue, 12 Oct 2021 13:00:00 +0300 -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 9 2 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: pygeofilter 2 | Maintainer: Fabian Schindler 3 | Uploaders: Angelos Tzotsos 4 | Section: python 5 | Priority: optional 6 | Build-Depends: debhelper (>= 9), 7 | python3-setuptools, 8 | dh-python, 9 | dpkg-dev (>= 1.16), 10 | autoconf, 11 | python3-all, 12 | python3-all-dev 13 | Standards-Version: 3.9.3 14 | Homepage: https://github.com/geopython/pygeofilter 15 | 16 | Package: python3-pygeofilter 17 | Architecture: any 18 | Section: web 19 | Depends: ${shlibs:Depends}, 20 | ${misc:Depends}, 21 | python3, 22 | python3-click 23 | Description: This package contains the pygeofilter library 24 | . 25 | pygeofilter is a pure Python parser implementation of OGC filtering standards. 26 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | This package was debianized by Angelos Tzotsos on 2 | Tue, 12 Oct 2021 13:00:00 +0300. 3 | 4 | It was downloaded from: 5 | 6 | https://github.com/geopython/pygeofilter 7 | 8 | Copyright: 9 | 10 | Copyright (c) 2021 geopython 11 | 12 | License: 13 | 14 | Permission is hereby granted, free of charge, to any person obtaining a copy 15 | of this software and associated documentation files (the "Software"), to deal 16 | in the Software without restriction, including without limitation the rights 17 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 18 | copies of the Software, and to permit persons to whom the Software is 19 | furnished to do so, subject to the following conditions: 20 | 21 | The above copyright notice and this permission notice shall be included in all 22 | copies of this Software or works derived from this Software. 23 | 24 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 25 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 26 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 27 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 28 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 29 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 30 | THE SOFTWARE. 31 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | # -*- makefile -*- 3 | 4 | # Uncomment this to turn on verbose mode. 5 | #export DH_VERBOSE=1 6 | 7 | export PYBUILD_NAME=pygeofilter 8 | 9 | %: 10 | dh $@ --with python3 --buildsystem pybuild 11 | 12 | override_dh_auto_test: 13 | @echo "nocheck set, not running tests" 14 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (quilt) 2 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | api 2 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = . 8 | BUILDDIR = _build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | import sys 17 | 18 | sys.path.insert(0, os.path.abspath("..")) 19 | 20 | 21 | # -- Project information ----------------------------------------------------- 22 | 23 | project = "pygeofilter" 24 | copyright = "2021, Fabian Schindler" 25 | author = "Fabian Schindler" 26 | 27 | # The short X.Y version 28 | version = "" 29 | # The full version, including alpha/beta/rc tags 30 | release = "0.0.3" 31 | 32 | 33 | # -- General configuration --------------------------------------------------- 34 | 35 | # If your documentation needs a minimal Sphinx version, state it here. 36 | # 37 | # needs_sphinx = '1.0' 38 | 39 | # Add any Sphinx extension module names here, as strings. They can be 40 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 41 | # ones. 42 | extensions = [ 43 | "sphinx.ext.autodoc", 44 | "sphinx.ext.intersphinx", 45 | "sphinxcontrib.apidoc", 46 | "m2r2", 47 | ] 48 | 49 | # Add any paths that contain templates here, relative to this directory. 50 | templates_path = ["_templates"] 51 | 52 | # The suffix(es) of source filenames. 53 | # You can specify multiple suffix as a list of string: 54 | # 55 | # source_suffix = ['.rst', '.md'] 56 | source_suffix = ".rst" 57 | 58 | # The master toctree document. 59 | master_doc = "index" 60 | 61 | # The language for content autogenerated by Sphinx. Refer to documentation 62 | # for a list of supported languages. 63 | # 64 | # This is also used if you do content translation via gettext catalogs. 65 | # Usually you set "language" from the command line for these cases. 66 | language = None 67 | 68 | # List of patterns, relative to source directory, that match files and 69 | # directories to ignore when looking for source files. 70 | # This pattern also affects html_static_path and html_extra_path. 71 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 72 | 73 | # The name of the Pygments (syntax highlighting) style to use. 74 | pygments_style = None 75 | 76 | 77 | # -- Options for HTML output ------------------------------------------------- 78 | 79 | # The theme to use for HTML and HTML Help pages. See the documentation for 80 | # a list of builtin themes. 81 | # 82 | html_theme = "alabaster" 83 | 84 | # Theme options are theme-specific and customize the look and feel of a theme 85 | # further. For a list of options available for each theme, see the 86 | # documentation. 87 | # 88 | # html_theme_options = {} 89 | 90 | # Add any paths that contain custom static files (such as style sheets) here, 91 | # relative to this directory. They are copied after the builtin static files, 92 | # so a file named "default.css" will overwrite the builtin "default.css". 93 | html_static_path = ["_static"] 94 | 95 | # Custom sidebar templates, must be a dictionary that maps document names 96 | # to template names. 97 | # 98 | # The default sidebars (for documents that don't match any pattern) are 99 | # defined by theme itself. Builtin themes are using these templates by 100 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 101 | # 'searchbox.html']``. 102 | # 103 | # html_sidebars = {} 104 | 105 | 106 | # -- Options for HTMLHelp output --------------------------------------------- 107 | 108 | # Output file base name for HTML help builder. 109 | htmlhelp_basename = "pygeofilterdoc" 110 | 111 | 112 | # -- Options for LaTeX output ------------------------------------------------ 113 | 114 | latex_elements = { 115 | # The paper size ('letterpaper' or 'a4paper'). 116 | # 117 | # 'papersize': 'letterpaper', 118 | # The font size ('10pt', '11pt' or '12pt'). 119 | # 120 | # 'pointsize': '10pt', 121 | # Additional stuff for the LaTeX preamble. 122 | # 123 | # 'preamble': '', 124 | # Latex figure (float) alignment 125 | # 126 | # 'figure_align': 'htbp', 127 | } 128 | 129 | # Grouping the document tree into LaTeX files. List of tuples 130 | # (source start file, target name, title, 131 | # author, documentclass [howto, manual, or own class]). 132 | latex_documents = [ 133 | ( 134 | master_doc, 135 | "pygeofilter.tex", 136 | "pygeofilter Documentation", 137 | "Fabian Schindler", 138 | "manual", 139 | ), 140 | ] 141 | 142 | 143 | # -- Options for manual page output ------------------------------------------ 144 | 145 | # One entry per manual page. List of tuples 146 | # (source start file, name, description, authors, manual section). 147 | man_pages = [(master_doc, "pygeofilter", "pygeofilter Documentation", [author], 1)] 148 | 149 | 150 | # -- Options for Texinfo output ---------------------------------------------- 151 | 152 | # Grouping the document tree into Texinfo files. List of tuples 153 | # (source start file, target name, title, author, 154 | # dir menu entry, description, category) 155 | texinfo_documents = [ 156 | ( 157 | master_doc, 158 | "pygeofilter", 159 | "pygeofilter Documentation", 160 | author, 161 | "pygeofilter", 162 | "One line description of project.", 163 | "Miscellaneous", 164 | ), 165 | ] 166 | 167 | 168 | # -- Options for Epub output ------------------------------------------------- 169 | 170 | # Bibliographic Dublin Core info. 171 | epub_title = project 172 | 173 | # The unique identifier of the text. This can be a ISBN number 174 | # or the project homepage. 175 | # 176 | # epub_identifier = '' 177 | 178 | # A unique identification for the text. 179 | # 180 | # epub_uid = '' 181 | 182 | # A list of files that should not be packed into the epub file. 183 | epub_exclude_files = ["search.html"] 184 | 185 | 186 | # -- Extension configuration ------------------------------------------------- 187 | 188 | 189 | intersphinx_mapping = { 190 | "python": ("https://python.readthedocs.org/en/latest/", None), 191 | "django": ("https://django.readthedocs.org/en/latest/", None), 192 | } 193 | 194 | # apidoc configs: 195 | apidoc_module_dir = "../pygeofilter" 196 | apidoc_output_dir = "api" 197 | # apidoc_excluded_paths = ['tests'] 198 | # apidoc_separate_modules = True 199 | # apidoc_module_first = True 200 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. mdinclude:: ../CONTRIBUTING.md -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. mdinclude:: ../README.md 2 | 3 | .. toctree:: 4 | :maxdepth: 2 5 | :caption: Contents: 6 | 7 | license 8 | contributing 9 | api/modules 10 | 11 | Indices and tables 12 | ================== 13 | 14 | * :ref:`genindex` 15 | * :ref:`modindex` 16 | * :ref:`search` 17 | -------------------------------------------------------------------------------- /docs/license.rst: -------------------------------------------------------------------------------- 1 | License 2 | ======= 3 | 4 | .. include:: ../LICENSE 5 | :literal: -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.https://www.sphinx-doc.org 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinxcontrib-apidoc 2 | m2r2 3 | -------------------------------------------------------------------------------- /examples/cql2.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "id": "fe8453fa", 7 | "metadata": {}, 8 | "outputs": [], 9 | "source": [ 10 | "from pygeofilter.parsers.cql2_json import parse\n", 11 | "from pygeofilter.backends.cql2_json import to_cql2\n", 12 | "import json\n", 13 | "import traceback\n", 14 | "from lark import lark, logger, v_args\n", 15 | "from pygeofilter.cql2 import BINARY_OP_PREDICATES_MAP\n" 16 | ] 17 | }, 18 | { 19 | "cell_type": "code", 20 | "execution_count": 2, 21 | "id": "b960603d", 22 | "metadata": {}, 23 | "outputs": [ 24 | { 25 | "data": { 26 | "text/plain": [ 27 | "And(lhs=And(lhs=And(lhs=Equal(lhs=ATTRIBUTE collection, rhs='landsat8_l1tp'), rhs=LessEqual(lhs=ATTRIBUTE gsd, rhs=30)), rhs=LessEqual(lhs=ATTRIBUTE eo:cloud_cover, rhs=10)), rhs=GreaterEqual(lhs=ATTRIBUTE datetime, rhs=datetime.datetime(2021, 4, 8, 4, 39, 23, tzinfo=)))" 28 | ] 29 | }, 30 | "execution_count": 2, 31 | "metadata": {}, 32 | "output_type": "execute_result" 33 | } 34 | ], 35 | "source": [ 36 | "from pygeofilter.parsers.cql2_text import parse as cql2_parse\n", 37 | "cql2_parse(\"collection = 'landsat8_l1tp' AND gsd <= 30 AND eo:cloud_cover <= 10 AND datetime >= TIMESTAMP('2021-04-08T04:39:23Z')\")" 38 | ] 39 | }, 40 | { 41 | "cell_type": "code", 42 | "execution_count": 3, 43 | "id": "c5f47281", 44 | "metadata": {}, 45 | "outputs": [ 46 | { 47 | "name": "stdout", 48 | "output_type": "stream", 49 | "text": [ 50 | "Example 1\n", 51 | "*******parsed trees match***************\n", 52 | "*******reconstructed json matches*******\n", 53 | "____________________________________________________________\n", 54 | "Example 2\n", 55 | "*******parsed trees match***************\n", 56 | "*******reconstructed json matches*******\n", 57 | "____________________________________________________________\n", 58 | "Example 3\n", 59 | "*******parsed trees match***************\n", 60 | "*******reconstructed json matches*******\n", 61 | "____________________________________________________________\n", 62 | "Example 4\n", 63 | "*******parsed trees match***************\n", 64 | "*******reconstructed json matches*******\n", 65 | "____________________________________________________________\n", 66 | "Example 5\n", 67 | "*******parsed trees match***************\n", 68 | "*******reconstructed json matches*******\n", 69 | "____________________________________________________________\n", 70 | "Example 6\n", 71 | "*******parsed trees match***************\n", 72 | "*******reconstructed json matches*******\n", 73 | "____________________________________________________________\n", 74 | "Example 7\n", 75 | "*******parsed trees match***************\n", 76 | "*******reconstructed json matches*******\n", 77 | "____________________________________________________________\n", 78 | "Example 8\n", 79 | "*******parsed trees match***************\n", 80 | "*******reconstructed json matches*******\n", 81 | "____________________________________________________________\n", 82 | "Example 9\n", 83 | "*******parsed trees match***************\n", 84 | "*******reconstructed json matches*******\n", 85 | "____________________________________________________________\n", 86 | "Example 10\n", 87 | "*******parsed trees match***************\n", 88 | "*******reconstructed json matches*******\n", 89 | "____________________________________________________________\n", 90 | "Example 11\n", 91 | "*******parsed trees match***************\n", 92 | "*******reconstructed json matches*******\n", 93 | "____________________________________________________________\n", 94 | "Example 12\n", 95 | "*******parsed trees match***************\n", 96 | "*******reconstructed json matches*******\n", 97 | "____________________________________________________________\n" 98 | ] 99 | } 100 | ], 101 | "source": [ 102 | "from pygeofilter.parsers.cql2_text import parse as text_parse\n", 103 | "from pygeofilter.parsers.cql2_json import parse as json_parse\n", 104 | "from pygeofilter.backends.cql2_json import to_cql2\n", 105 | "import orjson\n", 106 | "import json\n", 107 | "import pprint\n", 108 | "def pp(j):\n", 109 | " print(orjson.dumps(j))\n", 110 | "with open('tests/parsers/cql2_json/fixtures.json') as f:\n", 111 | " examples = json.load(f)\n", 112 | "\n", 113 | "for k, v in examples.items():\n", 114 | " parsed_text = None\n", 115 | " parsed_json = None\n", 116 | " print (k)\n", 117 | " t=v['text'].replace('filter=','')\n", 118 | " j=v['json']\n", 119 | " # print('\\t' + t)\n", 120 | " # pp(orjson.loads(j))\n", 121 | " # print('*****')\n", 122 | " try:\n", 123 | " parsed_text=text_parse(t)\n", 124 | " parsed_json=json_parse(j)\n", 125 | " if parsed_text == parsed_json:\n", 126 | " print('*******parsed trees match***************')\n", 127 | " else:\n", 128 | " print(parsed_text)\n", 129 | " print('-----')\n", 130 | " print(parsed_json)\n", 131 | " if parsed_json is None or parsed_text is None:\n", 132 | " raise Exception\n", 133 | " if to_cql2(parsed_text) == to_cql2(parsed_json):\n", 134 | " print('*******reconstructed json matches*******')\n", 135 | " else:\n", 136 | " pp(to_cql2(parsed_text))\n", 137 | " print('-----')\n", 138 | " pp(to_cql2(parsed_json))\n", 139 | " except Exception as e:\n", 140 | " print(parsed_text)\n", 141 | " print(parsed_json)\n", 142 | " print(j)\n", 143 | " traceback.print_exc(f\"Error: {e}\")\n", 144 | " pass\n", 145 | " print('____________________________________________________________')\n", 146 | " " 147 | ] 148 | }, 149 | { 150 | "cell_type": "code", 151 | "execution_count": null, 152 | "id": "ac0bb004", 153 | "metadata": {}, 154 | "outputs": [], 155 | "source": [] 156 | } 157 | ], 158 | "metadata": { 159 | "kernelspec": { 160 | "display_name": "pygeofilter", 161 | "language": "python", 162 | "name": "pygeofilter" 163 | }, 164 | "language_info": { 165 | "codemirror_mode": { 166 | "name": "ipython", 167 | "version": 3 168 | }, 169 | "file_extension": ".py", 170 | "mimetype": "text/x-python", 171 | "name": "python", 172 | "nbconvert_exporter": "python", 173 | "pygments_lexer": "ipython3", 174 | "version": "3.8.10" 175 | } 176 | }, 177 | "nbformat": 4, 178 | "nbformat_minor": 5 179 | } 180 | -------------------------------------------------------------------------------- /pygeofilter/__init__.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pygeofilter 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2019 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in 17 | # all copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | from .version import __version__ 29 | 30 | __all__ = ["__version__"] 31 | -------------------------------------------------------------------------------- /pygeofilter/backends/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pygeofilter/a0b1f158111569032666df6ed459c1b19f35b5b7/pygeofilter/backends/__init__.py -------------------------------------------------------------------------------- /pygeofilter/backends/cql2_json/__init__.py: -------------------------------------------------------------------------------- 1 | from .evaluate import to_cql2 2 | 3 | __all__ = ["to_cql2"] 4 | -------------------------------------------------------------------------------- /pygeofilter/backends/cql2_json/evaluate.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pygeofilter 4 | # Authors: Fabian Schindler , 5 | # David Bitner 6 | # 7 | # ------------------------------------------------------------------------------ 8 | # Copyright (C) 2021 EOX IT Services GmbH 9 | # 10 | # Permission is hereby granted, free of charge, to any person obtaining a copy 11 | # of this software and associated documentation files (the "Software"), to deal 12 | # in the Software without restriction, including without limitation the rights 13 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | # copies of the Software, and to permit persons to whom the Software is 15 | # furnished to do so, subject to the following conditions: 16 | # 17 | # The above copyright notice and this permission notice shall be included in 18 | # all copies of this Software or works derived from this Software. 19 | # 20 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 26 | # THE SOFTWARE. 27 | # ------------------------------------------------------------------------------ 28 | 29 | import json 30 | from datetime import date, datetime 31 | from typing import Dict, Optional 32 | 33 | from ... import ast, values 34 | from ...cql2 import get_op 35 | from ..evaluator import Evaluator, handle 36 | 37 | 38 | def json_serializer(obj): 39 | if isinstance(obj, (datetime, date)): 40 | return obj.isoformat() 41 | if hasattr(obj, "name"): 42 | return obj.name 43 | raise TypeError(f"{obj} with type {type(obj)} is not serializable.") 44 | 45 | 46 | class CQL2Evaluator(Evaluator): 47 | def __init__( 48 | self, 49 | attribute_map: Optional[Dict[str, str]], 50 | function_map: Optional[Dict[str, str]], 51 | ): 52 | self.attribute_map = attribute_map 53 | self.function_map = function_map 54 | 55 | @handle( 56 | ast.Condition, 57 | ast.Comparison, 58 | ast.TemporalPredicate, 59 | ast.SpatialComparisonPredicate, 60 | ast.Arithmetic, 61 | ast.ArrayPredicate, 62 | subclasses=True, 63 | ) 64 | def comparison(self, node, *args): 65 | op = get_op(node) 66 | return {"op": op, "args": [*args]} 67 | 68 | @handle(ast.Between) 69 | def between(self, node, lhs, low, high): 70 | return {"op": "between", "args": [lhs, [low, high]]} 71 | 72 | @handle(ast.Like) 73 | def like(self, node, *subargs): 74 | return {"op": "like", "args": [subargs[0], node.pattern]} 75 | 76 | @handle(ast.IsNull) 77 | def isnull(self, node, arg): 78 | return {"op": "isNull", "args": [arg]} 79 | 80 | @handle(ast.Function) 81 | def function(self, node, *args): 82 | name = node.name.lower() 83 | if name == "lower": 84 | ret = {"lower": args[0]} 85 | elif name == "upper": 86 | ret = {"upper": args[0]} 87 | else: 88 | ret = {"function": name, "args": [*args]} 89 | return ret 90 | 91 | @handle(ast.In) 92 | def in_(self, node, lhs, *options): 93 | return {"op": "in", "args": [lhs, options]} 94 | 95 | @handle(ast.Attribute) 96 | def attribute(self, node: ast.Attribute): 97 | return {"property": node.name} 98 | 99 | @handle(values.Interval) 100 | def interval(self, node: values.Interval, start, end): 101 | return {"interval": [start, end]} 102 | 103 | @handle(datetime) 104 | def datetime(self, node: ast.Attribute): 105 | return {"timestamp": node.name} 106 | 107 | @handle(*values.LITERALS) 108 | def literal(self, node): 109 | return node 110 | 111 | @handle(values.Geometry) 112 | def geometry(self, node: values.Geometry): 113 | return node.__geo_interface__ 114 | 115 | @handle(values.Envelope) 116 | def envelope(self, node: values.Envelope): 117 | return node.__geo_interface__ 118 | 119 | 120 | def to_cql2( 121 | root: ast.Node, 122 | field_mapping: Optional[Dict[str, str]] = None, 123 | function_map: Optional[Dict[str, str]] = None, 124 | ) -> str: 125 | return json.dumps( 126 | CQL2Evaluator(field_mapping, function_map).evaluate(root), 127 | default=json_serializer, 128 | ) 129 | -------------------------------------------------------------------------------- /pygeofilter/backends/django/__init__.py: -------------------------------------------------------------------------------- 1 | from .evaluate import to_filter 2 | 3 | __all__ = ["to_filter"] 4 | -------------------------------------------------------------------------------- /pygeofilter/backends/django/evaluate.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pygeofilter 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2019 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in 17 | # all copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | import json 29 | 30 | from django.contrib.gis.geos import GEOSGeometry, Polygon 31 | 32 | from ... import ast, values 33 | from ..evaluator import Evaluator, handle 34 | from . import filters 35 | 36 | 37 | class DjangoFilterEvaluator(Evaluator): 38 | def __init__(self, field_mapping, mapping_choices): 39 | self.field_mapping = field_mapping 40 | self.mapping_choices = mapping_choices 41 | 42 | @handle(ast.Not) 43 | def not_(self, node, sub): 44 | return filters.negate(sub) 45 | 46 | @handle(ast.And, ast.Or) 47 | def combination(self, node, lhs, rhs): 48 | return filters.combine((lhs, rhs), node.op.value) 49 | 50 | @handle(ast.Comparison, subclasses=True) 51 | def comparison(self, node, lhs, rhs): 52 | return filters.compare(lhs, rhs, node.op.value, self.mapping_choices) 53 | 54 | @handle(ast.Between) 55 | def between(self, node, lhs, low, high): 56 | return filters.between(lhs, low, high, node.not_) 57 | 58 | @handle(ast.Like) 59 | def like(self, node, lhs): 60 | return filters.like( 61 | lhs, node.pattern, node.nocase, node.not_, self.mapping_choices 62 | ) 63 | 64 | @handle(ast.In) 65 | def in_(self, node, lhs, *options): 66 | return filters.contains(lhs, options, node.not_, self.mapping_choices) 67 | 68 | @handle(ast.IsNull) 69 | def null(self, node, lhs): 70 | return filters.null(lhs, node.not_) 71 | 72 | # @handle(ast.ExistsPredicateNode) 73 | # def exists(self, node, lhs): 74 | # if self.use_getattr: 75 | # result = hasattr(self.obj, node.lhs.name) 76 | # else: 77 | # result = lhs in self.obj 78 | 79 | # if node.not_: 80 | # result = not result 81 | # return result 82 | 83 | @handle(ast.TemporalPredicate, subclasses=True) 84 | def temporal(self, node, lhs, rhs): 85 | return filters.temporal( 86 | lhs, 87 | rhs, 88 | node.op.value, 89 | ) 90 | 91 | @handle(ast.SpatialComparisonPredicate, subclasses=True) 92 | def spatial_operation(self, node, lhs, rhs): 93 | return filters.spatial( 94 | lhs, 95 | rhs, 96 | node.op.name, 97 | ) 98 | 99 | @handle(ast.Relate) 100 | def spatial_pattern(self, node, lhs, rhs): 101 | return filters.spatial_relate( 102 | lhs, 103 | rhs, 104 | pattern=node.pattern, 105 | ) 106 | 107 | @handle(ast.SpatialDistancePredicate, subclasses=True) 108 | def spatial_distance(self, node, lhs, rhs): 109 | return filters.spatial_distance( 110 | lhs, 111 | rhs, 112 | node.op.value, 113 | distance=node.distance, 114 | units=node.units, 115 | ) 116 | 117 | @handle(ast.BBox) 118 | def bbox(self, node, lhs): 119 | return filters.bbox(lhs, node.minx, node.miny, node.maxx, node.maxy, node.crs) 120 | 121 | @handle(ast.Attribute) 122 | def attribute(self, node): 123 | return filters.attribute(node.name, self.field_mapping) 124 | 125 | @handle(ast.Arithmetic, subclasses=True) 126 | def arithmetic(self, node, lhs, rhs): 127 | return filters.arithmetic(lhs, rhs, node.op.value) 128 | 129 | # TODO: map functions 130 | # @handle(ast.FunctionExpressionNode) 131 | # def function(self, node, *arguments): 132 | # return self.function_map[node.name](*arguments) 133 | 134 | @handle(*values.LITERALS) 135 | def literal(self, node): 136 | return filters.literal(node) 137 | 138 | @handle(values.Interval) 139 | def interval(self, node, start, end): 140 | return filters.literal((start, end)) 141 | 142 | @handle(values.Geometry) 143 | def geometry(self, node): 144 | return GEOSGeometry(json.dumps(node.__geo_interface__)) 145 | 146 | @handle(values.Envelope) 147 | def envelope(self, node): 148 | return Polygon.from_bbox((node.x1, node.y1, node.x2, node.y2)) 149 | 150 | 151 | def to_filter(root, field_mapping=None, mapping_choices=None): 152 | """Helper function to translate ECQL AST to Django Query expressions. 153 | 154 | :param ast: the abstract syntax tree 155 | :param field_mapping: a dict mapping from the filter name to the Django 156 | field lookup. 157 | :param mapping_choices: a dict mapping field lookups to choices. 158 | :type ast: :class:`Node` 159 | :returns: a Django query object 160 | :rtype: :class:`django.db.models.Q` 161 | """ 162 | return DjangoFilterEvaluator(field_mapping, mapping_choices).evaluate(root) 163 | -------------------------------------------------------------------------------- /pygeofilter/backends/elasticsearch/__init__.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pygeofilter 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2022 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in 17 | # all copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | """Elasticsearch backend for pygeofilter.""" 29 | 30 | from .evaluate import to_filter 31 | 32 | __all__ = ["to_filter"] 33 | -------------------------------------------------------------------------------- /pygeofilter/backends/elasticsearch/util.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pygeofilter 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2022 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in 17 | # all copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | """General utilities for the Elasticsearch backend.""" 29 | 30 | import re 31 | 32 | 33 | def like_to_wildcard( 34 | value: str, wildcard: str, single_char: str, escape_char: str = "\\" 35 | ) -> str: 36 | """Adapts a "LIKE" pattern to create an elasticsearch "wildcard" 37 | pattern. 38 | """ 39 | 40 | x_wildcard = re.escape(wildcard) 41 | x_single_char = re.escape(single_char) 42 | 43 | if escape_char == "\\": 44 | x_escape_char = "\\\\\\\\" 45 | else: 46 | x_escape_char = re.escape(escape_char) 47 | 48 | if wildcard != "*": 49 | value = re.sub( 50 | f"(? 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2021 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in 17 | # all copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | from functools import wraps 29 | from typing import Any, Callable, Dict, List, Type, cast 30 | 31 | from .. import ast 32 | 33 | 34 | def get_all_subclasses(*classes: Type) -> List[Type]: 35 | """Utility function to get all the leaf-classes (classes that don't 36 | have any further sub-classes) from a given list of classes. 37 | """ 38 | all_subclasses = [] 39 | 40 | for cls in classes: 41 | subclasses = cls.__subclasses__() 42 | if subclasses: 43 | all_subclasses.extend(get_all_subclasses(*subclasses)) 44 | else: 45 | # directly insert classes that do not have any sub-classes 46 | all_subclasses.append(cls) 47 | 48 | return all_subclasses 49 | 50 | 51 | def handle(*node_classes: Type, subclasses: bool = False) -> Callable: 52 | """Function-decorator to mark a class function as a handler for a 53 | given node type. 54 | """ 55 | assert node_classes 56 | 57 | @wraps(handle) 58 | def inner(func): 59 | if subclasses: 60 | func.handles_classes = get_all_subclasses(*node_classes) 61 | else: 62 | func.handles_classes = node_classes 63 | return func 64 | 65 | return inner 66 | 67 | 68 | class EvaluatorMeta(type): 69 | """Metaclass for the ``Evaluator`` class to create a static map for 70 | all handler methods by their respective handled types. 71 | """ 72 | 73 | def __init__(cls, name, bases, dct): 74 | cls.handler_map = {} 75 | for base in bases: 76 | cls.handler_map.update(getattr(base, "handler_map")) 77 | 78 | for value in dct.values(): 79 | if hasattr(value, "handles_classes"): 80 | for handled_class in value.handles_classes: 81 | cls.handler_map[handled_class] = value 82 | 83 | 84 | class Evaluator(metaclass=EvaluatorMeta): 85 | """Base class for AST evaluators.""" 86 | 87 | handler_map: Dict[Type, Callable] 88 | 89 | def evaluate(self, node: ast.AstType, adopt_result: bool = True) -> Any: 90 | """Recursive function to evaluate an abstract syntax tree. 91 | For every node in the walked syntax tree, its registered handler 92 | is called with the node as first parameter and all pre-evaluated 93 | child nodes as star-arguments. 94 | When no handler was found for a given node, the ``adopt`` function 95 | is called with the node and its arguments, which by default raises 96 | an ``NotImplementedError``. 97 | """ 98 | sub_args = [] 99 | if hasattr(node, "get_sub_nodes"): 100 | subnodes = cast(ast.Node, node).get_sub_nodes() 101 | if subnodes: 102 | if isinstance(subnodes, list): 103 | sub_args = [self.evaluate(sub_node, False) for sub_node in subnodes] 104 | else: 105 | sub_args = [self.evaluate(subnodes, False)] 106 | 107 | handler = self.handler_map.get(type(node)) 108 | if handler is not None: 109 | result = handler(self, node, *sub_args) 110 | else: 111 | result = self.adopt(node, *sub_args) 112 | 113 | if adopt_result: 114 | return self.adopt_result(result) 115 | else: 116 | return result 117 | 118 | def adopt(self, node, *sub_args): 119 | """Interface function for a last resort when trying to evaluate a node 120 | and no handler was found. 121 | """ 122 | raise NotImplementedError(f"Failed to evaluate node of type {type(node)}") 123 | 124 | def adopt_result(self, result: Any) -> Any: 125 | """Interface function for adopting the final evaluation result 126 | if necessary. Default is no-op. 127 | """ 128 | return result 129 | -------------------------------------------------------------------------------- /pygeofilter/backends/geopandas/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pygeofilter/a0b1f158111569032666df6ed459c1b19f35b5b7/pygeofilter/backends/geopandas/__init__.py -------------------------------------------------------------------------------- /pygeofilter/backends/geopandas/evaluate.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pygeofilter 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2021 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in 17 | # all copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | from datetime import date, datetime, time, timedelta 29 | 30 | from shapely import geometry 31 | 32 | from ... import ast, values 33 | from ..evaluator import Evaluator, handle 34 | from . import filters 35 | 36 | LITERALS = (str, float, int, bool, datetime, date, time, timedelta) 37 | 38 | 39 | class GeoPandasEvaluator(Evaluator): 40 | def __init__(self, df, field_mapping=None, function_map=None): 41 | self.df = df 42 | self.field_mapping = field_mapping 43 | self.function_map = function_map 44 | 45 | @handle(ast.Not) 46 | def not_(self, node, sub): 47 | return filters.negate(sub) 48 | 49 | @handle(ast.And, ast.Or) 50 | def combination(self, node, lhs, rhs): 51 | return filters.combine((lhs, rhs), node.op.value) 52 | 53 | @handle(ast.Comparison, subclasses=True) 54 | def comparison(self, node, lhs, rhs): 55 | return filters.compare( 56 | lhs, 57 | rhs, 58 | node.op.value, 59 | ) 60 | 61 | @handle(ast.Between) 62 | def between(self, node, lhs, low, high): 63 | return filters.between(lhs, low, high, node.not_) 64 | 65 | @handle(ast.Like) 66 | def like(self, node, lhs): 67 | return filters.like( 68 | lhs, 69 | node.pattern, 70 | node.nocase, 71 | node.wildcard, 72 | node.singlechar, 73 | node.escapechar, 74 | node.not_, 75 | ) 76 | 77 | @handle(ast.In) 78 | def in_(self, node, lhs, *options): 79 | return filters.contains( 80 | lhs, 81 | options, 82 | node.not_, 83 | ) 84 | 85 | @handle(ast.IsNull) 86 | def null(self, node, lhs): 87 | return filters.null( 88 | lhs, 89 | node.not_, 90 | ) 91 | 92 | @handle(ast.TemporalPredicate, subclasses=True) 93 | def temporal(self, node, lhs, rhs): 94 | return filters.temporal( 95 | node.lhs, 96 | node.rhs, 97 | node.op.value, 98 | ) 99 | 100 | @handle(ast.SpatialComparisonPredicate, subclasses=True) 101 | def spatial_operation(self, node, lhs, rhs): 102 | return filters.spatial( 103 | lhs, 104 | rhs, 105 | node.op.name, 106 | ) 107 | 108 | @handle(ast.BBox) 109 | def bbox(self, node, lhs): 110 | return filters.bbox(lhs, node.minx, node.miny, node.maxx, node.maxy, node.crs) 111 | 112 | @handle(ast.Attribute) 113 | def attribute(self, node): 114 | return filters.attribute(self.df, node.name, self.field_mapping) 115 | 116 | @handle(ast.Arithmetic, subclasses=True) 117 | def arithmetic(self, node, lhs, rhs): 118 | return filters.arithmetic(lhs, rhs, node.op.value) 119 | 120 | @handle(ast.Function) 121 | def function(self, node, *arguments): 122 | return self.function_map[node.name](*arguments) 123 | 124 | @handle(*values.LITERALS) 125 | def literal(self, node): 126 | return node 127 | 128 | @handle(values.Interval) 129 | def interval(self, node, start, end): 130 | return (start, end) 131 | 132 | @handle(values.Geometry) 133 | def geometry(self, node): 134 | return geometry.shape(node) 135 | 136 | @handle(values.Envelope) 137 | def envelope(self, node): 138 | return geometry.Polygon.from_bounds(node.x1, node.y1, node.x2, node.y2) 139 | 140 | 141 | def to_filter(df, root, field_mapping=None, function_map=None): 142 | """ """ 143 | return GeoPandasEvaluator(df, field_mapping, function_map).evaluate(root) 144 | -------------------------------------------------------------------------------- /pygeofilter/backends/geopandas/filters.py: -------------------------------------------------------------------------------- 1 | from functools import reduce 2 | from operator import add, and_, eq, ge, gt, le, lt, mul, ne, or_, sub, truediv 3 | 4 | import shapely 5 | 6 | from ...util import like_pattern_to_re 7 | 8 | 9 | def combine(sub_filters, combinator: str): 10 | """Combine filters using a logical combinator""" 11 | assert combinator in ("AND", "OR") 12 | op = and_ if combinator == "AND" else or_ 13 | return reduce(lambda acc, q: op(acc, q) if acc is not None else q, sub_filters) 14 | 15 | 16 | def negate(sub_filter): 17 | """Negate a filter, opposing its meaning.""" 18 | return ~sub_filter 19 | 20 | 21 | OP_MAP = { 22 | "<": lt, 23 | "<=": le, 24 | ">": gt, 25 | ">=": ge, 26 | "<>": ne, 27 | "=": eq, 28 | } 29 | 30 | 31 | def compare(lhs, rhs, op): 32 | return OP_MAP[op](lhs, rhs) 33 | 34 | 35 | def between(lhs, low, high, not_): 36 | result = lhs.between(low, high) 37 | if not_: 38 | result = ~result 39 | return result 40 | 41 | 42 | def like(lhs, pattern, nocase, wildcard, singlechar, escapechar, not_): 43 | regex = like_pattern_to_re( 44 | pattern, nocase, wildcard, singlechar, escapechar or "\\" 45 | ) 46 | result = lhs.str.match(regex) 47 | if not_: 48 | result = ~result 49 | return result 50 | 51 | 52 | def contains(lhs, items, not_): 53 | # TODO: check if dataframe or scalar 54 | result = lhs.isin(items) 55 | if not_: 56 | result = ~result 57 | return result 58 | 59 | 60 | def null(lhs, not_): 61 | result = lhs.isnull() 62 | if not_: 63 | result = ~result 64 | return result 65 | 66 | 67 | def temporal(lhs, time_or_period, op): 68 | pass 69 | # TODO implement 70 | 71 | 72 | SPATIAL_OP_MAP = { 73 | "INTERSECTS": "intersects", 74 | "DISJOINT": "disjoint", 75 | "CONTAINS": "contains", 76 | "WITHIN": "within", 77 | "TOUCHES": "touches", 78 | "CROSSES": "crosses", 79 | "OVERLAPS": "overlaps", 80 | "EQUALS": "geom_equals", 81 | } 82 | 83 | 84 | def spatial(lhs, rhs, op): 85 | assert op in SPATIAL_OP_MAP 86 | return getattr(lhs, SPATIAL_OP_MAP[op])(rhs) 87 | 88 | 89 | def bbox(lhs, minx, miny, maxx, maxy, crs=None): 90 | box = shapely.geometry.Polygon.from_bounds(minx, miny, maxx, maxy) 91 | # TODO: handle CRS 92 | return lhs.intersects(box) 93 | 94 | 95 | def attribute(df, name, field_mapping=None): 96 | if field_mapping: 97 | name = field_mapping[name] 98 | return df[name] 99 | 100 | 101 | OP_TO_FUNC = {"+": add, "-": sub, "*": mul, "/": truediv} 102 | 103 | 104 | def arithmetic(lhs, rhs, op): 105 | """Create an arithmetic filter""" 106 | return OP_TO_FUNC[op](lhs, rhs) 107 | -------------------------------------------------------------------------------- /pygeofilter/backends/native/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pygeofilter/a0b1f158111569032666df6ed459c1b19f35b5b7/pygeofilter/backends/native/__init__.py -------------------------------------------------------------------------------- /pygeofilter/backends/opensearch/__init__.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pygeofilter 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2022 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in 17 | # all copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | """OpenSearch backend for pygeofilter.""" 29 | 30 | from .evaluate import to_filter 31 | 32 | __all__ = ["to_filter"] 33 | -------------------------------------------------------------------------------- /pygeofilter/backends/opensearch/util.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pygeofilter 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2022 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in 17 | # all copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | """General utilities for the OpenSearch backend.""" 29 | 30 | import re 31 | 32 | 33 | def like_to_wildcard( 34 | value: str, wildcard: str, single_char: str, escape_char: str = "\\" 35 | ) -> str: 36 | """Adapts a "LIKE" pattern to create an OpenSearch "wildcard" 37 | pattern. 38 | """ 39 | 40 | x_wildcard = re.escape(wildcard) 41 | x_single_char = re.escape(single_char) 42 | 43 | if escape_char == "\\": 44 | x_escape_char = "\\\\\\\\" 45 | else: 46 | x_escape_char = re.escape(escape_char) 47 | 48 | if wildcard != "*": 49 | value = re.sub( 50 | f"(? 4 | # Authors: Andreas Kosubek 5 | # Bernhard Mallinger 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2024 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in 17 | # all copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | 29 | from .evaluate import to_sql_where, to_sql_where_with_bind_variables 30 | 31 | __all__ = ["to_sql_where", "to_sql_where_with_bind_variables"] 32 | -------------------------------------------------------------------------------- /pygeofilter/backends/solr/__init__.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pygeofilter 4 | # Authors: Magnar Martinsen 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2025 Norwegian Meteorological Institute 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in 17 | # all copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | """ Apache Solr backend for pygeofilter 29 | """ 30 | 31 | from .evaluate import to_filter 32 | 33 | __all__ = ["to_filter"] 34 | -------------------------------------------------------------------------------- /pygeofilter/backends/solr/util.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pygeofilter 4 | # Authors: Magnar Martinsen 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2025 Norwegian Meteorological Institute 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in 17 | # all copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | """ General utilities for the Apache Solr backend. 29 | """ 30 | 31 | import re 32 | 33 | 34 | def like_to_wildcard( 35 | value: str, wildcard: str, single_char: str, escape_char: str = "\\" 36 | ) -> str: 37 | """Adapts a "LIKE" pattern to create a Solr "wildcard" 38 | pattern. 39 | """ 40 | 41 | x_wildcard = re.escape(wildcard) 42 | x_single_char = re.escape(single_char) 43 | 44 | if escape_char == "\\": 45 | x_escape_char = "\\\\\\\\" 46 | else: 47 | x_escape_char = re.escape(escape_char) 48 | 49 | if wildcard != "*": 50 | value = re.sub( 51 | f"(? 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2021 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in 17 | # all copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | from typing import Dict, Optional 29 | 30 | import shapely.geometry 31 | 32 | from ... import ast, values 33 | from ..evaluator import Evaluator, handle 34 | 35 | COMPARISON_OP_MAP = { 36 | ast.ComparisonOp.EQ: "=", 37 | ast.ComparisonOp.NE: "<>", 38 | ast.ComparisonOp.LT: "<", 39 | ast.ComparisonOp.LE: "<=", 40 | ast.ComparisonOp.GT: ">", 41 | ast.ComparisonOp.GE: ">=", 42 | } 43 | 44 | 45 | ARITHMETIC_OP_MAP = { 46 | ast.ArithmeticOp.ADD: "+", 47 | ast.ArithmeticOp.SUB: "-", 48 | ast.ArithmeticOp.MUL: "*", 49 | ast.ArithmeticOp.DIV: "/", 50 | } 51 | 52 | SPATIAL_COMPARISON_OP_MAP = { 53 | ast.SpatialComparisonOp.INTERSECTS: "ST_Intersects", 54 | ast.SpatialComparisonOp.DISJOINT: "ST_Disjoint", 55 | ast.SpatialComparisonOp.CONTAINS: "ST_Contains", 56 | ast.SpatialComparisonOp.WITHIN: "ST_Within", 57 | ast.SpatialComparisonOp.TOUCHES: "ST_Touches", 58 | ast.SpatialComparisonOp.CROSSES: "ST_Crosses", 59 | ast.SpatialComparisonOp.OVERLAPS: "ST_Overlaps", 60 | ast.SpatialComparisonOp.EQUALS: "ST_Equals", 61 | } 62 | 63 | 64 | class SQLEvaluator(Evaluator): 65 | def __init__(self, attribute_map: Dict[str, str], function_map: Dict[str, str]): 66 | self.attribute_map = attribute_map 67 | self.function_map = function_map 68 | 69 | @handle(ast.Not) 70 | def not_(self, node, sub): 71 | return f"NOT {sub}" 72 | 73 | @handle(ast.And, ast.Or) 74 | def combination(self, node, lhs, rhs): 75 | return f"({lhs} {node.op.value} {rhs})" 76 | 77 | @handle(ast.Comparison, subclasses=True) 78 | def comparison(self, node, lhs, rhs): 79 | return f"({lhs} {COMPARISON_OP_MAP[node.op]} {rhs})" 80 | 81 | @handle(ast.Between) 82 | def between(self, node, lhs, low, high): 83 | return f"({lhs} {'NOT ' if node.not_ else ''}BETWEEN {low} AND {high})" 84 | 85 | @handle(ast.Like) 86 | def like(self, node, lhs): 87 | pattern = node.pattern 88 | if node.wildcard != "%": 89 | # TODO: not preceded by escapechar 90 | pattern = pattern.replace(node.wildcard, "%") 91 | if node.singlechar != "_": 92 | # TODO: not preceded by escapechar 93 | pattern = pattern.replace(node.singlechar, "_") 94 | 95 | # TODO: handle node.nocase 96 | return ( 97 | f"{lhs} {'NOT ' if node.not_ else ''}LIKE " 98 | f"'{pattern}' ESCAPE '{node.escapechar}'" 99 | ) 100 | 101 | @handle(ast.In) 102 | def in_(self, node, lhs, *options): 103 | return f"{lhs} {'NOT ' if node.not_ else ''}IN ({', '.join(options)})" 104 | 105 | @handle(ast.IsNull) 106 | def null(self, node, lhs): 107 | return f"{lhs} IS {'NOT ' if node.not_ else ''}NULL" 108 | 109 | # @handle(ast.TemporalPredicate, subclasses=True) 110 | # def temporal(self, node, lhs, rhs): 111 | # pass 112 | 113 | @handle(ast.SpatialComparisonPredicate, subclasses=True) 114 | def spatial_operation(self, node, lhs, rhs): 115 | func = SPATIAL_COMPARISON_OP_MAP[node.op] 116 | return f"{func}({lhs},{rhs})" 117 | 118 | @handle(ast.BBox) 119 | def bbox(self, node, lhs): 120 | func = SPATIAL_COMPARISON_OP_MAP[ast.SpatialComparisonOp.INTERSECTS] 121 | rhs = f"ST_GeomFromText('POLYGON(({node.minx} {node.miny}, {node.minx} {node.maxy}, {node.maxx} {node.maxy}, {node.maxx} {node.miny}, {node.minx} {node.miny}))')" # noqa 122 | return f"{func}({lhs},{rhs})" 123 | 124 | @handle(ast.Attribute) 125 | def attribute(self, node: ast.Attribute): 126 | return f'"{self.attribute_map[node.name]}"' 127 | 128 | @handle(ast.Arithmetic, subclasses=True) 129 | def arithmetic(self, node: ast.Arithmetic, lhs, rhs): 130 | op = ARITHMETIC_OP_MAP[node.op] 131 | return f"({lhs} {op} {rhs})" 132 | 133 | @handle(ast.Function) 134 | def function(self, node, *arguments): 135 | func = self.function_map[node.name] 136 | return f"{func}({','.join(arguments)})" 137 | 138 | @handle(*values.LITERALS) 139 | def literal(self, node): 140 | if isinstance(node, str): 141 | return f"'{node}'" 142 | else: 143 | # TODO: 144 | return str(node) 145 | 146 | @handle(values.Geometry) 147 | def geometry(self, node: values.Geometry): 148 | wkb_hex = shapely.geometry.shape(node).wkb_hex 149 | return f"ST_GeomFromWKB(x'{wkb_hex}')" 150 | 151 | @handle(values.Envelope) 152 | def envelope(self, node: values.Envelope): 153 | wkb_hex = shapely.geometry.box(node.x1, node.y1, node.x2, node.y2).wkb_hex 154 | return f"ST_GeomFromWKB(x'{wkb_hex}')" 155 | 156 | 157 | def to_sql_where( 158 | root: ast.Node, 159 | field_mapping: Dict[str, str], 160 | function_map: Optional[Dict[str, str]] = None, 161 | ) -> str: 162 | return SQLEvaluator(field_mapping, function_map or {}).evaluate(root) 163 | -------------------------------------------------------------------------------- /pygeofilter/backends/sqlalchemy/README.md: -------------------------------------------------------------------------------- 1 | ## SQLAlchemy Integration 2 | 3 | The SQLAlchemy Integration translates the AST into a set of filters suitable for input into a filter of a SQLAlchemy Query. 4 | 5 | Given the following example model: 6 | 7 | ```python 8 | from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey 9 | from geoalchemy2 import Geometry 10 | Base = declarative_base() 11 | 12 | 13 | class Record(Base): 14 | __tablename__ = "record" 15 | identifier = Column(String, primary_key=True) 16 | geometry = Column( 17 | Geometry( 18 | geometry_type="MULTIPOLYGON", 19 | srid=4326, 20 | spatial_index=False, 21 | management=True, 22 | ) 23 | ) 24 | float_attribute = Column(Float) 25 | int_attribute = Column(Integer) 26 | str_attribute = Column(String) 27 | datetime_attribute = Column(DateTime) 28 | choice_attribute = Column(Integer) 29 | 30 | 31 | class RecordMeta(Base): 32 | __tablename__ = "record_meta" 33 | identifier = Column(Integer, primary_key=True) 34 | record = Column(String, ForeignKey("record.identifier")) 35 | float_meta_attribute = Column(Float) 36 | int_meta_attribute = Column(Integer) 37 | str_meta_attribute = Column(String) 38 | datetime_meta_attribute = Column(DateTime) 39 | choice_meta_attribute = Column(Integer) 40 | ``` 41 | 42 | Now we can specify the field mappings to be used when applying the filters: 43 | 44 | ```python 45 | FIELD_MAPPING = { 46 | "identifier": Record.identifier, 47 | "geometry": Record.geometry, 48 | "floatAttribute": Record.float_attribute, 49 | "intAttribute": Record.int_attribute, 50 | "strAttribute": Record.str_attribute, 51 | "datetimeAttribute": Record.datetime_attribute, 52 | "choiceAttribute": Record.choice_attribute, 53 | # meta fields 54 | "floatMetaAttribute": RecordMeta.float_meta_attribute, 55 | "intMetaAttribute": RecordMeta.int_meta_attribute, 56 | "strMetaAttribute": RecordMeta.str_meta_attribute, 57 | "datetimeMetaAttribute": RecordMeta.datetime_meta_attribute, 58 | "choiceMetaAttribute": RecordMeta.choice_meta_attribute, 59 | } 60 | ``` 61 | 62 | Finally we are able to connect the CQL AST to the SQLAlchemy database models. We also provide factory 63 | functions to parse the timestamps, durations, geometries and envelopes, so that they can be used 64 | with the ORM layer: 65 | 66 | ```python 67 | from pygeofilter.integrations.sqlalchemy import to_filter, parse 68 | 69 | cql_expr = 'strMetaAttribute LIKE "%parent%" AND datetimeAttribute BEFORE 2000-01-01T00:00:01Z' 70 | 71 | # NOTE: we are using the sqlalchemy integration `parse` wrapper here 72 | ast = parse(cql_expr) 73 | print(ast) 74 | filters = to_filter(ast, FIELD_MAPPING) 75 | 76 | q = session.query(Record).join(RecordMeta).filter(filters) 77 | ``` 78 | 79 | ## Tests 80 | Tests for the sqlalchemy integration can be run as following: 81 | 82 | ```shell 83 | python -m unittest discover tests/sqlalchemy_test/ tests.py 84 | ``` 85 | -------------------------------------------------------------------------------- /pygeofilter/backends/sqlalchemy/__init__.py: -------------------------------------------------------------------------------- 1 | from .evaluate import to_filter 2 | 3 | __all__ = ["to_filter"] 4 | -------------------------------------------------------------------------------- /pygeofilter/backends/sqlalchemy/evaluate.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime, time, timedelta 2 | 3 | from ... import ast, values 4 | from ..evaluator import Evaluator, handle 5 | from . import filters 6 | 7 | LITERALS = (str, float, int, bool, datetime, date, time, timedelta) 8 | 9 | 10 | class SQLAlchemyFilterEvaluator(Evaluator): 11 | def __init__(self, field_mapping, undefined_as_null): 12 | self.field_mapping = field_mapping 13 | self.undefined_as_null = undefined_as_null 14 | 15 | @handle(ast.Not) 16 | def not_(self, node, sub): 17 | return filters.negate(sub) 18 | 19 | @handle(ast.And, ast.Or) 20 | def combination(self, node, lhs, rhs): 21 | return filters.combine((lhs, rhs), node.op.value) 22 | 23 | @handle(ast.Comparison, subclasses=True) 24 | def comparison(self, node, lhs, rhs): 25 | return filters.runop( 26 | lhs, 27 | rhs, 28 | node.op.value, 29 | ) 30 | 31 | @handle(ast.Between) 32 | def between(self, node, lhs, low, high): 33 | return filters.between(lhs, low, high, node.not_) 34 | 35 | @handle(ast.Like) 36 | def like(self, node, lhs): 37 | return filters.like( 38 | lhs, 39 | node.pattern, 40 | not node.nocase, 41 | node.not_, 42 | ) 43 | 44 | @handle(ast.In) 45 | def in_(self, node, lhs, *options): 46 | return filters.runop( 47 | lhs, 48 | options, 49 | "in", 50 | node.not_, 51 | ) 52 | 53 | @handle(ast.IsNull) 54 | def null(self, node, lhs): 55 | return filters.runop(lhs, None, "is_null", node.not_) 56 | 57 | # @handle(ast.ExistsPredicateNode) 58 | # def exists(self, node, lhs): 59 | # if self.use_getattr: 60 | # result = hasattr(self.obj, node.lhs.name) 61 | # else: 62 | # result = lhs in self.obj 63 | 64 | # if node.not_: 65 | # result = not result 66 | # return result 67 | 68 | @handle(ast.TemporalPredicate, subclasses=True) 69 | def temporal(self, node, lhs, rhs): 70 | return filters.temporal( 71 | lhs, 72 | rhs, 73 | node.op.value, 74 | ) 75 | 76 | @handle(ast.SpatialComparisonPredicate, subclasses=True) 77 | def spatial_operation(self, node, lhs, rhs): 78 | return filters.spatial( 79 | lhs, 80 | rhs, 81 | node.op.name, 82 | ) 83 | 84 | @handle(ast.Relate) 85 | def spatial_pattern(self, node, lhs, rhs): 86 | return filters.spatial( 87 | lhs, 88 | rhs, 89 | "RELATE", 90 | pattern=node.pattern, 91 | ) 92 | 93 | @handle(ast.SpatialDistancePredicate, subclasses=True) 94 | def spatial_distance(self, node, lhs, rhs): 95 | return filters.spatial( 96 | lhs, 97 | rhs, 98 | node.op.value, 99 | distance=node.distance, 100 | units=node.units, 101 | ) 102 | 103 | @handle(ast.BBox) 104 | def bbox(self, node, lhs): 105 | return filters.bbox(lhs, node.minx, node.miny, node.maxx, node.maxy, node.crs) 106 | 107 | @handle(ast.Attribute) 108 | def attribute(self, node): 109 | return filters.attribute(node.name, self.field_mapping, self.undefined_as_null) 110 | 111 | @handle(ast.Arithmetic, subclasses=True) 112 | def arithmetic(self, node, lhs, rhs): 113 | return filters.runop(lhs, rhs, node.op.value) 114 | 115 | # TODO: map functions 116 | # @handle(ast.FunctionExpressionNode) 117 | # def function(self, node, *arguments): 118 | # return self.function_map[node.name](*arguments) 119 | 120 | @handle(*values.LITERALS) 121 | def literal(self, node): 122 | return filters.literal(node) 123 | 124 | @handle(values.Interval) 125 | def interval(self, node, start, end): 126 | return filters.literal((start, end)) 127 | 128 | @handle(values.Geometry) 129 | def geometry(self, node): 130 | return filters.parse_geometry(node.__geo_interface__) 131 | 132 | @handle(values.Envelope) 133 | def envelope(self, node): 134 | return filters.parse_bbox([node.x1, node.y1, node.x2, node.y2]) 135 | 136 | 137 | def to_filter(ast, field_mapping={}, undefined_as_null=None): 138 | """Helper function to translate ECQL AST to SQLAlchemy Query expressions. 139 | 140 | :param ast: the abstract syntax tree 141 | :param field_mapping: a dict mapping from the filter name to the SQLAlchemy 142 | field lookup. 143 | :param undefined_as_null: whether a name not present in field_mapping 144 | should evaluate to null. 145 | :type ast: :class:`Node` 146 | :returns: a SQLAlchemy query object 147 | """ 148 | return SQLAlchemyFilterEvaluator(field_mapping, undefined_as_null).evaluate(ast) 149 | -------------------------------------------------------------------------------- /pygeofilter/cli.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pygeofilter 4 | # Authors: Tom Kralidis 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2025 Tom Kralidis 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in 17 | # all copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | import logging 29 | import sys 30 | 31 | import click 32 | 33 | from .parsers.cql_json.parser import parse as parse_cql_json 34 | from .parsers.cql2_json.parser import parse as parse_cql2_json 35 | from .parsers.cql2_text.parser import parse as parse_cql2_text 36 | from .parsers.ecql.parser import parse as parse_ecql 37 | from .parsers.fes.parser import parse as parse_fes 38 | from .parsers.jfe.parser import parse as parse_jfe 39 | from .version import __version__ 40 | 41 | __all__ = ["__version__"] 42 | 43 | PARSERS = { 44 | 'cql_json': parse_cql_json, 45 | 'cql2_json': parse_cql2_json, 46 | 'cql2_text': parse_cql2_text, 47 | 'ecql': parse_ecql, 48 | 'fes': parse_fes, 49 | 'jfe': parse_jfe 50 | } 51 | 52 | 53 | def CLI_OPTION_VERBOSITY(f): 54 | """Setup click logging output""" 55 | def callback(ctx, param, value): 56 | if value is not None: 57 | logging.basicConfig(stream=sys.stdout, 58 | level=getattr(logging, value)) 59 | return True 60 | 61 | return click.option('--verbosity', '-v', 62 | type=click.Choice(['ERROR', 'WARNING', 'INFO', 'DEBUG']), 63 | help='Verbosity', 64 | callback=callback)(f) 65 | 66 | 67 | @click.group() 68 | @click.version_option(version=__version__) 69 | def cli(): 70 | pass 71 | 72 | 73 | @cli.command() 74 | @click.pass_context 75 | @click.argument('parser', type=click.Choice(PARSERS.keys())) 76 | @click.argument('query') 77 | @CLI_OPTION_VERBOSITY 78 | def parse(ctx, parser, query, verbosity): 79 | """Parse a query into an abstract syntax tree""" 80 | 81 | click.echo(f'Parsing {parser} query into AST') 82 | try: 83 | click.echo(PARSERS[parser](query)) 84 | except Exception as err: 85 | raise click.ClickException(err) 86 | 87 | 88 | cli.add_command(parse) 89 | -------------------------------------------------------------------------------- /pygeofilter/cql2.py: -------------------------------------------------------------------------------- 1 | # Common configurations for cql2 parsers and evaluators. 2 | from typing import Dict, Type, Union 3 | 4 | from . import ast 5 | 6 | # https://github.com/opengeospatial/ogcapi-features/tree/master/cql2 7 | 8 | 9 | COMPARISON_MAP: Dict[str, Type[ast.Node]] = { 10 | "=": ast.Equal, 11 | "eq": ast.Equal, 12 | "<>": ast.NotEqual, 13 | "!=": ast.NotEqual, 14 | "ne": ast.NotEqual, 15 | "<": ast.LessThan, 16 | "lt": ast.LessThan, 17 | "<=": ast.LessEqual, 18 | "lte": ast.LessEqual, 19 | ">": ast.GreaterThan, 20 | "gt": ast.GreaterThan, 21 | ">=": ast.GreaterEqual, 22 | "gte": ast.GreaterEqual, 23 | "like": ast.Like, 24 | } 25 | 26 | SPATIAL_PREDICATES_MAP: Dict[str, Type[ast.SpatialComparisonPredicate]] = { 27 | "s_intersects": ast.GeometryIntersects, 28 | "s_equals": ast.GeometryEquals, 29 | "s_disjoint": ast.GeometryDisjoint, 30 | "s_touches": ast.GeometryTouches, 31 | "s_within": ast.GeometryWithin, 32 | "s_overlaps": ast.GeometryOverlaps, 33 | "s_crosses": ast.GeometryCrosses, 34 | "s_contains": ast.GeometryContains, 35 | } 36 | 37 | TEMPORAL_PREDICATES_MAP: Dict[str, Type[ast.TemporalPredicate]] = { 38 | "t_before": ast.TimeBefore, 39 | "t_after": ast.TimeAfter, 40 | "t_meets": ast.TimeMeets, 41 | "t_metby": ast.TimeMetBy, 42 | "t_overlaps": ast.TimeOverlaps, 43 | "t_overlappedby": ast.TimeOverlappedBy, 44 | "t_begins": ast.TimeBegins, 45 | "t_begunby": ast.TimeBegunBy, 46 | "t_during": ast.TimeDuring, 47 | "t_contains": ast.TimeContains, 48 | "t_ends": ast.TimeEnds, 49 | "t_endedby": ast.TimeEndedBy, 50 | "t_equals": ast.TimeEquals, 51 | "t_intersects": ast.TimeOverlaps, 52 | } 53 | 54 | 55 | ARRAY_PREDICATES_MAP: Dict[str, Type[ast.ArrayPredicate]] = { 56 | "a_equals": ast.ArrayEquals, 57 | "a_contains": ast.ArrayContains, 58 | "a_containedby": ast.ArrayContainedBy, 59 | "a_overlaps": ast.ArrayOverlaps, 60 | } 61 | 62 | ARITHMETIC_MAP: Dict[str, Type[ast.Arithmetic]] = { 63 | "+": ast.Add, 64 | "-": ast.Sub, 65 | "*": ast.Mul, 66 | "/": ast.Div, 67 | } 68 | 69 | CONDITION_MAP: Dict[str, Type[ast.Node]] = { 70 | "and": ast.And, 71 | "or": ast.Or, 72 | "not": ast.Not, 73 | "isNull": ast.IsNull, 74 | } 75 | 76 | BINARY_OP_PREDICATES_MAP: Dict[ 77 | str, 78 | Union[ 79 | Type[ast.Node], 80 | Type[ast.Comparison], 81 | Type[ast.SpatialComparisonPredicate], 82 | Type[ast.TemporalPredicate], 83 | Type[ast.ArrayPredicate], 84 | Type[ast.Arithmetic], 85 | ], 86 | ] = { 87 | **COMPARISON_MAP, 88 | **SPATIAL_PREDICATES_MAP, 89 | **TEMPORAL_PREDICATES_MAP, 90 | **ARRAY_PREDICATES_MAP, 91 | **ARITHMETIC_MAP, 92 | **CONDITION_MAP, 93 | } 94 | 95 | 96 | def get_op(node: ast.Node) -> Union[str, None]: 97 | # Get the cql2 operator string from a node. 98 | for k, v in BINARY_OP_PREDICATES_MAP.items(): 99 | if isinstance(node, v): 100 | return k 101 | return None 102 | -------------------------------------------------------------------------------- /pygeofilter/parsers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pygeofilter/a0b1f158111569032666df6ed459c1b19f35b5b7/pygeofilter/parsers/__init__.py -------------------------------------------------------------------------------- /pygeofilter/parsers/cql2_json/__init__.py: -------------------------------------------------------------------------------- 1 | from .parser import parse 2 | 3 | __all__ = ["parse"] 4 | -------------------------------------------------------------------------------- /pygeofilter/parsers/cql2_json/parser.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pygeofilter 4 | # Authors: Fabian Schindler , 5 | # David Bitner 6 | # 7 | # ------------------------------------------------------------------------------ 8 | # Copyright (C) 2021 EOX IT Services GmbH 9 | # 10 | # Permission is hereby granted, free of charge, to any person obtaining a copy 11 | # of this software and associated documentation files (the "Software"), to deal 12 | # in the Software without restriction, including without limitation the rights 13 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | # copies of the Software, and to permit persons to whom the Software is 15 | # furnished to do so, subject to the following conditions: 16 | # 17 | # The above copyright notice and this permission notice shall be included in 18 | # all copies of this Software or works derived from this Software. 19 | # 20 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 26 | # THE SOFTWARE. 27 | # ------------------------------------------------------------------------------ 28 | 29 | import json 30 | from datetime import date, datetime, timedelta 31 | from typing import List, Union, cast 32 | 33 | from ... import ast, values 34 | from ...cql2 import BINARY_OP_PREDICATES_MAP 35 | from ...util import parse_date, parse_datetime, parse_duration 36 | 37 | # https://github.com/opengeospatial/ogcapi-features/tree/master/cql2 38 | 39 | 40 | JsonType = Union[dict, list, str, float, int, bool, None] 41 | 42 | 43 | def walk_cql_json(node: JsonType): # noqa: C901 44 | if isinstance( 45 | node, 46 | ( 47 | str, 48 | float, 49 | int, 50 | bool, 51 | datetime, 52 | values.Geometry, 53 | values.Interval, 54 | ast.Node, 55 | ), 56 | ): 57 | return node 58 | 59 | if isinstance(node, list): 60 | return [walk_cql_json(sub_node) for sub_node in node] 61 | 62 | if not isinstance(node, dict): 63 | raise ValueError(f"Invalid type {type(node)}") 64 | 65 | if "filter-lang" in node and node["filter-lang"] != "cql2-json": 66 | raise Exception(f"Cannot parse {node['filter-lang']} with cql2-json.") 67 | 68 | elif "filter" in node: 69 | return walk_cql_json(node["filter"]) 70 | 71 | # check if we are dealing with a geometry 72 | if "type" in node and "coordinates" in node: 73 | # TODO: test if node is actually valid 74 | return values.Geometry(node) 75 | 76 | elif "bbox" in node: 77 | return values.Envelope(*node["bbox"]) 78 | 79 | elif "date" in node: 80 | return parse_date(node["date"]) 81 | 82 | elif "timestamp" in node: 83 | return parse_datetime(node["timestamp"]) 84 | 85 | elif "interval" in node: 86 | parsed: List[Union[date, datetime, timedelta, None]] = [] 87 | for value in node["interval"]: 88 | if value == "..": 89 | parsed.append(None) 90 | continue 91 | try: 92 | parsed.append(parse_date(value)) 93 | except ValueError: 94 | try: 95 | parsed.append(parse_duration(value)) 96 | except ValueError: 97 | parsed.append(parse_datetime(value)) 98 | 99 | return values.Interval(*parsed) 100 | 101 | elif "property" in node: 102 | return ast.Attribute(node["property"]) 103 | 104 | elif "function" in node: 105 | return ast.Function( 106 | node["function"]["name"], 107 | cast(List[ast.AstType], walk_cql_json(node["function"]["arguments"])), 108 | ) 109 | 110 | elif "lower" in node: 111 | return ast.Function("lower", [cast(ast.Node, walk_cql_json(node["lower"]))]) 112 | 113 | elif "op" in node: 114 | op = node["op"] 115 | args = walk_cql_json(node["args"]) 116 | 117 | if op in ("and", "or"): 118 | return (ast.And if op == "and" else ast.Or).from_items(*args) 119 | 120 | elif op == "not": 121 | # allow both arrays and objects, the standard is ambigous in 122 | # that regard 123 | if isinstance(args, list): 124 | args = args[0] 125 | return ast.Not(cast(ast.Node, walk_cql_json(args))) 126 | 127 | elif op == "isNull": 128 | # like with "not", allow both arrays and objects 129 | if isinstance(args, list): 130 | args = args[0] 131 | return ast.IsNull(cast(ast.Node, walk_cql_json(args)), not_=False) 132 | 133 | elif op == "between": 134 | return ast.Between( 135 | cast(ast.Node, walk_cql_json(args[0])), 136 | cast(ast.ScalarAstType, walk_cql_json(args[1][0])), 137 | cast(ast.ScalarAstType, walk_cql_json(args[1][1])), 138 | not_=False, 139 | ) 140 | 141 | elif op == "like": 142 | return ast.Like( 143 | cast(ast.Node, walk_cql_json(args[0])), 144 | pattern=cast(str, args[1]), 145 | nocase=False, 146 | wildcard="%", 147 | singlechar=".", 148 | escapechar="\\", 149 | not_=False, 150 | ) 151 | 152 | elif op == "in": 153 | return ast.In( 154 | cast(ast.AstType, walk_cql_json(args[0])), 155 | cast(List[ast.AstType], walk_cql_json(args[1])), 156 | not_=False, 157 | ) 158 | 159 | elif op in BINARY_OP_PREDICATES_MAP: 160 | args = [cast(ast.Node, walk_cql_json(arg)) for arg in args] 161 | return BINARY_OP_PREDICATES_MAP[op](*args) 162 | 163 | raise ValueError(f"Unable to parse expression node {node!r}") 164 | 165 | 166 | def parse(cql: Union[str, dict]) -> ast.AstType: 167 | if isinstance(cql, str): 168 | root = json.loads(cql) 169 | else: 170 | root = cql 171 | 172 | return walk_cql_json(root) 173 | -------------------------------------------------------------------------------- /pygeofilter/parsers/cql2_text/__init__.py: -------------------------------------------------------------------------------- 1 | from .parser import parse 2 | 3 | __all__ = ["parse"] 4 | -------------------------------------------------------------------------------- /pygeofilter/parsers/cql2_text/grammar.lark: -------------------------------------------------------------------------------- 1 | // ------------------------------------------------------------------------------ 2 | // 3 | // Project: pygeofilter 4 | // Authors: Fabian Schindler , David Bitner 5 | // 6 | // ------------------------------------------------------------------------------ 7 | // Copyright (C) 2021 EOX IT Services GmbH 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in 17 | // all copies of this Software or works derived from this Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | // THE SOFTWARE. 26 | // ------------------------------------------------------------------------------ 27 | 28 | ?start: condition 29 | 30 | ?condition: condition_1 31 | | condition "AND"i condition_1 -> and_ 32 | | condition "OR"i condition_1 -> or_ 33 | 34 | ?condition_1: predicate 35 | | "NOT"i predicate -> not_ 36 | | "(" condition ")" 37 | 38 | 39 | ?predicate: expression "=" expression -> eq 40 | | expression "eq"i expression -> eq 41 | | expression "<>" expression -> ne 42 | | expression "ne"i expression -> ne 43 | | expression "!=" expression -> ne 44 | | expression "<" expression -> lt 45 | | expression "lt"i expression -> lt 46 | | expression "<=" expression -> lte 47 | | expression "lte"i expression -> lte 48 | | expression ">" expression -> gt 49 | | expression "gt"i expression -> gt 50 | | expression ">=" expression -> gte 51 | | expression "gte"i expression -> gte 52 | | expression "BETWEEN"i expression "AND"i expression -> between 53 | | expression "LIKE"i SINGLE_QUOTED -> like 54 | | expression "IN"i "(" expression ( "," expression )* ")" -> in_ 55 | | expression "IS"i "NULL"i -> null 56 | | expression "IS"i "NOT"i "NULL"i -> not_null 57 | | "INCLUDE"i -> include 58 | | "EXCLUDE"i -> exclude 59 | | spatial_predicate 60 | | temporal_predicate 61 | 62 | 63 | 64 | ?temporal_predicate: expression _binary_temporal_predicate_func expression -> binary_temporal_predicate 65 | 66 | !_binary_temporal_predicate_func: "T_BEFORE"i 67 | | "T_AFTER"i 68 | | "T_MEETS"i 69 | | "T_METBY"i 70 | | "T_OVERLAPS"i 71 | | "T_OVERLAPPEDBY"i 72 | | "T_BEGINS"i 73 | | "T_BEGUNBY"i 74 | | "T_DURING"i 75 | | "T_CONTAINS"i 76 | | "T_ENDS"i 77 | | "T_ENDEDBY"i 78 | | "T_EQUALS"i 79 | | "T_INTERSECTS"i 80 | 81 | 82 | ?spatial_predicate: _binary_spatial_predicate_func "(" expression "," expression ")" -> binary_spatial_predicate 83 | | "RELATE" "(" expression "," expression "," SINGLE_QUOTED ")" -> relate_spatial_predicate 84 | | "BBOX" "(" expression "," full_number "," full_number "," full_number "," full_number [ "," SINGLE_QUOTED] ")" -> bbox_spatial_predicate 85 | 86 | !_binary_spatial_predicate_func: "S_INTERSECTS"i 87 | | "S_DISJOINT"i 88 | | "S_CONTAINS"i 89 | | "S_WITHIN"i 90 | | "S_TOUCHES"i 91 | | "S_CROSSES"i 92 | | "S_OVERLAPS"i 93 | | "S_EQUALS"i 94 | 95 | 96 | ?expression: sum 97 | 98 | ?sum: product 99 | | sum "+" product -> add 100 | | sum "-" product -> sub 101 | 102 | ?product: atom 103 | | product "*" atom -> mul 104 | | product "/" atom -> div 105 | 106 | ?atom: func 107 | | attribute 108 | | literal 109 | | "-" atom -> neg 110 | | "(" expression ")" 111 | 112 | func.2: attribute "(" expression ("," expression)* ")" -> function 113 | 114 | 115 | ?literal: timestamp 116 | | interval 117 | | number 118 | | BOOLEAN 119 | | SINGLE_QUOTED 120 | | ewkt_geometry -> geometry 121 | | envelope 122 | 123 | ?full_number: number 124 | | "-" number -> neg 125 | 126 | ?number: FLOAT | INT 127 | 128 | envelope: "ENVELOPE"i "(" number number number number ")" 129 | 130 | BOOLEAN.2: ( "TRUE"i | "FALSE"i) 131 | 132 | DOUBLE_QUOTED: "\"" /.*?/ "\"" 133 | SINGLE_QUOTED: "'" /.*?/ "'" 134 | 135 | DATETIME: /[0-9]{4}-?[0-1][0-9]-?[0-3][0-9][T ][0-2][0-9]:?[0-5][0-9]:?[0-5][0-9](\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})?/ 136 | ?timestamp: "TIMESTAMP" "(" "'" DATETIME "'" ")" 137 | ?interval: "INTERVAL" "(" "'" DATETIME "'" "," "'" DATETIME "'" ")" 138 | 139 | 140 | 141 | attribute: /[a-zA-Z][a-zA-Z_:0-9.]+/ 142 | | DOUBLE_QUOTED 143 | 144 | 145 | // NAME: /[a-z_]+/ 146 | %import .wkt.ewkt_geometry 147 | 148 | // %import common.CNAME -> NAME 149 | %import common.INT 150 | %import common.FLOAT 151 | %import common.WS_INLINE 152 | %ignore WS_INLINE 153 | -------------------------------------------------------------------------------- /pygeofilter/parsers/cql_json/__init__.py: -------------------------------------------------------------------------------- 1 | from .parser import parse 2 | 3 | __all__ = ["parse"] 4 | -------------------------------------------------------------------------------- /pygeofilter/parsers/ecql/__init__.py: -------------------------------------------------------------------------------- 1 | from .parser import parse 2 | 3 | __all__ = ["parse"] 4 | -------------------------------------------------------------------------------- /pygeofilter/parsers/ecql/grammar.lark: -------------------------------------------------------------------------------- 1 | // ------------------------------------------------------------------------------ 2 | // 3 | // Project: pygeofilter 4 | // Authors: Fabian Schindler 5 | // 6 | // ------------------------------------------------------------------------------ 7 | // Copyright (C) 2021 EOX IT Services GmbH 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in 17 | // all copies of this Software or works derived from this Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | // THE SOFTWARE. 26 | // ------------------------------------------------------------------------------ 27 | 28 | ?start: condition 29 | 30 | ?condition: condition_1 31 | | condition "AND" condition_1 -> and_ 32 | | condition "OR" condition_1 -> or_ 33 | 34 | ?condition_1: predicate 35 | | "NOT" predicate -> not_ 36 | | "(" condition ")" 37 | 38 | ?predicate: expression "=" expression -> eq 39 | | expression "<>" expression -> ne 40 | | expression "<" expression -> lt 41 | | expression "<=" expression -> lte 42 | | expression ">" expression -> gt 43 | | expression ">=" expression -> gte 44 | | expression "BETWEEN" expression "AND" expression -> between 45 | | expression "NOT" "BETWEEN" expression "AND" expression -> not_between 46 | | expression "LIKE" SINGLE_QUOTED -> like 47 | | expression "NOT" "LIKE" SINGLE_QUOTED -> not_like 48 | | expression "ILIKE" SINGLE_QUOTED -> ilike 49 | | expression "NOT" "ILIKE" SINGLE_QUOTED -> not_ilike 50 | | expression "IN" "(" expression ( "," expression )* ")" -> in_ 51 | | expression "NOT" "IN" "(" expression ( "," expression )* ")" -> not_in 52 | | expression "IS" "NULL" -> null 53 | | expression "IS" "NOT" "NULL" -> not_null 54 | | attribute "EXISTS" -> exists 55 | | attribute "DOES-NOT-EXIST" -> does_not_exist 56 | | "INCLUDE" -> include 57 | | "EXCLUDE" -> exclude 58 | | temporal_predicate 59 | | spatial_predicate 60 | 61 | ?temporal_predicate: expression "BEFORE" DATETIME -> before 62 | | expression "BEFORE" "OR" "DURING" period -> before_or_during 63 | | expression "DURING" period -> during 64 | | expression "DURING" "OR" "AFTER" period -> during_or_after 65 | | expression "AFTER" DATETIME -> after 66 | 67 | ?spatial_predicate: _binary_spatial_predicate_func "(" expression "," expression ")" -> binary_spatial_predicate 68 | | "RELATE" "(" expression "," expression "," SINGLE_QUOTED ")" -> relate_spatial_predicate 69 | | _distance_spatial_predicate_func "(" expression "," expression "," number "," distance_units ")" -> distance_spatial_predicate 70 | | "BBOX" "(" expression "," full_number "," full_number "," full_number "," full_number [ "," SINGLE_QUOTED] ")" -> bbox_spatial_predicate 71 | !_binary_spatial_predicate_func: "INTERSECTS" | "DISJOINT" | "CONTAINS" | "WITHIN" | "TOUCHES" | "CROSSES" | "OVERLAPS" | "EQUALS" 72 | !_distance_spatial_predicate_func: "DWITHIN" | "BEYOND" 73 | !distance_units: "feet" | "meters" | "statute miles" | "nautical miles" | "kilometers" -> distance_units 74 | 75 | ?expression: sum 76 | 77 | ?sum: product 78 | | sum "+" product -> add 79 | | sum "-" product -> sub 80 | 81 | ?product: atom 82 | | product "*" atom -> mul 83 | | product "/" atom -> div 84 | 85 | ?atom: attribute 86 | | literal 87 | | "-" atom -> neg 88 | | NAME "(" [ expression ("," expression)* ] ")" -> function 89 | | "(" expression ")" 90 | 91 | attribute: NAME 92 | | DOUBLE_QUOTED 93 | | QUALIFIED_NAME 94 | 95 | ?literal: number 96 | | BOOLEAN 97 | | SINGLE_QUOTED 98 | | ewkt_geometry -> geometry 99 | | envelope 100 | 101 | ?full_number: number 102 | | "-" number -> neg 103 | 104 | ?number: FLOAT | INT 105 | 106 | period: DATETIME "/" DATETIME 107 | | DURATION "/" DATETIME 108 | | DATETIME "/" DURATION 109 | 110 | envelope: "ENVELOPE" "(" number number number number ")" 111 | 112 | BOOLEAN.2: ( "TRUE"i | "FALSE"i ) 113 | 114 | DOUBLE_QUOTED: "\"" /.*?/ "\"" 115 | SINGLE_QUOTED: "'" /.*?/ "'" 116 | 117 | QUALIFIED_NAME: (NAME ("." | ":"))+ NAME 118 | 119 | %import .wkt.ewkt_geometry 120 | %import .iso8601.DATETIME 121 | %import .iso8601.DURATION 122 | %import common.CNAME -> NAME 123 | %import common.INT 124 | %import common.FLOAT 125 | %import common.WS_INLINE 126 | %ignore WS_INLINE 127 | -------------------------------------------------------------------------------- /pygeofilter/parsers/ecql/parser.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pygeofilter 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2021 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in 17 | # all copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | 27 | import logging 28 | import os.path 29 | 30 | from lark import Lark, logger, v_args 31 | 32 | from ... import ast, values 33 | from ..iso8601 import ISO8601Transformer 34 | from ..wkt import WKTTransformer 35 | 36 | logger.setLevel(logging.DEBUG) 37 | 38 | 39 | SPATIAL_PREDICATES_MAP = { 40 | "INTERSECTS": ast.GeometryIntersects, 41 | "DISJOINT": ast.GeometryDisjoint, 42 | "CONTAINS": ast.GeometryContains, 43 | "WITHIN": ast.GeometryWithin, 44 | "TOUCHES": ast.GeometryTouches, 45 | "CROSSES": ast.GeometryCrosses, 46 | "OVERLAPS": ast.GeometryOverlaps, 47 | "EQUALS": ast.GeometryEquals, 48 | } 49 | 50 | 51 | @v_args(meta=False, inline=True) 52 | class ECQLTransformer(WKTTransformer, ISO8601Transformer): 53 | def and_(self, lhs, rhs): 54 | return ast.And(lhs, rhs) 55 | 56 | def or_(self, lhs, rhs): 57 | return ast.Or(lhs, rhs) 58 | 59 | def not_(self, node): 60 | return ast.Not(node) 61 | 62 | def eq(self, lhs, rhs): 63 | return ast.Equal(lhs, rhs) 64 | 65 | def ne(self, lhs, rhs): 66 | return ast.NotEqual(lhs, rhs) 67 | 68 | def lt(self, lhs, rhs): 69 | return ast.LessThan(lhs, rhs) 70 | 71 | def lte(self, lhs, rhs): 72 | return ast.LessEqual(lhs, rhs) 73 | 74 | def gt(self, lhs, rhs): 75 | return ast.GreaterThan(lhs, rhs) 76 | 77 | def gte(self, lhs, rhs): 78 | return ast.GreaterEqual(lhs, rhs) 79 | 80 | def between(self, lhs, low, high): 81 | return ast.Between(lhs, low, high, False) 82 | 83 | def not_between(self, lhs, low, high): 84 | return ast.Between(lhs, low, high, True) 85 | 86 | def like(self, node, pattern): 87 | return ast.Like(node, pattern, False, "%", ".", "\\", False) 88 | 89 | def not_like(self, node, pattern): 90 | return ast.Like(node, pattern, False, "%", ".", "\\", True) 91 | 92 | def ilike(self, node, pattern): 93 | return ast.Like(node, pattern, True, "%", ".", "\\", False) 94 | 95 | def not_ilike(self, node, pattern): 96 | return ast.Like(node, pattern, True, "%", ".", "\\", True) 97 | 98 | def in_(self, node, *options): 99 | return ast.In(node, list(options), False) 100 | 101 | def not_in(self, node, *options): 102 | return ast.In(node, list(options), True) 103 | 104 | def null(self, node): 105 | return ast.IsNull(node, False) 106 | 107 | def not_null(self, node): 108 | return ast.IsNull(node, True) 109 | 110 | def exists(self, attribute): 111 | return ast.Exists(attribute, False) 112 | 113 | def does_not_exist(self, attribute): 114 | return ast.Exists(attribute, True) 115 | 116 | def include(self): 117 | return ast.Include(False) 118 | 119 | def exclude(self): 120 | return ast.Include(True) 121 | 122 | def before(self, node, dt): 123 | return ast.TimeBefore(node, dt) 124 | 125 | def before_or_during(self, node, period): 126 | return ast.TimeBeforeOrDuring(node, period) 127 | 128 | def during(self, node, period): 129 | return ast.TimeDuring(node, period) 130 | 131 | def during_or_after(self, node, period): 132 | return ast.TimeDuringOrAfter(node, period) 133 | 134 | def after(self, node, dt): 135 | return ast.TimeAfter(node, dt) 136 | 137 | def binary_spatial_predicate(self, op, lhs, rhs): 138 | return SPATIAL_PREDICATES_MAP[op](lhs, rhs) 139 | 140 | def relate_spatial_predicate(self, lhs, rhs, pattern): 141 | return ast.Relate(lhs, rhs, pattern) 142 | 143 | def distance_spatial_predicate(self, op, lhs, rhs, distance, units): 144 | cls = ast.DistanceWithin if op == "DWITHIN" else ast.DistanceBeyond 145 | return cls(lhs, rhs, distance, units) 146 | 147 | def distance_units(self, value): 148 | return value 149 | 150 | def bbox_spatial_predicate(self, lhs, minx, miny, maxx, maxy, crs=None): 151 | return ast.BBox(lhs, minx, miny, maxx, maxy, crs) 152 | 153 | def function(self, func_name, *expressions): 154 | return ast.Function(str(func_name), list(expressions)) 155 | 156 | def add(self, lhs, rhs): 157 | return ast.Add(lhs, rhs) 158 | 159 | def sub(self, lhs, rhs): 160 | return ast.Sub(lhs, rhs) 161 | 162 | def mul(self, lhs, rhs): 163 | return ast.Mul(lhs, rhs) 164 | 165 | def div(self, lhs, rhs): 166 | return ast.Div(lhs, rhs) 167 | 168 | def neg(self, value): 169 | return -value 170 | 171 | def attribute(self, name): 172 | return ast.Attribute(str(name)) 173 | 174 | def period(self, start, end): 175 | return values.Interval(start, end) 176 | 177 | def INT(self, value): 178 | return int(value) 179 | 180 | def FLOAT(self, value): 181 | return float(value) 182 | 183 | def BOOLEAN(self, value): 184 | return value.lower() == "true" 185 | 186 | def DOUBLE_QUOTED(self, token): 187 | return token[1:-1] 188 | 189 | def SINGLE_QUOTED(self, token): 190 | return token[1:-1] 191 | 192 | def geometry(self, value): 193 | return values.Geometry(value) 194 | 195 | def envelope(self, x1, x2, y1, y2): 196 | return values.Envelope(x1, x2, y1, y2) 197 | 198 | 199 | parser = Lark.open( 200 | "grammar.lark", 201 | rel_to=__file__, 202 | parser="lalr", 203 | debug=True, 204 | maybe_placeholders=False, 205 | transformer=ECQLTransformer(), 206 | import_paths=[os.path.dirname(os.path.dirname(__file__))], 207 | ) 208 | 209 | 210 | def parse(cql_text): 211 | return parser.parse(cql_text) 212 | -------------------------------------------------------------------------------- /pygeofilter/parsers/fes/__init__.py: -------------------------------------------------------------------------------- 1 | from .parser import parse 2 | 3 | __all__ = ["parse"] 4 | -------------------------------------------------------------------------------- /pygeofilter/parsers/fes/gml.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime, timedelta 2 | from typing import Dict, Union 3 | 4 | from lxml import etree 5 | 6 | from ... import values 7 | from ...util import parse_datetime, parse_duration 8 | from .util import Element 9 | 10 | Temporal = Union[date, datetime, timedelta, values.Interval] 11 | 12 | 13 | def _parse_time_position(node: Element, nsmap: Dict[str, str]) -> datetime: 14 | return parse_datetime(node.text) 15 | 16 | 17 | def _parse_time_instant(node: Element, nsmap: Dict[str, str]) -> datetime: 18 | position = node.xpath("gml:timePosition", namespaces=nsmap)[0] 19 | return _parse_time_position(position, nsmap) 20 | 21 | 22 | def _parse_time_period(node: Element, nsmap: Dict[str, str]) -> values.Interval: 23 | begin = node.xpath( 24 | "gml:begin/gml:TimeInstant/gml:timePosition|gml:beginPosition", namespaces=nsmap 25 | )[0] 26 | end = node.xpath( 27 | "gml:end/gml:TimeInstant/gml:timePosition|gml:endPosition", namespaces=nsmap 28 | )[0] 29 | return values.Interval( 30 | _parse_time_position(begin, nsmap), 31 | _parse_time_position(end, nsmap), 32 | ) 33 | 34 | 35 | def _parse_valid_time(node: Element, nsmap: Dict[str, str]) -> Temporal: 36 | return parse_temporal(node[0], nsmap) 37 | 38 | 39 | def _parse_duration(node: Element, nsmap: Dict[str, str]) -> timedelta: 40 | return parse_duration(node.text) 41 | 42 | 43 | PARSER_MAP = { 44 | "validTime": _parse_valid_time, 45 | "timePosition": _parse_time_position, 46 | "TimeInstant": _parse_time_instant, 47 | "TimePeriod": _parse_time_period, 48 | "duration": _parse_duration, 49 | } 50 | 51 | 52 | def is_temporal(node: Element) -> bool: 53 | return etree.QName(node).localname in PARSER_MAP 54 | 55 | 56 | def parse_temporal(node: Element, nsmap: Dict[str, str]) -> Temporal: 57 | parser = PARSER_MAP[etree.QName(node).localname] 58 | return parser(node, nsmap) 59 | -------------------------------------------------------------------------------- /pygeofilter/parsers/fes/parser.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from lxml import etree 4 | 5 | from ... import ast 6 | from .util import Element, ElementTree 7 | from .v11 import FES11Parser 8 | from .v20 import FES20Parser 9 | 10 | 11 | def parse(xml: Union[str, Element, ElementTree]) -> ast.Node: 12 | if isinstance(xml, str): 13 | root = etree.fromstring(xml) 14 | else: 15 | root = xml 16 | 17 | # decide upon namespace which parser to use 18 | namespace = etree.QName(root).namespace 19 | if namespace == FES11Parser.namespace: 20 | return FES11Parser().parse(root) 21 | elif namespace == FES20Parser.namespace: 22 | return FES20Parser().parse(root) 23 | 24 | raise ValueError(f"Unsupported namespace {namespace}") 25 | -------------------------------------------------------------------------------- /pygeofilter/parsers/fes/util.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from typing import Callable, Optional, Type, Union 3 | 4 | from lxml import etree 5 | 6 | from ... import ast 7 | 8 | Element = etree._Element 9 | ElementTree = etree._ElementTree 10 | ParseInput = Union[etree._Element, etree._ElementTree, str] 11 | 12 | 13 | class NodeParsingError(ValueError): 14 | pass 15 | 16 | 17 | class Missing: 18 | pass 19 | 20 | 21 | def handle( 22 | *tags: str, namespace: Union[str, Type[Missing]] = Missing, subiter: bool = True 23 | ) -> Callable: 24 | """Function-decorator to mark a class function as a handler for a 25 | given node type. 26 | """ 27 | assert tags 28 | 29 | @wraps(handle) 30 | def inner(func): 31 | func.handles_tags = tags 32 | func.namespace = namespace 33 | func.subiter = subiter 34 | return func 35 | 36 | return inner 37 | 38 | 39 | def handle_namespace(namespace: str, subiter: bool = True) -> Callable: 40 | """Function-decorator to mark a class function as a handler for a 41 | given namespace. 42 | """ 43 | 44 | @wraps(handle) 45 | def inner(func): 46 | func.handles_namespace = namespace 47 | func.subiter = subiter 48 | return func 49 | 50 | return inner 51 | 52 | 53 | class XMLParserMeta(type): 54 | def __init__(cls, name, bases, dct): 55 | cls_values = [(cls, dct.values())] 56 | cls_namespace = getattr(cls, "namespace", None) 57 | 58 | for base in bases: 59 | cls_namespace = cls_namespace or getattr(base, "namespace", None) 60 | cls_values.append((base, base.__dict__.values())) 61 | 62 | tag_map = {} 63 | namespace_map = {} 64 | for cls_, values in cls_values: 65 | for value in values: 66 | if hasattr(value, "handles_tags"): 67 | for handled_tag in value.handles_tags: 68 | namespace = value.namespace 69 | if namespace is Missing: 70 | namespace = ( 71 | getattr(cls_, "namespace", None) or cls_namespace 72 | ) 73 | if namespace: 74 | if isinstance(namespace, (list, tuple)): 75 | namespaces = namespace 76 | else: 77 | namespaces = [namespace] 78 | 79 | for namespace in namespaces: 80 | full_tag = f"{{{namespace}}}{handled_tag}" 81 | tag_map[full_tag] = value 82 | else: 83 | tag_map[handled_tag] = value 84 | 85 | if hasattr(value, "handles_namespace"): 86 | namespace_map[value.handles_namespace] = value 87 | 88 | cls.tag_map = tag_map 89 | cls.namespace_map = namespace_map 90 | 91 | 92 | class XMLParser(metaclass=XMLParserMeta): 93 | namespace: Optional[str] = None 94 | tag_map: dict 95 | namespace_map: dict 96 | 97 | def parse(self, input_: ParseInput) -> ast.Node: 98 | if isinstance(input_, Element): 99 | root = input_ 100 | elif isinstance(input_, ElementTree): 101 | root = input_.getroot() 102 | else: 103 | root = etree.fromstring(input_) 104 | 105 | return self._evaluate_node(root) 106 | 107 | def _evaluate_node(self, node: etree._Element) -> ast.Node: 108 | qname = etree.QName(node.tag) 109 | if node.tag in self.tag_map: 110 | parse_func = self.tag_map[node.tag] 111 | elif qname.namespace in self.namespace_map: 112 | parse_func = self.namespace_map[qname.namespace] 113 | else: 114 | raise NodeParsingError(f"Cannot parse XML tag {node.tag}") 115 | 116 | if parse_func.subiter: 117 | sub_nodes = [self._evaluate_node(child) for child in node.iterchildren()] 118 | return parse_func(self, node, *sub_nodes) 119 | else: 120 | return parse_func(self, node) 121 | -------------------------------------------------------------------------------- /pygeofilter/parsers/fes/v11.py: -------------------------------------------------------------------------------- 1 | from ... import ast 2 | from .base import FESBaseParser 3 | from .util import Element, ParseInput, handle 4 | 5 | 6 | class FES11Parser(FESBaseParser): 7 | namespace = "http://www.opengis.net/ogc" 8 | 9 | @handle("Add") 10 | def add( 11 | self, node: Element, lhs: ast.ScalarAstType, rhs: ast.ScalarAstType 12 | ) -> ast.Node: 13 | return ast.Add(lhs, rhs) 14 | 15 | @handle("Sub") 16 | def sub( 17 | self, node: Element, lhs: ast.ScalarAstType, rhs: ast.ScalarAstType 18 | ) -> ast.Node: 19 | return ast.Sub(lhs, rhs) 20 | 21 | @handle("Mul") 22 | def mul( 23 | self, node: Element, lhs: ast.ScalarAstType, rhs: ast.ScalarAstType 24 | ) -> ast.Node: 25 | return ast.Mul(lhs, rhs) 26 | 27 | @handle("Div") 28 | def div( 29 | self, node: Element, lhs: ast.ScalarAstType, rhs: ast.ScalarAstType 30 | ) -> ast.Node: 31 | return ast.Div(lhs, rhs) 32 | 33 | @handle("PropertyName") 34 | def property_name(self, node): 35 | return ast.Attribute(node.text) 36 | 37 | 38 | def parse(input_: ParseInput) -> ast.Node: 39 | return FES11Parser().parse(input_) 40 | -------------------------------------------------------------------------------- /pygeofilter/parsers/fes/v20.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import datetime 3 | 4 | from ... import ast 5 | from ...util import parse_datetime, parse_duration 6 | from .base import FESBaseParser 7 | from .util import Element, ParseInput, handle 8 | 9 | 10 | class FES20Parser(FESBaseParser): 11 | namespace = "http://www.opengis.net/fes/2.0" 12 | 13 | # @handle('PropertyIsNil') 14 | # def property_is_nil(self, node: Element, lhs, rhs): 15 | # return ast... 16 | 17 | @handle("After") 18 | def time_after(self, node: Element, lhs, rhs): 19 | return ast.TimeAfter(lhs, rhs) 20 | 21 | @handle("Before") 22 | def time_before(self, node: Element, lhs, rhs): 23 | return ast.TimeBefore(lhs, rhs) 24 | 25 | @handle("Begins") 26 | def time_begins(self, node: Element, lhs, rhs): 27 | return ast.TimeBegins(lhs, rhs) 28 | 29 | @handle("BegunBy") 30 | def time_begun_by(self, node: Element, lhs, rhs): 31 | return ast.TimeBegunBy(lhs, rhs) 32 | 33 | @handle("TContains") 34 | def time_contains(self, node: Element, lhs, rhs): 35 | return ast.TimeContains(lhs, rhs) 36 | 37 | @handle("During") 38 | def time_during(self, node: Element, lhs, rhs): 39 | return ast.TimeDuring(lhs, rhs) 40 | 41 | @handle("TEquals") 42 | def time_equals(self, node: Element, lhs, rhs): 43 | return ast.TimeEquals(lhs, rhs) 44 | 45 | @handle("TOverlaps") 46 | def time_overlaps(self, node: Element, lhs, rhs): 47 | return ast.TimeOverlaps(lhs, rhs) 48 | 49 | @handle("Meets") 50 | def time_meets(self, node: Element, lhs, rhs): 51 | return ast.TimeMeets(lhs, rhs) 52 | 53 | @handle("OverlappedBy") 54 | def time_overlapped_by(self, node: Element, lhs, rhs): 55 | return ast.TimeOverlappedBy(lhs, rhs) 56 | 57 | @handle("MetBy") 58 | def time_met_by(self, node: Element, lhs, rhs): 59 | return ast.TimeMetBy(lhs, rhs) 60 | 61 | @handle("Ends") 62 | def time_ends(self, node: Element, lhs, rhs): 63 | return ast.TimeEnds(lhs, rhs) 64 | 65 | @handle("EndedBy") 66 | def time_ended_by(self, node: Element, lhs, rhs): 67 | return ast.TimeEndedBy(lhs, rhs) 68 | 69 | @handle("ValueReference") 70 | def value_reference(self, node: Element): 71 | return ast.Attribute(node.text) 72 | 73 | @handle("Literal") 74 | def literal(self, node: Element): 75 | type_ = node.get("type").rpartition(":")[2] 76 | value = node.text 77 | if type_ == "boolean": 78 | return value.lower() == "true" 79 | elif type_ in ( 80 | "byte", 81 | "int", 82 | "integer", 83 | "long", 84 | "negativeInteger", 85 | "nonNegativeInteger", 86 | "nonPositiveInteger", 87 | "positiveInteger", 88 | "short", 89 | "unsignedByte", 90 | "unsignedInt", 91 | "unsignedLong", 92 | "unsignedShort", 93 | ): 94 | return int(value) 95 | elif type_ in ("decimal", "double", "float"): 96 | return float(value) 97 | elif type_ == "base64Binary": 98 | return base64.b64decode(value) 99 | elif type_ == "hexBinary": 100 | return bytes.fromhex(value) 101 | elif type_ == "date": 102 | return datetime.date.fromisoformat(value) 103 | elif type_ == "dateTime": 104 | return parse_datetime(value) 105 | elif type_ == "duration": 106 | return parse_duration(value) 107 | 108 | # return to string 109 | return value 110 | 111 | 112 | def parse(input_: ParseInput) -> ast.Node: 113 | return FES20Parser().parse(input_) 114 | -------------------------------------------------------------------------------- /pygeofilter/parsers/iso8601.lark: -------------------------------------------------------------------------------- 1 | // ------------------------------------------------------------------------------ 2 | // 3 | // Project: pygeofilter 4 | // Authors: Fabian Schindler 5 | // 6 | // ------------------------------------------------------------------------------ 7 | // Copyright (C) 2021 EOX IT Services GmbH 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in 17 | // all copies of this Software or works derived from this Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | // THE SOFTWARE. 26 | // ------------------------------------------------------------------------------ 27 | 28 | DATETIME: /\d{4}-\d{2}-\d{2}T[0-2][0-9]:[0-5][0-9]:[0-5][0-9](\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})/ 29 | DURATION: /P((\d+Y)?(\d+M)?(\d+D)?)?(T(\d+H)?(\d+M)?(\d+S)?)?/ 30 | -------------------------------------------------------------------------------- /pygeofilter/parsers/iso8601.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pygeofilter 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2021 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in 17 | # all copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | from lark import Transformer, v_args 29 | 30 | from ..util import parse_datetime, parse_duration 31 | 32 | 33 | @v_args(meta=False, inline=True) 34 | class ISO8601Transformer(Transformer): 35 | def DATETIME(self, dt): 36 | return parse_datetime(dt) 37 | 38 | def DURATION(self, duration): 39 | return parse_duration(duration) 40 | -------------------------------------------------------------------------------- /pygeofilter/parsers/jfe/__init__.py: -------------------------------------------------------------------------------- 1 | from .parser import parse 2 | 3 | __all__ = ["parse"] 4 | -------------------------------------------------------------------------------- /pygeofilter/parsers/jfe/parser.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pygeofilter 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2021 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in 17 | # all copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | """ 29 | Parser implementation for JFE. Spec here: 30 | https://github.com/tschaub/ogcapi-features/tree/json-array-expression/extensions/cql/jfe 31 | """ 32 | 33 | import json 34 | from datetime import datetime 35 | from typing import Any, Dict, List, Type, Union, cast 36 | 37 | from ... import ast, values 38 | from ...util import parse_datetime 39 | 40 | COMPARISON_MAP: Dict[str, Type] = { 41 | "==": ast.Equal, 42 | "!=": ast.NotEqual, 43 | "<": ast.LessThan, 44 | "<=": ast.LessEqual, 45 | ">": ast.GreaterThan, 46 | ">=": ast.GreaterEqual, 47 | } 48 | 49 | SPATIAL_PREDICATES_MAP = { 50 | "intersects": ast.GeometryIntersects, 51 | "within": ast.GeometryWithin, 52 | } 53 | 54 | TEMPORAL_PREDICATES_MAP = { 55 | "before": ast.TimeBefore, 56 | "after": ast.TimeAfter, 57 | "during": ast.TimeDuring, 58 | } 59 | 60 | ARITHMETIC_MAP = { 61 | "+": ast.Add, 62 | "-": ast.Sub, 63 | "*": ast.Mul, 64 | "/": ast.Div, 65 | } 66 | 67 | FUNCTION_MAP = { 68 | "%": "mod", 69 | "^": "pow", 70 | } 71 | 72 | ParseResult = Union[ 73 | ast.Node, 74 | str, 75 | float, 76 | int, 77 | datetime, 78 | values.Geometry, 79 | values.Interval, 80 | Dict[Any, Any], # TODO: for like wildcards. 81 | ] 82 | 83 | 84 | def _parse_node(node: Union[list, dict]) -> ParseResult: # noqa: C901 85 | if isinstance(node, (str, float, int)): 86 | return node 87 | elif isinstance(node, dict): 88 | # wrap geometry, we say that the 'type' property defines if it is a 89 | # geometry 90 | if "type" in node: 91 | return values.Geometry(node) 92 | 93 | # just return objects for example `like` wildcards 94 | else: 95 | return node 96 | 97 | if not isinstance(node, list): 98 | raise ValueError(f"Invalid node class {type(node)}") 99 | 100 | op = node[0] 101 | arguments = [_parse_node(sub) for sub in node[1:]] 102 | 103 | if op in ["all", "any"]: 104 | cls = ast.And if op == "all" else ast.Or 105 | return cls.from_items(*arguments) 106 | 107 | elif op == "!": 108 | return ast.Not(*cast(List[ast.Node], arguments)) 109 | 110 | elif op in COMPARISON_MAP: 111 | return COMPARISON_MAP[op](*arguments) 112 | 113 | elif op == "like": 114 | wildcard = "%" 115 | if len(arguments) > 2: 116 | wildcard = cast(dict, arguments[2]).get("wildCard", "%") 117 | return ast.Like( 118 | cast(ast.Node, arguments[0]), 119 | cast(str, arguments[1]), 120 | nocase=False, 121 | wildcard=wildcard, 122 | singlechar=".", 123 | escapechar="\\", 124 | not_=False, 125 | ) 126 | 127 | elif op == "in": 128 | assert isinstance(arguments[0], ast.Node) 129 | return ast.In( 130 | cast(ast.Node, arguments[0]), 131 | cast(List[ast.AstType], arguments[1:]), 132 | not_=False, 133 | ) 134 | 135 | elif op in SPATIAL_PREDICATES_MAP: 136 | return SPATIAL_PREDICATES_MAP[op](*cast(List[ast.SpatialAstType], arguments)) 137 | 138 | elif op in TEMPORAL_PREDICATES_MAP: 139 | # parse strings to datetimes 140 | dt_args = [ 141 | parse_datetime(arg) if isinstance(arg, str) else arg for arg in arguments 142 | ] 143 | if len(arguments) == 3: 144 | if isinstance(dt_args[0], datetime) and isinstance(dt_args[1], datetime): 145 | dt_args = [ 146 | values.Interval(dt_args[0], dt_args[1]), 147 | dt_args[2], 148 | ] 149 | if isinstance(dt_args[1], datetime) and isinstance(dt_args[2], datetime): 150 | dt_args = [ 151 | dt_args[0], 152 | values.Interval(dt_args[1], dt_args[2]), 153 | ] 154 | 155 | return TEMPORAL_PREDICATES_MAP[op](*cast(List[ast.TemporalAstType], dt_args)) 156 | 157 | # special property getters 158 | elif op in ["id", "geometry"]: 159 | return ast.Attribute(op) 160 | 161 | # normal property getter 162 | elif op == "get": 163 | return ast.Attribute(arguments[0]) 164 | 165 | elif op == "bbox": 166 | pass # TODO 167 | 168 | elif op in ARITHMETIC_MAP: 169 | return ARITHMETIC_MAP[op](*cast(List[ast.ScalarAstType], arguments)) 170 | 171 | elif op in ["%", "floor", "ceil", "abs", "^", "min", "max"]: 172 | return ast.Function( 173 | FUNCTION_MAP.get(op, op), cast(List[ast.AstType], arguments) 174 | ) 175 | 176 | raise ValueError(f"Invalid expression operation '{op}'") 177 | 178 | 179 | def parse(jfe: Union[str, list, dict]) -> ast.Node: 180 | """Parses the given JFE expression (either a string or an already 181 | parsed JSON) to an AST. 182 | If a string is passed, it will be parsed as JSON. 183 | 184 | https://github.com/tschaub/ogcapi-features/tree/json-array-expression/extensions/cql/jfe 185 | """ 186 | if isinstance(jfe, str): 187 | root = json.loads(jfe) 188 | else: 189 | root = jfe 190 | 191 | return cast(ast.Node, _parse_node(root)) 192 | -------------------------------------------------------------------------------- /pygeofilter/parsers/wkt.lark: -------------------------------------------------------------------------------- 1 | // ------------------------------------------------------------------------------ 2 | // 3 | // Project: pygeofilter 4 | // Authors: Fabian Schindler 5 | // 6 | // ------------------------------------------------------------------------------ 7 | // Copyright (C) 2021 EOX IT Services GmbH 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in 17 | // all copies of this Software or works derived from this Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | // THE SOFTWARE. 26 | // ------------------------------------------------------------------------------ 27 | 28 | ?ewkt_geometry: "SRID" "=" INT ";" geometry -> geometry_with_srid 29 | | geometry 30 | 31 | ?geometry: point 32 | | linestring 33 | | polygon 34 | | multipoint 35 | | multilinestring 36 | | multipolygon 37 | | geometrycollection 38 | 39 | geometrycollection: "GEOMETRYCOLLECTION" "(" geometry ( "," geometry )* ")" 40 | point: "POINT" "(" coordinate ")" 41 | linestring: "LINESTRING" "(" coordinate_list ")" 42 | polygon: "POLYGON" "(" coordinate_lists ")" 43 | 44 | multipoint: "MULTIPOINT" "(" coordinate_list ")" -> multipoint 45 | | "MULTIPOINT" "(" "(" coordinate ")" ( "," "(" coordinate ")" )* ")" -> multipoint_2 46 | multilinestring: "MULTILINESTRING" "(" coordinate_lists ")" 47 | 48 | multipolygon: "MULTIPOLYGON" "(" "(" coordinate_lists ")" ( "," "(" coordinate_lists ")" )* ")" 49 | 50 | coordinate_lists: "(" coordinate_list ")" ( "," "(" coordinate_list ")" )* 51 | 52 | ?coordinate_list: coordinate_list "," coordinate 53 | | coordinate -> coordinate_list_start 54 | coordinate: SIGNED_NUMBER SIGNED_NUMBER [ SIGNED_NUMBER [ SIGNED_NUMBER ] ] 55 | 56 | // NUMBER: /-?\d+\.?\d+/ 57 | %import common.NUMBER 58 | %import common.SIGNED_NUMBER 59 | %import common.INT 60 | -------------------------------------------------------------------------------- /pygeofilter/parsers/wkt.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pygeofilter 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2021 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in 17 | # all copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | from lark import Transformer, v_args 29 | 30 | 31 | @v_args(meta=False, inline=True) 32 | class WKTTransformer(Transformer): 33 | def wkt__geometry_with_srid(self, srid, geometry): 34 | print(srid, geometry) 35 | geometry["crs"] = { 36 | "type": "name", 37 | "properties": {"name": f"urn:ogc:def:crs:EPSG::{srid}"}, 38 | } 39 | return geometry 40 | 41 | def wkt__geometrycollection(self, *geometries): 42 | return {"type": "GeometryCollection", "geometries": geometries} 43 | 44 | def wkt__point(self, coordinates): 45 | return { 46 | "type": "Point", 47 | "coordinates": coordinates, 48 | } 49 | 50 | def wkt__linestring(self, coordinate_list): 51 | return { 52 | "type": "LineString", 53 | "coordinates": coordinate_list, 54 | } 55 | 56 | def wkt__polygon(self, coordinate_lists): 57 | return { 58 | "type": "Polygon", 59 | "coordinates": coordinate_lists, 60 | } 61 | 62 | def wkt__multipoint(self, coordinates): 63 | return { 64 | "type": "MultiPoint", 65 | "coordinates": coordinates, 66 | } 67 | 68 | def wkt__multipoint_2(self, *coordinates): 69 | print(coordinates) 70 | return { 71 | "type": "MultiPoint", 72 | "coordinates": coordinates, 73 | } 74 | 75 | def wkt__multilinestring(self, coordinate_lists): 76 | return { 77 | "type": "MultiLineString", 78 | "coordinates": coordinate_lists, 79 | } 80 | 81 | def wkt__multipolygon(self, *coordinate_lists): 82 | return { 83 | "type": "MultiPolygon", 84 | "coordinates": coordinate_lists, 85 | } 86 | 87 | def wkt__coordinate_lists(self, *coordinate_lists): 88 | return coordinate_lists 89 | 90 | def wkt__coordinate_list(self, coordinate_list, coordinate): 91 | return coordinate_list + (coordinate,) 92 | 93 | def wkt__coordinate_list_start(self, coordinate_list): 94 | return (coordinate_list,) 95 | 96 | def wkt__coordinate(self, *components): 97 | return components 98 | 99 | def wkt__SIGNED_NUMBER(self, value): 100 | return float(value) 101 | 102 | def wkt__NUMBER(self, value): 103 | return float(value) 104 | -------------------------------------------------------------------------------- /pygeofilter/util.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pygeofilter 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2019 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in 17 | # all copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | import re 29 | from collections.abc import Mapping 30 | from datetime import date, datetime, timedelta 31 | 32 | from dateparser import parse as _parse_datetime 33 | 34 | __all__ = [ 35 | "parse_datetime", 36 | "RE_ISO_8601", 37 | "parse_duration", 38 | "like_pattern_to_re_pattern", 39 | "like_pattern_to_re", 40 | ] 41 | 42 | RE_ISO_8601 = re.compile( 43 | r"^(?P[+-])?P" 44 | r"(?:(?P\d+(\.\d+)?)Y)?" 45 | r"(?:(?P\d+(\.\d+)?)M)?" 46 | r"(?:(?P\d+(\.\d+)?)D)?" 47 | r"T?(?:(?P\d+(\.\d+)?)H)?" 48 | r"(?:(?P\d+(\.\d+)?)M)?" 49 | r"(?:(?P\d+(\.\d+)?)S)?$" 50 | ) 51 | 52 | 53 | def parse_duration(value: str) -> timedelta: 54 | """Parses an ISO 8601 duration string into a python timedelta object. 55 | Raises a ``ValueError`` if a conversion was not possible. 56 | 57 | :param value: the ISO8601 duration string to parse 58 | :type value: str 59 | :return: the parsed duration 60 | :rtype: datetime.timedelta 61 | """ 62 | 63 | match = RE_ISO_8601.match(value) 64 | if not match: 65 | raise ValueError("Could not parse ISO 8601 duration from '%s'." % value) 66 | parts = match.groupdict() 67 | 68 | sign = -1 if "-" == parts["sign"] else 1 69 | days = float(parts["days"] or 0) 70 | days += float(parts["months"] or 0) * 30 # ?! 71 | days += float(parts["years"] or 0) * 365 # ?! 72 | fsec = float(parts["seconds"] or 0) 73 | fsec += float(parts["minutes"] or 0) * 60 74 | fsec += float(parts["hours"] or 0) * 3600 75 | 76 | return sign * timedelta(days, fsec) 77 | 78 | 79 | def parse_date(value: str) -> date: 80 | """Backport for `fromisoformat` for dates in Python 3.6""" 81 | return date(*(int(part) for part in value.split("-"))) 82 | 83 | 84 | def parse_datetime(value: str) -> datetime: 85 | parsed = _parse_datetime(value) 86 | if parsed is None: 87 | raise ValueError(value) 88 | return parsed 89 | 90 | 91 | def like_pattern_to_re_pattern(like, wildcard, single_char, escape_char): 92 | x_wildcard = re.escape(wildcard) 93 | x_single_char = re.escape(single_char) 94 | 95 | dx_wildcard = re.escape(x_wildcard) 96 | dx_single_char = re.escape(x_single_char) 97 | 98 | # special handling if escape char clashes with re escape char 99 | if escape_char == "\\": 100 | x_escape_char = "\\\\\\\\" 101 | else: 102 | x_escape_char = re.escape(escape_char) 103 | dx_escape_char = re.escape(x_escape_char) 104 | 105 | pattern = re.escape(like) 106 | 107 | # handle not escaped wildcards/single chars 108 | pattern = re.sub( 109 | f"(? int: 157 | return 0 158 | -------------------------------------------------------------------------------- /pygeofilter/values.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pygeofilter 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2019 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in 17 | # all copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | 29 | from dataclasses import dataclass 30 | from datetime import date, datetime, time, timedelta 31 | from typing import Any, List, Optional, Union 32 | 33 | from pygeoif import shape 34 | 35 | 36 | @dataclass 37 | class Geometry: 38 | geometry: dict 39 | 40 | @property 41 | def __geo_interface__(self): 42 | return self.geometry 43 | 44 | def __eq__(self, o: object) -> bool: 45 | return shape(self).__geo_interface__ == shape(o).__geo_interface__ 46 | 47 | 48 | @dataclass 49 | class Envelope: 50 | x1: float 51 | x2: float 52 | y1: float 53 | y2: float 54 | 55 | @property 56 | def geometry(self): 57 | return { 58 | "type": "Polygon", 59 | "coordinates": [ 60 | [ 61 | [self.x1, self.y1], 62 | [self.x1, self.y2], 63 | [self.x2, self.y2], 64 | [self.x2, self.y1], 65 | [self.x1, self.y1], 66 | ] 67 | ], 68 | } 69 | 70 | @property 71 | def __geo_interface__(self): 72 | return self.geometry 73 | 74 | def __eq__(self, o: object) -> bool: 75 | return shape(self).__geo_interface__ == shape(o).__geo_interface__ 76 | 77 | 78 | @dataclass 79 | class Interval: 80 | start: Optional[Union[date, datetime, timedelta]] = None 81 | end: Optional[Union[date, datetime, timedelta]] = None 82 | 83 | def get_sub_nodes(self) -> List[Any]: # TODO: find way to type this 84 | return [self.start, self.end] 85 | 86 | 87 | # used for handler declaration 88 | LITERALS = (list, str, float, int, bool, datetime, date, time, timedelta) 89 | 90 | # used for type checking 91 | 92 | SpatialValueType = Union[Geometry, Envelope] 93 | 94 | TemporalValueType = Union[date, datetime, timedelta, Interval] 95 | 96 | ValueType = Union[ 97 | SpatialValueType, 98 | TemporalValueType, 99 | bool, 100 | float, 101 | int, 102 | str, 103 | ] 104 | -------------------------------------------------------------------------------- /pygeofilter/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.3.1" 2 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=46.4", "wheel"] 3 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | flake8 2 | pytest 3 | pytest-django 4 | wheel 5 | mypy<=1.10.0 6 | types-dateparser 7 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | django 2 | geoalchemy2 3 | sqlalchemy 4 | geopandas 5 | fiona 6 | pyproj 7 | rtree 8 | pygml 9 | dateparser 10 | lark 11 | elasticsearch 12 | elasticsearch-dsl 13 | opensearch-py 14 | opensearch-dsl 15 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | version = attr: pygeofilter.version.__version__ 3 | 4 | ###################################################### 5 | # code formating / lint / type checking configurations 6 | [isort] 7 | profile = black 8 | default_section = THIRDPARTY 9 | 10 | [flake8] 11 | ignore = E501,W503,E203 12 | exclude = .git,__pycache__,docs/conf.py,old,build,dist 13 | max-complexity = 12 14 | max-line-length = 80 15 | 16 | [mypy] 17 | ignore_missing_imports = True 18 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pygeofilter 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2019 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in 17 | # all copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | """Install pygeofilter.""" 29 | 30 | import os 31 | import os.path 32 | 33 | from setuptools import find_packages, setup 34 | 35 | # don't install dependencies when building win readthedocs 36 | on_rtd = os.environ.get("READTHEDOCS") == "True" 37 | 38 | # use README.md for project long_description 39 | with open("README.md") as f: 40 | readme = f.read() 41 | 42 | description = ( 43 | "pygeofilter is a pure Python parser implementation of OGC filtering standards" 44 | ) 45 | 46 | setup( 47 | name="pygeofilter", 48 | description=description, 49 | long_description=readme, 50 | long_description_content_type="text/markdown", 51 | author="Fabian Schindler", 52 | author_email="fabian.schindler@eox.at", 53 | url="https://github.com/geopython/pygeofilter", 54 | license="MIT", 55 | packages=find_packages(), 56 | include_package_data=True, 57 | install_requires=( 58 | [ 59 | "click", 60 | "dateparser", 61 | "lark", 62 | "pygeoif>=1.0.0" 63 | ] 64 | if not on_rtd 65 | else [] 66 | ), 67 | extras_require={ 68 | "backend-django": ["django"], 69 | "backend-sqlalchemy": ["geoalchemy2", "sqlalchemy"], 70 | "backend-native": ["shapely"], 71 | "backend-elasticsearch": ["elasticsearch", "elasticsearch-dsl"], 72 | "backend-opensearch": ["opensearch-py", "opensearch-dsl"], 73 | "fes": ["pygml>=0.2"], 74 | }, 75 | classifiers=[ 76 | "Development Status :: 3 - Alpha", 77 | "Intended Audience :: Developers", 78 | "Topic :: Scientific/Engineering :: GIS", 79 | "License :: OSI Approved :: MIT License", 80 | "Programming Language :: Python :: 3.8", 81 | "Programming Language :: Python :: 3.9", 82 | "Programming Language :: Python :: 3.10", 83 | "Programming Language :: Python :: 3.11", 84 | "Programming Language :: Python :: 3.12", 85 | "Programming Language :: Python :: 3.13", 86 | ], 87 | entry_points={ 88 | 'console_scripts': [ 89 | 'pygeofilter=pygeofilter.cli:cli' 90 | ] 91 | }, 92 | tests_require=["pytest"] 93 | ) 94 | -------------------------------------------------------------------------------- /tests/backends/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pygeofilter/a0b1f158111569032666df6ed459c1b19f35b5b7/tests/backends/__init__.py -------------------------------------------------------------------------------- /tests/backends/django/conftest.py: -------------------------------------------------------------------------------- 1 | import django 2 | import pytest 3 | from django.conf import settings 4 | from django.core.management import call_command 5 | 6 | 7 | def pytest_configure(): 8 | settings.configure( 9 | SECRET_KEY="secret", 10 | INSTALLED_APPS=[ 11 | "django.contrib.admin", 12 | "django.contrib.auth", 13 | "django.contrib.contenttypes", 14 | "django.contrib.sessions", 15 | "django.contrib.messages", 16 | "django.contrib.staticfiles", 17 | "django.contrib.gis", 18 | "testapp", 19 | ], 20 | DATABASES={ 21 | "default": { 22 | "ENGINE": "django.contrib.gis.db.backends.spatialite", 23 | "NAME": "db.sqlite", 24 | "TEST": { 25 | "NAME": ":memory:", 26 | }, 27 | } 28 | }, 29 | LANGUAGE_CODE="en-us", 30 | TIME_ZONE="UTC", 31 | USE_I18N=True, 32 | USE_TZ=True, 33 | ) 34 | django.setup() 35 | 36 | 37 | @pytest.fixture(scope="session") 38 | def django_db_setup(django_db_setup, django_db_blocker): 39 | with django_db_blocker.unblock(): 40 | call_command("loaddata", "test.json") 41 | -------------------------------------------------------------------------------- /tests/backends/django/testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pygeofilter/a0b1f158111569032666df6ed459c1b19f35b5b7/tests/backends/django/testapp/__init__.py -------------------------------------------------------------------------------- /tests/backends/django/testapp/admin.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pygeofilter 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2019 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in 17 | # all copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | # Register your models here. 29 | -------------------------------------------------------------------------------- /tests/backends/django/testapp/apps.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pygeofilter 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2019 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in 17 | # all copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | from django.apps import AppConfig 29 | 30 | 31 | class TestappConfig(AppConfig): 32 | name = "testapp" 33 | -------------------------------------------------------------------------------- /tests/backends/django/testapp/fixtures/test.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "testapp.record", 4 | "pk": 1, 5 | "fields": { 6 | "identifier": "A", 7 | "geometry": "SRID=4326;MULTIPOLYGON (((0 0, 0 5, 5 5, 5 0, 0 0)))", 8 | "float_attribute": 0.0, 9 | "int_attribute": 10, 10 | "str_attribute": "AAA", 11 | "datetime_attribute": "2000-01-01T00:00:00Z", 12 | "choice_attribute": 1 13 | } 14 | }, 15 | { 16 | "model": "testapp.record", 17 | "pk": 2, 18 | "fields": { 19 | "identifier": "B", 20 | "geometry": "SRID=4326;MULTIPOLYGON (((5 5, 5 10, 10 10, 10 5, 5 5)))", 21 | "float_attribute": 30.0, 22 | "int_attribute": null, 23 | "str_attribute": "BBB", 24 | "datetime_attribute": "2000-01-01T00:00:05Z", 25 | "choice_attribute": 2 26 | } 27 | }, 28 | { 29 | "model": "testapp.recordmeta", 30 | "pk": 1, 31 | "fields": { 32 | "record": 1, 33 | "float_meta_attribute": 10.0, 34 | "int_meta_attribute": 20, 35 | "str_meta_attribute": "AparentA", 36 | "datetime_meta_attribute": "2000-01-01T00:00:05Z", 37 | "choice_meta_attribute": 1 38 | } 39 | }, 40 | { 41 | "model": "testapp.recordmeta", 42 | "pk": 2, 43 | "fields": { 44 | "record": 2, 45 | "float_meta_attribute": 20.0, 46 | "int_meta_attribute": 30, 47 | "str_meta_attribute": "BparentB", 48 | "datetime_meta_attribute": "2000-01-01T00:00:10Z", 49 | "choice_meta_attribute": 2 50 | } 51 | } 52 | ] 53 | -------------------------------------------------------------------------------- /tests/backends/django/testapp/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | 3 | # Generated by Django 2.2.5 on 2019-09-09 07:18 4 | 5 | import django.contrib.gis.db.models.fields 6 | import django.db.models.deletion 7 | from django.db import migrations, models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="Record", 19 | fields=[ 20 | ( 21 | "id", 22 | models.AutoField( 23 | auto_created=True, 24 | primary_key=True, 25 | serialize=False, 26 | verbose_name="ID", 27 | ), 28 | ), 29 | ("identifier", models.CharField(max_length=256, unique=True)), 30 | ( 31 | "geometry", 32 | django.contrib.gis.db.models.fields.GeometryField(srid=4326), 33 | ), 34 | ("float_attribute", models.FloatField(blank=True, null=True)), 35 | ("int_attribute", models.IntegerField(blank=True, null=True)), 36 | ( 37 | "str_attribute", 38 | models.CharField(blank=True, max_length=256, null=True), 39 | ), 40 | ("datetime_attribute", models.DateTimeField(blank=True, null=True)), 41 | ( 42 | "choice_attribute", 43 | models.PositiveSmallIntegerField( 44 | blank=True, choices=[(1, "A"), (2, "B"), (3, "C")], null=True 45 | ), 46 | ), 47 | ], 48 | ), 49 | migrations.CreateModel( 50 | name="RecordMeta", 51 | fields=[ 52 | ( 53 | "id", 54 | models.AutoField( 55 | auto_created=True, 56 | primary_key=True, 57 | serialize=False, 58 | verbose_name="ID", 59 | ), 60 | ), 61 | ("float_meta_attribute", models.FloatField(blank=True, null=True)), 62 | ("int_meta_attribute", models.IntegerField(blank=True, null=True)), 63 | ( 64 | "str_meta_attribute", 65 | models.CharField(blank=True, max_length=256, null=True), 66 | ), 67 | ( 68 | "datetime_meta_attribute", 69 | models.DateTimeField(blank=True, null=True), 70 | ), 71 | ( 72 | "choice_meta_attribute", 73 | models.PositiveSmallIntegerField( 74 | blank=True, choices=[(1, "X"), (2, "Y"), (3, "Z")], null=True 75 | ), 76 | ), 77 | ( 78 | "record", 79 | models.ForeignKey( 80 | on_delete=django.db.models.deletion.CASCADE, 81 | related_name="record_metas", 82 | to="testapp.Record", 83 | ), 84 | ), 85 | ], 86 | ), 87 | ] 88 | -------------------------------------------------------------------------------- /tests/backends/django/testapp/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pygeofilter/a0b1f158111569032666df6ed459c1b19f35b5b7/tests/backends/django/testapp/migrations/__init__.py -------------------------------------------------------------------------------- /tests/backends/django/testapp/models.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pygeofilter 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2019 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in 17 | # all copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | # flake8: noqa 29 | 30 | from django.contrib.gis.db import models 31 | 32 | optional = dict(null=True, blank=True) 33 | 34 | 35 | class Record(models.Model): 36 | identifier = models.CharField(max_length=256, unique=True, null=False) 37 | geometry = models.GeometryField() 38 | 39 | float_attribute = models.FloatField(**optional) 40 | int_attribute = models.IntegerField(**optional) 41 | str_attribute = models.CharField(max_length=256, **optional) 42 | datetime_attribute = models.DateTimeField(**optional) 43 | choice_attribute = models.PositiveSmallIntegerField( 44 | choices=[ 45 | (1, "ASCENDING"), 46 | (2, "DESCENDING"), 47 | ], 48 | **optional 49 | ) 50 | 51 | 52 | class RecordMeta(models.Model): 53 | record = models.ForeignKey( 54 | Record, on_delete=models.CASCADE, related_name="record_metas" 55 | ) 56 | 57 | float_meta_attribute = models.FloatField(**optional) 58 | int_meta_attribute = models.IntegerField(**optional) 59 | str_meta_attribute = models.CharField(max_length=256, **optional) 60 | datetime_meta_attribute = models.DateTimeField(**optional) 61 | choice_meta_attribute = models.PositiveSmallIntegerField( 62 | choices=[(1, "X"), (2, "Y"), (3, "Z")], **optional 63 | ) 64 | 65 | 66 | FIELD_MAPPING = { 67 | "identifier": "identifier", 68 | "geometry": "geometry", 69 | "floatAttribute": "float_attribute", 70 | "intAttribute": "int_attribute", 71 | "strAttribute": "str_attribute", 72 | "datetimeAttribute": "datetime_attribute", 73 | "choiceAttribute": "choice_attribute", 74 | # meta fields 75 | "floatMetaAttribute": "record_metas__float_meta_attribute", 76 | "intMetaAttribute": "record_metas__int_meta_attribute", 77 | "strMetaAttribute": "record_metas__str_meta_attribute", 78 | "datetimeMetaAttribute": "record_metas__datetime_meta_attribute", 79 | "choiceMetaAttribute": "record_metas__choice_meta_attribute", 80 | } 81 | 82 | MAPPING_CHOICES = { 83 | "choiceAttribute": dict(Record._meta.get_field("choice_attribute").choices), 84 | "choiceMetaAttribute": dict( 85 | RecordMeta._meta.get_field("choice_meta_attribute").choices 86 | ), 87 | } 88 | -------------------------------------------------------------------------------- /tests/backends/django/testapp/views.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pygeofilter 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2019 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in 17 | # all copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | # Create your views here. 29 | -------------------------------------------------------------------------------- /tests/backends/elasticsearch/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pygeofilter/a0b1f158111569032666df6ed459c1b19f35b5b7/tests/backends/elasticsearch/__init__.py -------------------------------------------------------------------------------- /tests/backends/elasticsearch/test_util.py: -------------------------------------------------------------------------------- 1 | from pygeofilter.backends.elasticsearch.util import like_to_wildcard 2 | 3 | 4 | def test_like_to_wildcard(): 5 | assert "This ? a test" == like_to_wildcard("This . a test", "*", ".") 6 | assert "This * a test" == like_to_wildcard("This * a test", "*", ".") 7 | -------------------------------------------------------------------------------- /tests/backends/opensearch/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pygeofilter/a0b1f158111569032666df6ed459c1b19f35b5b7/tests/backends/opensearch/__init__.py -------------------------------------------------------------------------------- /tests/backends/oraclesql/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pygeofilter/a0b1f158111569032666df6ed459c1b19f35b5b7/tests/backends/oraclesql/__init__.py -------------------------------------------------------------------------------- /tests/backends/oraclesql/test_evaluate.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pygeofilter 4 | # Authors: Andreas Kosubek 5 | # Bernhard Mallinger 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2023 Agrar Markt Austria 8 | # Copyright (C) 2024 EOX IT Services GmbH 9 | # 10 | # Permission is hereby granted, free of charge, to any person obtaining a copy 11 | # of this software and associated documentation files (the "Software"), to deal 12 | # in the Software without restriction, including without limitation the rights 13 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | # copies of the Software, and to permit persons to whom the Software is 15 | # furnished to do so, subject to the following conditions: 16 | # 17 | # The above copyright notice and this permission notice shall be included in 18 | # all copies of this Software or works derived from this Software. 19 | # 20 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 26 | # THE SOFTWARE. 27 | # ------------------------------------------------------------------------------ 28 | 29 | 30 | from pygeofilter.backends.oraclesql import ( 31 | to_sql_where, 32 | to_sql_where_with_bind_variables, 33 | ) 34 | from pygeofilter.parsers.ecql import parse 35 | 36 | FIELD_MAPPING = { 37 | "str_attr": "str_attr", 38 | "int_attr": "int_attr", 39 | "float_attr": "float_attr", 40 | "point_attr": "geometry_attr", 41 | } 42 | 43 | FUNCTION_MAP = {} 44 | 45 | 46 | def test_between(): 47 | where = to_sql_where( 48 | parse("int_attr NOT BETWEEN 4 AND 6"), FIELD_MAPPING, FUNCTION_MAP 49 | ) 50 | assert where == "(int_attr NOT BETWEEN 4 AND 6)" 51 | 52 | 53 | def test_between_with_binds(): 54 | where, binds = to_sql_where_with_bind_variables( 55 | parse("int_attr NOT BETWEEN 4 AND 6"), FIELD_MAPPING, FUNCTION_MAP 56 | ) 57 | assert where == "(int_attr NOT BETWEEN :int_attr_low_0 AND :int_attr_high_0)" 58 | assert binds == {"int_attr_low_0": 4, "int_attr_high_0": 6} 59 | 60 | 61 | def test_like(): 62 | where = to_sql_where(parse("str_attr LIKE 'foo%'"), FIELD_MAPPING, FUNCTION_MAP) 63 | assert where == "str_attr LIKE 'foo%' ESCAPE '\\'" 64 | 65 | 66 | def test_like_with_binds(): 67 | where, binds = to_sql_where_with_bind_variables( 68 | parse("str_attr LIKE 'foo%'"), FIELD_MAPPING, FUNCTION_MAP 69 | ) 70 | assert where == "str_attr LIKE :str_attr_0 ESCAPE '\\'" 71 | assert binds == {"str_attr_0": "foo%"} 72 | 73 | 74 | def test_combination(): 75 | where = to_sql_where( 76 | parse("int_attr = 5 AND float_attr < 6.0"), FIELD_MAPPING, FUNCTION_MAP 77 | ) 78 | assert where == "((int_attr = 5) AND (float_attr < 6.0))" 79 | 80 | 81 | def test_combination_with_binds(): 82 | where, binds = to_sql_where_with_bind_variables( 83 | parse("int_attr = 5 AND float_attr < 6.0"), FIELD_MAPPING, FUNCTION_MAP 84 | ) 85 | assert where == "((int_attr = :int_attr_0) AND (float_attr < :float_attr_1))" 86 | assert binds == {"int_attr_0": 5, "float_attr_1": 6.0} 87 | 88 | 89 | def test_spatial(): 90 | where = to_sql_where( 91 | parse("INTERSECTS(point_attr, ENVELOPE (0 1 0 1))"), 92 | FIELD_MAPPING, 93 | FUNCTION_MAP, 94 | ) 95 | geo_json = ( 96 | '{"type": "Polygon", ' 97 | '"coordinates": [[[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]]}' 98 | ) 99 | assert where == ( 100 | "SDO_RELATE(geometry_attr, " 101 | f"SDO_UTIL.FROM_JSON(geometry => '{geo_json}', srid => 4326), " 102 | "'mask=ANYINTERACT') = 'TRUE'" 103 | ) 104 | 105 | 106 | def test_spatial_with_binds(): 107 | where, binds = to_sql_where_with_bind_variables( 108 | parse("INTERSECTS(point_attr, ENVELOPE (0 1 0 1))"), 109 | FIELD_MAPPING, 110 | FUNCTION_MAP, 111 | ) 112 | geo_json = ( 113 | '{"type": "Polygon", ' 114 | '"coordinates": [[[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]]}' 115 | ) 116 | assert where == ( 117 | "SDO_RELATE(geometry_attr, " 118 | "SDO_UTIL.FROM_JSON(geometry => :geo_json_0, srid => :srid_0), " 119 | "'mask=ANYINTERACT') = 'TRUE'" 120 | ) 121 | assert binds == {"geo_json_0": geo_json, "srid_0": 4326} 122 | 123 | 124 | def test_bbox(): 125 | where = to_sql_where( 126 | parse("BBOX(point_attr,-140.99778,41.6751050889,-52.6480987209,83.23324)"), 127 | FIELD_MAPPING, 128 | FUNCTION_MAP, 129 | ) 130 | geo_json = ( 131 | '{"type": "Polygon", "coordinates": [[' 132 | "[-140.99778, 41.6751050889], " 133 | "[-140.99778, 83.23324], " 134 | "[-52.6480987209, 83.23324], " 135 | "[-52.6480987209, 41.6751050889], " 136 | "[-140.99778, 41.6751050889]]]}" 137 | ) 138 | assert where == ( 139 | "SDO_RELATE(geometry_attr, " 140 | f"SDO_UTIL.FROM_JSON(geometry => '{geo_json}', srid => 4326), " 141 | "'mask=ANYINTERACT') = 'TRUE'" 142 | ) 143 | 144 | 145 | def test_bbox_with_binds(): 146 | where, binds = to_sql_where_with_bind_variables( 147 | parse("BBOX(point_attr,-140.99778,41.6751050889,-52.6480987209,83.23324)"), 148 | FIELD_MAPPING, 149 | FUNCTION_MAP, 150 | ) 151 | geo_json = ( 152 | '{"type": "Polygon", "coordinates": [[' 153 | "[-140.99778, 41.6751050889], " 154 | "[-140.99778, 83.23324], " 155 | "[-52.6480987209, 83.23324], " 156 | "[-52.6480987209, 41.6751050889], " 157 | "[-140.99778, 41.6751050889]]]}" 158 | ) 159 | assert where == ( 160 | "SDO_RELATE(geometry_attr, " 161 | "SDO_UTIL.FROM_JSON(geometry => :geo_json_0, srid => :srid_0), " 162 | "'mask=ANYINTERACT') = 'TRUE'" 163 | ) 164 | assert binds == {"geo_json_0": geo_json, "srid_0": 4326} 165 | -------------------------------------------------------------------------------- /tests/backends/solr/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pygeofilter/a0b1f158111569032666df6ed459c1b19f35b5b7/tests/backends/solr/__init__.py -------------------------------------------------------------------------------- /tests/backends/solr/test_util.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pygeofilter 4 | # Authors: Magnar Martinsen 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2025 Norwegian Meteorological Institute 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in 17 | # all copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | from pygeofilter.backends.solr.util import like_to_wildcard 29 | 30 | 31 | def test_like_to_wildcard(): 32 | assert "This ? a test" == like_to_wildcard("This . a test", "*", ".") 33 | assert "This * a test" == like_to_wildcard("This * a test", "*", ".") 34 | -------------------------------------------------------------------------------- /tests/backends/sqlalchemy/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pygeofilter/a0b1f158111569032666df6ed459c1b19f35b5b7/tests/backends/sqlalchemy/__init__.py -------------------------------------------------------------------------------- /tests/backends/sqlalchemy/test_filters.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | import pytest 4 | 5 | from pygeofilter.backends.sqlalchemy import filters 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "geom, expected", 10 | [ 11 | pytest.param( 12 | {"type": "Point", "coordinates": [10, 12]}, 13 | "ST_GeomFromEWKT('SRID=4326;POINT (10 12)')", 14 | id="without-crs", 15 | ), 16 | pytest.param( 17 | { 18 | "type": "Point", 19 | "coordinates": [1, 2], 20 | "crs": { 21 | "type": "name", 22 | "properties": {"name": "urn:ogc:def:crs:EPSG::3004"}, 23 | }, 24 | }, 25 | "ST_GeomFromEWKT('SRID=3004;POINT (1 2)')", 26 | id="with-crs", 27 | ), 28 | ], 29 | ) 30 | def test_parse_geometry(geom, expected): 31 | parsed = filters.parse_geometry(cast(dict, geom)) 32 | result = str(parsed.compile(compile_kwargs={"literal_binds": True})) 33 | assert result == expected 34 | -------------------------------------------------------------------------------- /tests/native/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pygeofilter/a0b1f158111569032666df6ed459c1b19f35b5b7/tests/native/__init__.py -------------------------------------------------------------------------------- /tests/parsers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pygeofilter/a0b1f158111569032666df6ed459c1b19f35b5b7/tests/parsers/__init__.py -------------------------------------------------------------------------------- /tests/parsers/cql2_json/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pygeofilter/a0b1f158111569032666df6ed459c1b19f35b5b7/tests/parsers/cql2_json/__init__.py -------------------------------------------------------------------------------- /tests/parsers/cql2_json/fixtures.json: -------------------------------------------------------------------------------- 1 | { 2 | "Example 1": { 3 | "text": "filter=id='LC08_L1TP_060247_20180905_20180912_01_T1_L1TP' AND collection='landsat8_l1tp'", 4 | "json": "{\"filter\": {\"op\": \"and\", \"args\": [{\"op\": \"=\", \"args\": [{\"property\": \"id\"}, \"LC08_L1TP_060247_20180905_20180912_01_T1_L1TP\"]}, {\"op\": \"=\", \"args\": [{\"property\": \"collection\"}, \"landsat8_l1tp\"]}]}}" 5 | }, 6 | "Example 2": { 7 | "text": "filter=collection = 'landsat8_l1tp' AND eo:cloud_cover <= 10 AND datetime >= TIMESTAMP('2021-04-08T04:39:23Z') AND S_INTERSECTS(geometry, POLYGON((43.5845 -79.5442, 43.6079 -79.4893, 43.5677 -79.4632, 43.6129 -79.3925, 43.6223 -79.3238, 43.6576 -79.3163, 43.7945 -79.1178, 43.8144 -79.1542, 43.8555 -79.1714, 43.7509 -79.6390, 43.5845 -79.5442)))", 8 | "json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"and\", \"args\": [{\"op\": \"=\", \"args\": [{\"property\": \"collection\"}, \"landsat8_l1tp\"]}, {\"op\": \"<=\", \"args\": [{\"property\": \"eo:cloud_cover\"}, 10]}, {\"op\": \">=\", \"args\": [{\"property\": \"datetime\"}, {\"timestamp\": \"2021-04-08T04:39:23Z\"}]}, {\"op\": \"s_intersects\", \"args\": [{\"property\": \"geometry\"}, {\"type\": \"Polygon\", \"coordinates\": [[[43.5845, -79.5442], [43.6079, -79.4893], [43.5677, -79.4632], [43.6129, -79.3925], [43.6223, -79.3238], [43.6576, -79.3163], [43.7945, -79.1178], [43.8144, -79.1542], [43.8555, -79.1714], [43.7509, -79.639], [43.5845, -79.5442]]]}]}]}}" 9 | }, 10 | "Example 3": { 11 | "text": "filter=sentinel:data_coverage > 50 AND eo:cloud_cover < 10 ", 12 | "json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"and\", \"args\": [{\"op\": \">\", \"args\": [{\"property\": \"sentinel:data_coverage\"}, 50]}, {\"op\": \"<\", \"args\": [{\"property\": \"eo:cloud_cover\"}, 10]}]}}" 13 | }, 14 | "Example 4": { 15 | "text": "filter=sentinel:data_coverage > 50 OR eo:cloud_cover < 10 ", 16 | "json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"or\", \"args\": [{\"op\": \">\", \"args\": [{\"property\": \"sentinel:data_coverage\"}, 50]}, {\"op\": \"<\", \"args\": [{\"property\": \"eo:cloud_cover\"}, 10]}]}}" 17 | }, 18 | "Example 5": { 19 | "text": "filter=prop1 = prop2", 20 | "json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"=\", \"args\": [{\"property\": \"prop1\"}, {\"property\": \"prop2\"}]}}" 21 | }, 22 | "Example 6": { 23 | "text": "filter=datetime T_INTERSECTS INTERVAL('2020-11-11T00:00:00Z', '2020-11-12T00:00:00Z')", 24 | "json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"t_intersects\", \"args\": [{\"property\": \"datetime\"}, {\"interval\": [\"2020-11-11T00:00:00Z\", \"2020-11-12T00:00:00Z\"]}]}}" 25 | }, 26 | "Example 7": { 27 | "text": "filter=S_INTERSECTS(geometry,POLYGON((-77.0824 38.7886,-77.0189 38.7886,-77.0189 38.8351,-77.0824 38.8351,-77.0824 38.7886)))", 28 | "json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"s_intersects\", \"args\": [{\"property\": \"geometry\"}, {\"type\": \"Polygon\", \"coordinates\": [[[-77.0824, 38.7886], [-77.0189, 38.7886], [-77.0189, 38.8351], [-77.0824, 38.8351], [-77.0824, 38.7886]]]}]}}" 29 | }, 30 | "Example 8": { 31 | "text": "filter=S_INTERSECTS(geometry,POLYGON((-77.0824 38.7886,-77.0189 38.7886,-77.0189 38.8351,-77.0824 38.8351,-77.0824 38.7886))) OR S_INTERSECTS(geometry,POLYGON((-79.0935 38.7886,-79.0290 38.7886,-79.0290 38.8351,-79.0935 38.8351,-79.0935 38.7886)))", 32 | "json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"or\", \"args\": [{\"op\": \"s_intersects\", \"args\": [{\"property\": \"geometry\"}, {\"type\": \"Polygon\", \"coordinates\": [[[-77.0824, 38.7886], [-77.0189, 38.7886], [-77.0189, 38.8351], [-77.0824, 38.8351], [-77.0824, 38.7886]]]}]}, {\"op\": \"s_intersects\", \"args\": [{\"property\": \"geometry\"}, {\"type\": \"Polygon\", \"coordinates\": [[[-79.0935, 38.7886], [-79.029, 38.7886], [-79.029, 38.8351], [-79.0935, 38.8351], [-79.0935, 38.7886]]]}]}]}}" 33 | }, 34 | "Example 9": { 35 | "text": "filter=sentinel:data_coverage > 50 OR landsat:coverage_percent < 10 OR (sentinel:data_coverage IS NULL AND landsat:coverage_percent IS NULL)", 36 | "json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"or\", \"args\": [{\"op\": \">\", \"args\": [{\"property\": \"sentinel:data_coverage\"}, 50]}, {\"op\": \"<\", \"args\": [{\"property\": \"landsat:coverage_percent\"}, 10]}, {\"op\": \"and\", \"args\": [{\"op\": \"isNull\", \"args\": [{\"property\": \"sentinel:data_coverage\"}]}, {\"op\": \"isNull\", \"args\": [{\"property\": \"landsat:coverage_percent\"}]}]}]}}" 37 | }, 38 | "Example 10": { 39 | "text": "filter=eo:cloud_cover BETWEEN 0 AND 50", 40 | "json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"between\", \"args\": [{\"property\": \"eo:cloud_cover\"}, [0, 50]]}}" 41 | }, 42 | "Example 11": { 43 | "text": "filter=mission LIKE 'sentinel%'", 44 | "json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"like\", \"args\": [{\"property\": \"mission\"}, \"sentinel%\"]}}" 45 | }, 46 | "Example 12": { 47 | "text": "filter=CASEI(provider) = 'coolsat'", 48 | "json": "{\"filter-lang\": \"cql2-json\", \"filter\": {\"op\": \"=\", \"args\": [{\"lower\": {\"property\": \"provider\"}}, \"coolsat\"]}}" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/parsers/cql2_json/get_fixtures.py: -------------------------------------------------------------------------------- 1 | """Get fixtures from the spec.""" 2 | 3 | import json 4 | import re 5 | 6 | import requests 7 | 8 | url = ( 9 | "https://raw.githubusercontent.com/radiantearth/" 10 | "stac-api-spec/dev/fragments/filter/README.md" 11 | ) 12 | 13 | fixtures = {} 14 | examples_text = requests.get(url).text 15 | examples_raw = re.findall( 16 | r"### (Example \d+).*?```http" r"(.*?)" r"```.*?```json" r"(.*?)" r"```", 17 | examples_text, 18 | re.S, 19 | ) 20 | for example in examples_raw: 21 | fixtures[example[0]] = { 22 | "text": example[1].replace("\n", ""), 23 | "json": json.dumps(json.loads(example[2])), 24 | } 25 | 26 | with open("fixtures.json", "w") as f: 27 | json.dump(fixtures, f, indent=4) 28 | -------------------------------------------------------------------------------- /tests/parsers/cql2_json/test_cql2_spec_fixtures.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pathlib 3 | 4 | from pygeofilter.backends.cql2_json import to_cql2 5 | from pygeofilter.parsers.cql2_json import parse as json_parse 6 | from pygeofilter.parsers.cql2_text import parse as text_parse 7 | 8 | dir = pathlib.Path(__file__).parent.resolve() 9 | fixtures = pathlib.Path(dir, "fixtures.json") 10 | 11 | 12 | def test_fixtures(): 13 | """Test against fixtures from spec documentation. 14 | 15 | Parses both cql2_text and cql2_json from spec 16 | documentation and makes sure AST is the same 17 | and that json when each are converted back to 18 | cql2_json is the same. 19 | """ 20 | with open(fixtures) as f: 21 | examples = json.load(f) 22 | 23 | for _, v in examples.items(): 24 | t = v["text"].replace("filter=", "") 25 | j = v["json"] 26 | parsed_text = text_parse(t) 27 | parsed_json = json_parse(j) 28 | assert parsed_text == parsed_json 29 | assert to_cql2(parsed_text) == to_cql2(parsed_json) 30 | -------------------------------------------------------------------------------- /tests/parsers/cql2_text/test_parser.py: -------------------------------------------------------------------------------- 1 | from pygeofilter import ast 2 | from pygeofilter.parsers.cql2_text import parse 3 | 4 | 5 | def test_attribute_eq_true_uppercase(): 6 | result = parse("attr = TRUE") 7 | assert result == ast.Equal( 8 | ast.Attribute("attr"), 9 | True, 10 | ) 11 | 12 | 13 | def test_attribute_eq_true_lowercase(): 14 | result = parse("attr = true") 15 | assert result == ast.Equal( 16 | ast.Attribute("attr"), 17 | True, 18 | ) 19 | 20 | 21 | def test_attribute_eq_false_uppercase(): 22 | result = parse("attr = FALSE") 23 | assert result == ast.Equal( 24 | ast.Attribute("attr"), 25 | False, 26 | ) 27 | 28 | 29 | def test_attribute_eq_false_lowercase(): 30 | result = parse("attr = false") 31 | assert result == ast.Equal( 32 | ast.Attribute("attr"), 33 | False, 34 | ) 35 | -------------------------------------------------------------------------------- /tests/parsers/cql_json/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pygeofilter/a0b1f158111569032666df6ed459c1b19f35b5b7/tests/parsers/cql_json/__init__.py -------------------------------------------------------------------------------- /tests/parsers/ecql/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pygeofilter/a0b1f158111569032666df6ed459c1b19f35b5b7/tests/parsers/ecql/__init__.py -------------------------------------------------------------------------------- /tests/parsers/fes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pygeofilter/a0b1f158111569032666df6ed459c1b19f35b5b7/tests/parsers/fes/__init__.py -------------------------------------------------------------------------------- /tests/parsers/jfe/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pygeofilter/a0b1f158111569032666df6ed459c1b19f35b5b7/tests/parsers/jfe/__init__.py -------------------------------------------------------------------------------- /tests/test_geopandas/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pygeofilter/a0b1f158111569032666df6ed459c1b19f35b5b7/tests/test_geopandas/__init__.py -------------------------------------------------------------------------------- /tests/test_geopandas/test_evaluate.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime 2 | 3 | import geopandas 4 | import numpy as np 5 | import pytest 6 | from shapely.geometry import Point 7 | 8 | from pygeofilter.backends.geopandas.evaluate import to_filter 9 | from pygeofilter.parsers.ecql import parse 10 | 11 | 12 | @pytest.fixture 13 | def data(): 14 | return geopandas.GeoDataFrame( 15 | { 16 | "str_attr": ["this is a test", "this is another test"], 17 | "maybe_str_attr": [None, "not null"], 18 | "int_attr": [5, 8], 19 | "float_attr": [5.5, 8.5], 20 | "date_attr": [date(2010, 1, 1), date(2010, 1, 10)], 21 | "datetime_attr": [datetime(2010, 1, 1), datetime(2010, 1, 10)], 22 | "point_attr": geopandas.GeoSeries([Point(1, 1), Point(2, 2)]), 23 | } 24 | ) 25 | 26 | 27 | def filter_(ast, data): 28 | function_map = { 29 | "sin": np.sin, 30 | } 31 | 32 | return data[to_filter(data, ast, {}, function_map)] 33 | 34 | 35 | def test_comparison(data): 36 | result = filter_(parse("int_attr = 5"), data) 37 | assert len(result) == 1 and result.index[0] == 0 38 | 39 | result = filter_(parse("int_attr < 6"), data) 40 | assert len(result) == 1 and result.index[0] == 0 41 | 42 | result = filter_(parse("int_attr > 6"), data) 43 | assert len(result) == 1 and result.index[0] == 1 44 | 45 | result = filter_(parse("int_attr <= 5"), data) 46 | assert len(result) == 1 and result.index[0] == 0 47 | 48 | result = filter_(parse("int_attr >= 8"), data) 49 | assert len(result) == 1 and result.index[0] == 1 50 | 51 | result = filter_(parse("int_attr <> 5"), data) 52 | assert len(result) == 1 and result.index[0] == 1 53 | 54 | 55 | def test_combination(data): 56 | result = filter_(parse("int_attr = 5 AND float_attr < 6.0"), data) 57 | assert len(result) == 1 and result.index[0] == 0 58 | 59 | result = filter_(parse("int_attr = 5 AND float_attr < 6.0"), data) 60 | assert len(result) == 1 and result.index[0] == 0 61 | 62 | 63 | def test_between(data): 64 | result = filter_(parse("float_attr BETWEEN 4 AND 6"), data) 65 | assert len(result) == 1 and result.index[0] == 0 66 | 67 | result = filter_(parse("int_attr NOT BETWEEN 4 AND 6"), data) 68 | assert len(result) == 1 and result.index[0] == 1 69 | 70 | 71 | def test_like(data): 72 | result = filter_(parse("str_attr LIKE 'this is . test'"), data) 73 | assert len(result) == 1 and result.index[0] == 0 74 | 75 | result = filter_(parse("str_attr LIKE 'this is % test'"), data) 76 | assert len(result) == 2 77 | 78 | result = filter_(parse("str_attr NOT LIKE '% another test'"), data) 79 | assert len(result) == 1 and result.index[0] == 0 80 | 81 | result = filter_(parse("str_attr NOT LIKE 'this is . test'"), data) 82 | assert len(result) == 1 and result.index[0] == 1 83 | 84 | result = filter_(parse("str_attr ILIKE 'THIS IS . TEST'"), data) 85 | assert len(result) == 1 and result.index[0] == 0 86 | 87 | result = filter_(parse("str_attr ILIKE 'THIS IS % TEST'"), data) 88 | assert len(result) == 2 89 | 90 | 91 | def test_in(data): 92 | result = filter_(parse("int_attr IN ( 1, 2, 3, 4, 5 )"), data) 93 | assert len(result) == 1 and result.index[0] == 0 94 | 95 | result = filter_(parse("int_attr NOT IN ( 1, 2, 3, 4, 5 )"), data) 96 | assert len(result) == 1 and result.index[0] == 1 97 | 98 | 99 | def test_null(data): 100 | result = filter_(parse("maybe_str_attr IS NULL"), data) 101 | assert len(result) == 1 and result.index[0] == 0 102 | 103 | result = filter_(parse("maybe_str_attr IS NOT NULL"), data) 104 | assert len(result) == 1 and result.index[0] == 1 105 | 106 | 107 | # TODO: possible? 108 | # def test_has_attr(data): 109 | # result = filter_(parse('extra_attr EXISTS'), data) 110 | # assert len(result) == 1 and result[0] is data[0] 111 | 112 | # result = filter_(parse('extra_attr DOES-NOT-EXIST'), data) 113 | # assert len(result) == 1 and result[0] is data[1] 114 | 115 | 116 | # def test_temporal(data): 117 | # result = filter_( 118 | # parse('date_attr BEFORE 2010-01-08T00:00:00.00Z'), 119 | # data 120 | # ) 121 | # assert len(result) == 1 and result.index[0] == 0 122 | 123 | # result = filter_( 124 | # parse('date_attr AFTER 2010-01-08T00:00:00.00+01:00'), 125 | # data 126 | # ) 127 | # assert len(result) == 1 and result.index[0] == 1 128 | 129 | 130 | def test_spatial(data): 131 | result = filter_( 132 | parse("INTERSECTS(point_attr, ENVELOPE (0 1 0 1))"), 133 | data, 134 | ) 135 | assert len(result) == 1 and result.index[0] == 0 136 | 137 | result = filter_( 138 | parse("EQUALS(point_attr, POINT(2 2))"), 139 | data, 140 | ) 141 | assert len(result) == 1 and result.index[0] == 1 142 | 143 | 144 | def test_arithmetic(data): 145 | result = filter_( 146 | parse("int_attr = float_attr - 0.5"), 147 | data, 148 | ) 149 | assert len(result) == 2 150 | 151 | result = filter_( 152 | parse("int_attr = 5 + 20 / 2 - 10"), 153 | data, 154 | ) 155 | assert len(result) == 1 and result.index[0] == 0 156 | 157 | 158 | def test_function(data): 159 | result = filter_( 160 | parse("sin(float_attr) BETWEEN -0.75 AND -0.70"), 161 | data, 162 | ) 163 | assert len(result) == 1 and result.index[0] == 0 164 | -------------------------------------------------------------------------------- /tests/test_sql/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopython/pygeofilter/a0b1f158111569032666df6ed459c1b19f35b5b7/tests/test_sql/__init__.py -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # 3 | # Project: pygeofilter 4 | # Authors: Fabian Schindler 5 | # 6 | # ------------------------------------------------------------------------------ 7 | # Copyright (C) 2021 EOX IT Services GmbH 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in 17 | # all copies of this Software or works derived from this Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | # THE SOFTWARE. 26 | # ------------------------------------------------------------------------------ 27 | 28 | from pygeofilter.util import like_pattern_to_re 29 | 30 | SEARCH_STRING = "This is a test" 31 | 32 | 33 | def test_basic_single(): 34 | pattern = r"This is . test" 35 | regex = like_pattern_to_re( 36 | pattern, 37 | nocase=False, 38 | wildcard="%", 39 | single_char=".", 40 | escape_char="\\", 41 | ) 42 | assert regex.match(SEARCH_STRING) is not None 43 | 44 | 45 | def test_basic(): 46 | pattern = r"% a test" 47 | regex = like_pattern_to_re( 48 | pattern, 49 | nocase=False, 50 | wildcard="%", 51 | single_char=".", 52 | escape_char="\\", 53 | ) 54 | assert regex.match(SEARCH_STRING) is not None 55 | 56 | 57 | def test_basic_nocase(): 58 | pattern = r"% A TEST" 59 | regex = like_pattern_to_re( 60 | pattern, 61 | nocase=True, 62 | wildcard="%", 63 | single_char=".", 64 | escape_char="\\", 65 | ) 66 | assert regex.match(SEARCH_STRING) is not None 67 | 68 | 69 | def test_basic_regex_escape_re_func(): 70 | pattern = r".* a test" 71 | regex = like_pattern_to_re( 72 | pattern, 73 | nocase=True, 74 | wildcard="%", 75 | single_char=".", 76 | escape_char="\\", 77 | ) 78 | assert regex.match(SEARCH_STRING) is None 79 | 80 | 81 | def test_basic_regex_escape_char(): 82 | search_string = r"This is a % sign" 83 | pattern = r"This is a /% sign" 84 | regex = like_pattern_to_re( 85 | pattern, 86 | nocase=True, 87 | wildcard="%", 88 | single_char=".", 89 | escape_char="/", 90 | ) 91 | assert regex.match(search_string) is not None 92 | 93 | 94 | def test_basic_regex_escape_char_2(): 95 | search_string = r"This is a . sign" 96 | pattern = r"This is a /. sign" 97 | regex = like_pattern_to_re( 98 | pattern, 99 | nocase=True, 100 | wildcard="%", 101 | single_char=".", 102 | escape_char="/", 103 | ) 104 | assert regex.match(search_string) is not None 105 | --------------------------------------------------------------------------------