├── .coveragerc ├── .devcontainer └── devcontainer.json ├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── codeql.yml ├── .gitignore ├── History.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── jsonpath_ng ├── __init__.py ├── bin │ ├── __init__.py │ └── jsonpath.py ├── exceptions.py ├── ext │ ├── __init__.py │ ├── arithmetic.py │ ├── filter.py │ ├── iterable.py │ ├── parser.py │ └── string.py ├── jsonpath.py ├── lexer.py └── parser.py ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt ├── setup.py ├── tests ├── __init__.py ├── bin │ ├── test1.json │ ├── test2.json │ └── test_jsonpath.py ├── conftest.py ├── helpers.py ├── test_create.py ├── test_examples.py ├── test_exceptions.py ├── test_jsonpath.py ├── test_jsonpath_rw_ext.py ├── test_lexer.py └── test_parser.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | parallel = True 3 | branch = True 4 | source = jsonpath_ng, tests 5 | 6 | [report] 7 | fail_under = 82 8 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "mcr.microsoft.com/devcontainers/python:3.12", 3 | "hostRequirements": { 4 | "cpus": 4 5 | }, 6 | "waitFor": "onCreateCommand", 7 | "updateContentCommand": "pip install -r requirements.txt -r requirements-dev.txt", 8 | "customizations": { 9 | "vscode": { 10 | "extensions": [ 11 | "ms-python.python" 12 | ] 13 | } 14 | } 15 | } 16 | 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | end_of_line = lf 8 | insert_final_newline = true 9 | 10 | [*.{py,ini,toml}] 11 | charset = utf-8 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [Makefile] 16 | indent_style = tab 17 | 18 | [*.yml] 19 | indent_style = space 20 | indent_size = 2 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - '*' 6 | tags: 7 | - 'v*' 8 | pull_request: 9 | branches: 10 | - master 11 | permissions: 12 | contents: read 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: 19 | - "3.8" 20 | - "3.9" 21 | - "3.10" 22 | - "3.11" 23 | - "3.12" 24 | - "3.13" 25 | steps: 26 | - uses: actions/checkout@v4 27 | with: 28 | persist-credentials: false 29 | - uses: actions/setup-python@v5 30 | with: 31 | allow-prereleases: true 32 | python-version: ${{ matrix.python-version }} 33 | - name: Install dependencies 34 | run: | 35 | python -m pip install --upgrade pip setuptools wheel 36 | pip install -r requirements.txt 37 | pip install -r requirements-dev.txt 38 | - name: Run tests 39 | run: tox run -e py${{ matrix.python-version }} 40 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: 6 | - "master" 7 | pull_request: 8 | branches: 9 | - "master" 10 | schedule: 11 | - cron: '39 16 * * 5' 12 | 13 | jobs: 14 | analyze: 15 | name: Analyze 16 | runs-on: ubuntu-latest 17 | timeout-minutes: 360 18 | permissions: 19 | actions: read 20 | contents: read 21 | security-events: write 22 | 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v4 26 | with: 27 | persist-credentials: false 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v3 31 | with: 32 | languages: python 33 | 34 | - name: Perform CodeQL Analysis 35 | uses: github/codeql-action/analyze@v3 36 | with: 37 | category: "/language:python" 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | *~ 4 | 5 | # Emacs 6 | \#( 7 | .\#* 8 | 9 | # Build and test artifacts 10 | /README.txt 11 | /dist 12 | /*.egg-info 13 | parser.out 14 | .cache 15 | .coverage 16 | .coverage.* 17 | .tox/ 18 | 19 | build 20 | /jsonpath_rw/VERSION 21 | 22 | .idea 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /History.md: -------------------------------------------------------------------------------- 1 | 1.7.0 / 2024-10-11 2 | =================== 3 | * Allow raw numeric values to be used as keys 4 | * Add `wherenot` 5 | * Added EZRegex pattern for the split extension regex 6 | * Added negative and * indecies and quotes to `Split` parameters 7 | * Typo: duplicate line removed. 8 | * Added `path` extension that exposes datum's path from the jsonpath expression itself. 9 | * Remove Python 3.7 support 10 | * Only construct the parse table once 11 | * updated test for `jsonpath.py` changes 12 | * fix for Updating a json object fails if the value of a key is boolean #73 13 | * Add Codespaces configuration 14 | * Add `.editorconfig` 15 | * Fix a GitHub workflow schema issue 16 | 17 | 1.6.1 / 2024-01-11 18 | =================== 19 | * Bump actions/setup-python from 4 to 5 20 | * Bump github/codeql-action from 2 to 3 21 | * Use tox to run the test suite against all supported Pythons 22 | * Fix a typo in the README 23 | * Add a test case 24 | * Fix issue with lambda based updates 25 | * Remove unused code from the test suite 26 | * Refactor `tests/test_parser.py` 27 | * Refactor `tests/test_lexer.py` 28 | * Refactor `tests/test_jsonpath_rw_ext.py` 29 | * De-duplicate the parser test cases 30 | * Refactor `tests/test_jsonpath.py` 31 | * Refactor `tests/test_jsonpath.py` 32 | * Refactor `tests/test_exceptions.py` 33 | * Remove a test that merely checks exception inheritance 34 | * Refactor `tests/test_examples.py` 35 | * Add pytest-randomly to shake out auto_id side effects 36 | * Bump actions/checkout from 3 to 4 37 | * Include the test suite in coverage reports 38 | * Remove tests that don't affect coverage and contribute nothing 39 | * Reformat `tests/test_create.py` 40 | * Remove `test_doctests`, which is a no-op 41 | * Demonstrate that there are no doctests 42 | * Remove the `coveralls` dependency 43 | * Migrate `tests/bin/test_jsonpath.py` to use pytest 44 | * remove Python2 crumbs 45 | * Add CodeQL analysis 46 | * Remove the `oslotest` dependency 47 | * Fix running CI against incoming PRs 48 | * Support, and test against, Python 3.12 49 | * Update the currently-tested CPython versions in the README 50 | * Remove an unused Travis CI config file 51 | * Add a Dependabot config to keep GitHub action versions updated 52 | * add a test for the case when root element is a list 53 | * Fix issue with assignment in case root element is a list. 54 | * Fix typo in README 55 | * Fix test commands in Makefile 56 | * Fix .coveragerc path 57 | * Simplify clean in Makefile 58 | * Refactor unit tests for better errors 59 | * test case for existing auto id 60 | * Add more examples to README (thanks @baynes) 61 | * fixed typo 62 | * Don't fail when regex match is attempted on non-strings 63 | * added step in slice 64 | * Add additional tests 65 | * Add `keys` keyword 66 | 67 | 1.6.0 / 2023-09-13 68 | =================== 69 | * Enclose field names containing literals in quotes 70 | * Add note about extensions 71 | * Remove documentation status link 72 | * Update supported versions in setup.py 73 | * Add LICENSE file 74 | * Code cleanup 75 | * Remove dependency on six 76 | * Update build status badge 77 | * (origin/github-actions, github-actions) Remove testscenarios dependency 78 | * Remove pytest version constraints 79 | * Add testing with GitHub actions 80 | * Escape back slashes in tests to avoid DeprecationWarning. 81 | * Use raw strings for regular expressions to avoid DeprecationWarning. 82 | * refactor(package): remove dependency for decorator 83 | * Merge pull request #128 from michaelmior/hashable 84 | * Make path instances hashable 85 | * Merge pull request #122 from snopoke/snopoke-patch-1 86 | * Add more detail to filter docs. 87 | * remove incorrect parenthesis in filter examples 88 | * Merge pull request #119 from snopoke/patch-1 89 | * add 'sub' line with function param names 90 | * readme formatting fixes 91 | * chore(history): update 92 | * Update __init__.py 93 | 94 | 1.5.3 / 2021-07-05 95 | ================== 96 | 97 | * Update __init__.py 98 | * Update setup.py 99 | * Merge pull request #72 from kaapstorm/find_or_create 100 | * Tests 101 | * Add `update_or_create()` method 102 | * Merge pull request #68 from kaapstorm/example_tests 103 | * Merge pull request #70 from kaapstorm/exceptions 104 | * Add/fix `__eq__()` 105 | * Add tests based on Stefan Goessner's examples 106 | * Tests 107 | * Allow callers to catch JSONPathErrors 108 | 109 | v1.5.2 / 2020-09-07 110 | =================== 111 | 112 | * Merge pull request #41 from josephwhite13/allow-dictionary-filtering 113 | * Merge pull request #48 from back2root/master 114 | * Check for null value. 115 | * Merge pull request #40 from memborsky/add-regular-expression-contains-support 116 | * feat: support regular expression for performing contains (=~) filtering 117 | * if datum.value is a dictionary, filter on the list of values 118 | 119 | 1.5.1 / 2020-03-09 120 | ================== 121 | 122 | * feat(version): bump 123 | * fix(setup): strip extension 124 | 125 | v1.5.0 / 2020-03-06 126 | =================== 127 | 128 | * feat(version): bump to 1,5.0 129 | * Merge pull request #13 from dcreemer/master 130 | * fix(travis): remove python 3.4 (deprecated) 131 | * refactor(docs): delete coverage badge 132 | * Merge pull request #25 from rahendatri/patch-1 133 | * Merge pull request #26 from guandalf/contains_operator 134 | * Merge pull request #31 from borysvorona/master 135 | * refactor(travis): update python versions 136 | * Merge pull request #34 from dchourasia/patch-1 137 | * Updated Filter.py to implement update function 138 | * added hook for catching null value instead of empty list in path 139 | * Ignore vscode folder 140 | * Contains operator implementation 141 | * Update requirements-dev.txt 142 | * setuptools>=18.5 143 | * update setuptools 144 | * update cryptography 145 | * new version of cryptography requires it 146 | * entry point conflict with https://pypi.org/project/jsonpath/ 147 | * add str() method 148 | * clean up 149 | * remove extra print() 150 | * refactor(docs): remove codesponsor 151 | * feat(docs): add sponsor banner 152 | * Update .travis.yml 153 | * feat(History): add History file 154 | * fix(travis-ci): ignore versions 155 | * feat(requirements): add missing pytest-cov dependency 156 | * refactor(requirements): use version contraint 157 | * fix: remove .cache files 158 | * feat: add required files 159 | * fix(travis-ci): install proper packages 160 | * refactor(setup.py): update description 161 | * refactor(docs): remove downloads badge 162 | * fix(tests): pass unit tests 163 | * feat(docs): add TravisCI and PyPI badges 164 | * Merge pull request #2 from tomas-fp/master 165 | * feat(docs): update readme notes 166 | * feat(setup): increase version 167 | * Merge pull request #1 from kmmbvnr/patch-1 168 | * Fix github url on pypi 169 | 170 | v1.4.3 / 2017-08-24 171 | =================== 172 | 173 | * fix(travis-ci): ignore versions 174 | * feat(requirements): add missing pytest-cov dependency 175 | * refactor(requirements): use version contraint 176 | * fix: remove .cache files 177 | * feat: add required files 178 | * fix(travis-ci): install proper packages 179 | * refactor(setup.py): update description 180 | * refactor(docs): remove downloads badge 181 | * fix(tests): pass unit tests 182 | * feat(docs): add TravisCI and PyPI badges 183 | * Merge pull request #2 from tomas-fp/master 184 | * feat(docs): update readme notes 185 | * feat(setup): increase version 186 | * Merge pull request #1 from kmmbvnr/patch-1 187 | * Fix github url on pypi 188 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include tests *.json *.py 2 | include LICENSE 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | OK_COLOR=\033[32;01m 2 | NO_COLOR=\033[0m 3 | 4 | all: lint unit 5 | 6 | export PYTHONPATH:=${PWD} 7 | version=`python -c 'import jsonpath_ng; print(jsonpath_ng.__version__)'` 8 | filename=jsonpath_ng-`python -c 'import jsonpath_ng; print(jsonpath_ng.__version__)'`.tar.gz 9 | 10 | apidocs: 11 | @sphinx-apidoc -f --follow-links -H "API documentation" -o docs/source jsonpath_ng 12 | 13 | htmldocs: 14 | @rm -rf docs/_build 15 | $(MAKE) -C docs html 16 | 17 | install: 18 | @pip install -r requirements.txt 19 | @pip install -r requirements-dev.txt 20 | 21 | lint: 22 | @echo "$(OK_COLOR)==> Linting code ...$(NO_COLOR)" 23 | @flake8 --exclude=tests . 24 | 25 | test: clean 26 | @echo "$(OK_COLOR)==> Running tests ...$(NO_COLOR)" 27 | @tox 28 | 29 | tag: 30 | @echo "$(OK_COLOR)==> Creating tag $(version) ...$(NO_COLOR)" 31 | @git tag -a "v$(version)" -m "Version $(version)" 32 | @echo "$(OK_COLOR)==> Pushing tag $(version) to origin ...$(NO_COLOR)" 33 | @git push origin "v$(version)" 34 | 35 | bump: 36 | @bumpversion --commit --tag --current-version $(version) patch jsonpath_ng/__init__.py --allow-dirty 37 | 38 | bump-minor: 39 | @bumpversion --commit --tag --current-version $(version) minor jsonpath_ng/__init__.py --allow-dirty 40 | 41 | history: 42 | @git changelog --tag $(version) 43 | 44 | clean: 45 | @echo "$(OK_COLOR)==> Cleaning up files that are already in .gitignore...$(NO_COLOR)" 46 | @git clean -Xf 47 | 48 | publish: 49 | @echo "$(OK_COLOR)==> Releasing package ...$(NO_COLOR)" 50 | @python setup.py sdist bdist_wheel 51 | @twine upload dist/* 52 | @rm -fr build dist .egg pook.egg-info 53 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Python JSONPath Next-Generation |Build Status| |PyPI| 2 | ===================================================== 3 | 4 | A final implementation of JSONPath for Python that aims to be standard compliant, including arithmetic 5 | and binary comparison operators, as defined in the original `JSONPath proposal`_. 6 | 7 | This packages merges both `jsonpath-rw`_ and `jsonpath-rw-ext`_ and 8 | provides several AST API enhancements, such as the ability to update or remove nodes in the tree. 9 | 10 | About 11 | ----- 12 | 13 | This library provides a robust and significantly extended implementation 14 | of JSONPath for Python. It is tested with CPython 3.8 and higher. 15 | 16 | This library differs from other JSONPath implementations in that it is a 17 | full *language* implementation, meaning the JSONPath expressions are 18 | first class objects, easy to analyze, transform, parse, print, and 19 | extend. 20 | 21 | Quick Start 22 | ----------- 23 | 24 | To install, use pip: 25 | 26 | .. code:: bash 27 | 28 | $ pip install --upgrade jsonpath-ng 29 | 30 | 31 | Usage 32 | ----- 33 | 34 | Basic examples: 35 | 36 | .. code:: python 37 | 38 | $ python 39 | 40 | >>> from jsonpath_ng import jsonpath, parse 41 | 42 | # A robust parser, not just a regex. (Makes powerful extensions possible; see below) 43 | >>> jsonpath_expr = parse('foo[*].baz') 44 | 45 | # Extracting values is easy 46 | >>> [match.value for match in jsonpath_expr.find({'foo': [{'baz': 1}, {'baz': 2}]})] 47 | [1, 2] 48 | 49 | # Matches remember where they came from 50 | >>> [str(match.full_path) for match in jsonpath_expr.find({'foo': [{'baz': 1}, {'baz': 2}]})] 51 | ['foo.[0].baz', 'foo.[1].baz'] 52 | 53 | # Modifying values matching the path 54 | >>> jsonpath_expr.update( {'foo': [{'baz': 1}, {'baz': 2}]}, 3) 55 | {'foo': [{'baz': 3}, {'baz': 3}]} 56 | 57 | # Modifying one of the values matching the path 58 | >>> matches = jsonpath_expr.find({'foo': [{'baz': 1}, {'baz': 2}]}) 59 | >>> matches[0].full_path.update( {'foo': [{'baz': 1}, {'baz': 2}]}, 3) 60 | {'foo': [{'baz': 3}, {'baz': 2}]} 61 | 62 | # Removing all values matching a path 63 | >>> jsonpath_expr.filter(lambda d: True, {'foo': [{'baz': 1}, {'baz': 2}]}) 64 | {'foo': [{}, {}]} 65 | 66 | # Removing values containing particular data matching path 67 | >>> jsonpath_expr.filter(lambda d: d == 2, {'foo': [{'baz': 1}, {'baz': 2}]}) 68 | {'foo': [{'baz': 1}, {}]} 69 | 70 | # And this can be useful for automatically providing ids for bits of data that do not have them (currently a global switch) 71 | >>> jsonpath.auto_id_field = 'id' 72 | >>> [match.value for match in parse('foo[*].id').find({'foo': [{'id': 'bizzle'}, {'baz': 3}]})] 73 | ['foo.bizzle', 'foo.[1]'] 74 | 75 | # A handy extension: named operators like `parent` 76 | >>> [match.value for match in parse('a.*.b.`parent`.c').find({'a': {'x': {'b': 1, 'c': 'number one'}, 'y': {'b': 2, 'c': 'number two'}}})] 77 | ['number two', 'number one'] 78 | 79 | # You can also build expressions directly quite easily 80 | >>> from jsonpath_ng.jsonpath import Fields 81 | >>> from jsonpath_ng.jsonpath import Slice 82 | 83 | >>> jsonpath_expr_direct = Fields('foo').child(Slice('*')).child(Fields('baz')) # This is equivalent 84 | 85 | 86 | Using the extended parser: 87 | 88 | .. code:: python 89 | 90 | $ python 91 | 92 | >>> from jsonpath_ng.ext import parse 93 | 94 | # A robust parser, not just a regex. (Makes powerful extensions possible; see below) 95 | >>> jsonpath_expr = parse('foo[*].baz') 96 | 97 | 98 | JSONPath Syntax 99 | --------------- 100 | 101 | The JSONPath syntax supported by this library includes some additional 102 | features and omits some problematic features (those that make it 103 | unportable). In particular, some new operators such as ``|`` and 104 | ``where`` are available, and parentheses are used for grouping not for 105 | callbacks into Python, since with these changes the language is not 106 | trivially associative. Also, fields may be quoted whether or not they 107 | are contained in brackets. 108 | 109 | Atomic expressions: 110 | 111 | +-----------------------+---------------------------------------------------------------------------------------------+ 112 | | Syntax | Meaning | 113 | +=======================+=============================================================================================+ 114 | | ``$`` | The root object | 115 | +-----------------------+---------------------------------------------------------------------------------------------+ 116 | | ```this``` | The "current" object. | 117 | +-----------------------+---------------------------------------------------------------------------------------------+ 118 | | ```foo``` | More generally, this syntax allows "named operators" to extend JSONPath is arbitrary ways | 119 | +-----------------------+---------------------------------------------------------------------------------------------+ 120 | | *field* | Specified field(s), described below | 121 | +-----------------------+---------------------------------------------------------------------------------------------+ 122 | | ``[`` *field* ``]`` | Same as *field* | 123 | +-----------------------+---------------------------------------------------------------------------------------------+ 124 | | ``[`` *idx* ``]`` | Array access, described below (this is always unambiguous with field access) | 125 | +-----------------------+---------------------------------------------------------------------------------------------+ 126 | 127 | Jsonpath operators: 128 | 129 | +--------------------------------------+-----------------------------------------------------------------------------------+ 130 | | Syntax | Meaning | 131 | +======================================+===================================================================================+ 132 | | *jsonpath1* ``.`` *jsonpath2* | All nodes matched by *jsonpath2* starting at any node matching *jsonpath1* | 133 | +--------------------------------------+-----------------------------------------------------------------------------------+ 134 | | *jsonpath* ``[`` *whatever* ``]`` | Same as *jsonpath*\ ``.``\ *whatever* | 135 | +--------------------------------------+-----------------------------------------------------------------------------------+ 136 | | *jsonpath1* ``..`` *jsonpath2* | All nodes matched by *jsonpath2* that descend from any node matching *jsonpath1* | 137 | +--------------------------------------+-----------------------------------------------------------------------------------+ 138 | | *jsonpath1* ``where`` *jsonpath2* | Any nodes matching *jsonpath1* with a child matching *jsonpath2* | 139 | +--------------------------------------+-----------------------------------------------------------------------------------+ 140 | | *jsonpath1* ``wherenot`` *jsonpath2* | Any nodes matching *jsonpath1* with a child not matching *jsonpath2* | 141 | +--------------------------------------+-----------------------------------------------------------------------------------+ 142 | | *jsonpath1* ``|`` *jsonpath2* | Any nodes matching the union of *jsonpath1* and *jsonpath2* | 143 | +--------------------------------------+-----------------------------------------------------------------------------------+ 144 | 145 | Field specifiers ( *field* ): 146 | 147 | +-------------------------+-------------------------------------------------------------------------------------+ 148 | | Syntax | Meaning | 149 | +=========================+=====================================================================================+ 150 | | ``fieldname`` | the field ``fieldname`` (from the "current" object) | 151 | +-------------------------+-------------------------------------------------------------------------------------+ 152 | | ``"fieldname"`` | same as above, for allowing special characters in the fieldname | 153 | +-------------------------+-------------------------------------------------------------------------------------+ 154 | | ``'fieldname'`` | ditto | 155 | +-------------------------+-------------------------------------------------------------------------------------+ 156 | | ``*`` | any field | 157 | +-------------------------+-------------------------------------------------------------------------------------+ 158 | | *field* ``,`` *field* | either of the named fields (you can always build equivalent jsonpath using ``|``) | 159 | +-------------------------+-------------------------------------------------------------------------------------+ 160 | 161 | Array specifiers ( *idx* ): 162 | 163 | +-----------------------------------------+---------------------------------------------------------------------------------------+ 164 | | Syntax | Meaning | 165 | +=========================================+=======================================================================================+ 166 | | ``[``\ *n*\ ``]`` | array index (may be comma-separated list) | 167 | +-----------------------------------------+---------------------------------------------------------------------------------------+ 168 | | ``[``\ *start*\ ``?:``\ *end*\ ``?]`` | array slicing (note that *step* is unimplemented only due to lack of need thus far) | 169 | +-----------------------------------------+---------------------------------------------------------------------------------------+ 170 | | ``[*]`` | any array index | 171 | +-----------------------------------------+---------------------------------------------------------------------------------------+ 172 | 173 | Programmatic JSONPath 174 | --------------------- 175 | 176 | If you are programming in Python and would like a more robust way to 177 | create JSONPath expressions that does not depend on a parser, it is very 178 | easy to do so directly, and here are some examples: 179 | 180 | - ``Root()`` 181 | - ``Slice(start=0, end=None, step=None)`` 182 | - ``Fields('foo', 'bar')`` 183 | - ``Index(42)`` 184 | - ``Child(Fields('foo'), Index(42))`` 185 | - ``Where(Slice(), Fields('subfield'))`` 186 | - ``Descendants(jsonpath, jsonpath)`` 187 | 188 | 189 | Extras 190 | ------ 191 | 192 | - *Path data*: The result of ``JsonPath.find`` provide detailed context 193 | and path data so it is easy to traverse to parent objects, print full 194 | paths to pieces of data, and generate automatic ids. 195 | - *Automatic Ids*: If you set ``jsonpath_ng.auto_id_field`` to a value 196 | other than None, then for any piece of data missing that field, it 197 | will be replaced by the JSONPath to it, giving automatic unique ids 198 | to any piece of data. These ids will take into account any ids 199 | already present as well. 200 | - *Named operators*: Instead of using ``@`` to reference the current 201 | object, this library uses ```this```. In general, any string 202 | contained in backquotes can be made to be a new operator, currently 203 | by extending the library. 204 | 205 | 206 | Extensions 207 | ---------- 208 | 209 | To use the extensions below you must import from `jsonpath_ng.ext`. 210 | 211 | +--------------+-----------------------------------------------+ 212 | | name | Example | 213 | +==============+===============================================+ 214 | | len | - ``$.objects.`len``` | 215 | +--------------+-----------------------------------------------+ 216 | | keys | - ``$.objects.`keys``` | 217 | +--------------+-----------------------------------------------+ 218 | | str | - ``$.field.`str()``` | 219 | +--------------+-----------------------------------------------+ 220 | | sub | - ``$.field.`sub(/foo\\\\+(.*)/, \\\\1)``` | 221 | | | - ``$.field.`sub(/regex/, replacement)``` | 222 | +--------------+-----------------------------------------------+ 223 | | split | - ``$.field.`split(+, 2, -1)``` | 224 | | | - ``$.field.`split(",", *, -1)``` | 225 | | | - ``$.field.`split(' ', -1, -1)``` | 226 | | | - ``$.field.`split(sep, segement, maxsplit)```| 227 | +--------------+-----------------------------------------------+ 228 | | sorted | - ``$.objects.`sorted``` | 229 | | | - ``$.objects[\\some_field]`` | 230 | | | - ``$.objects[\\some_field,/other_field]`` | 231 | +--------------+-----------------------------------------------+ 232 | | filter | - ``$.objects[?(@some_field > 5)]`` | 233 | | | - ``$.objects[?some_field = "foobar"]`` | 234 | | | - ``$.objects[?some_field =~ "foobar"]`` | 235 | | | - ``$.objects[?some_field > 5 & other < 2]`` | 236 | | | | 237 | | | Supported operators: | 238 | | | - Equality: ==, =, != | 239 | | | - Comparison: >, >=, <, <= | 240 | | | - Regex match: =~ | 241 | | | | 242 | | | Combine multiple criteria with '&'. | 243 | | | | 244 | | | Properties can only be compared to static | 245 | | | values. | 246 | +--------------+-----------------------------------------------+ 247 | | arithmetic | - ``$.foo + "_" + $.bar`` | 248 | | (-+*/) | - ``$.foo * 12`` | 249 | | | - ``$.objects[*].cow + $.objects[*].cat`` | 250 | +--------------+-----------------------------------------------+ 251 | 252 | About arithmetic and string 253 | --------------------------- 254 | 255 | Operations are done with python operators and allows types that python 256 | allows, and return [] if the operation can't be done due to incompatible types. 257 | 258 | When operators are used, a jsonpath must be be fully defined otherwise 259 | jsonpath-rw-ext can't known if the expression is a string or a jsonpath field, 260 | in this case it will choice string as type. 261 | 262 | Example with data:: 263 | 264 | { 265 | 'cow': 'foo', 266 | 'fish': 'bar' 267 | } 268 | 269 | | ``cow + fish`` returns ``cowfish`` 270 | | ``$.cow + $.fish`` returns ``foobar`` 271 | | ``$.cow + "_" + $.fish`` returns ``foo_bar`` 272 | | ``$.cow + "_" + fish`` returns ``foo_fish`` 273 | 274 | About arithmetic and list 275 | ------------------------- 276 | 277 | Arithmetic can be used against two lists if they have the same size. 278 | 279 | Example with data:: 280 | 281 | {'objects': [ 282 | {'cow': 2, 'cat': 3}, 283 | {'cow': 4, 'cat': 6} 284 | ]} 285 | 286 | | ``$.objects[\*].cow + $.objects[\*].cat`` returns ``[6, 9]`` 287 | 288 | More to explore 289 | --------------- 290 | 291 | There are way too many JSONPath implementations out there to discuss. 292 | Some are robust, some are toy projects that still work fine, some are 293 | exercises. There will undoubtedly be many more. This one is made for use 294 | in released, maintained code, and in particular for programmatic access 295 | to the abstract syntax and extension. But JSONPath at its simplest just 296 | isn't that complicated, so you can probably use any of them 297 | successfully. Why not this one? 298 | 299 | The original proposal, as far as I know: 300 | 301 | - `JSONPath - XPath for 302 | JSON `__ by Stefan Goessner. 303 | 304 | Other examples 305 | -------------- 306 | 307 | Loading json data from file 308 | 309 | .. code:: python 310 | 311 | import json 312 | d = json.loads('{"foo": [{"baz": 1}, {"baz": 2}]}') 313 | # or 314 | with open('myfile.json') as f: 315 | d = json.load(f) 316 | 317 | Special note about PLY and docstrings 318 | ------------------------------------- 319 | 320 | The main parsing toolkit underlying this library, 321 | `PLY `__, does not work with docstrings 322 | removed. For example, ``PYTHONOPTIMIZE=2`` and ``python -OO`` will both 323 | cause a failure. 324 | 325 | Contributors 326 | ------------ 327 | 328 | This package is authored and maintained by: 329 | 330 | - `Kenn Knowles `__ 331 | (`@kennknowles `__) 332 | - `Tomas Aparicio ` 333 | 334 | with the help of patches submitted by `these contributors `__. 335 | 336 | Copyright and License 337 | --------------------- 338 | 339 | Copyright 2013 - Kenneth Knowles 340 | 341 | Copyright 2017 - Tomas Aparicio 342 | 343 | Licensed under the Apache License, Version 2.0 (the "License"); you may 344 | not use this file except in compliance with the License. You may obtain 345 | a copy of the License at 346 | 347 | :: 348 | 349 | http://www.apache.org/licenses/LICENSE-2.0 350 | 351 | Unless required by applicable law or agreed to in writing, software 352 | distributed under the License is distributed on an "AS IS" BASIS, 353 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 354 | See the License for the specific language governing permissions and 355 | limitations under the License. 356 | 357 | .. _`JSONPath proposal`: http://goessner.net/articles/JsonPath/ 358 | .. _`jsonpath-rw`: https://github.com/kennknowles/python-jsonpath-rw 359 | .. _`jsonpath-rw-ext`: https://pypi.python.org/pypi/jsonpath-rw-ext/ 360 | 361 | .. |PyPi downloads| image:: https://pypip.in/d/jsonpath-ng/badge.png 362 | :target: https://pypi.python.org/pypi/jsonpath-ng 363 | .. |Build Status| image:: https://github.com/h2non/jsonpath-ng/actions/workflows/ci.yml/badge.svg 364 | :target: https://github.com/h2non/jsonpath-ng/actions/workflows/ci.yml 365 | .. |PyPI| image:: https://img.shields.io/pypi/v/jsonpath-ng.svg?maxAge=2592000?style=flat-square 366 | :target: https://pypi.python.org/pypi/jsonpath-ng 367 | -------------------------------------------------------------------------------- /jsonpath_ng/__init__.py: -------------------------------------------------------------------------------- 1 | from .jsonpath import * # noqa 2 | from .parser import parse # noqa 3 | 4 | 5 | # Current package version 6 | __version__ = '1.7.0' 7 | -------------------------------------------------------------------------------- /jsonpath_ng/bin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/h2non/jsonpath-ng/ca251d50a404aa5a608e42e800e8fa435338ad7e/jsonpath_ng/bin/__init__.py -------------------------------------------------------------------------------- /jsonpath_ng/bin/jsonpath.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # encoding: utf-8 3 | # Copyright © 2012 Felix Richter 4 | # This work is free. You can redistribute it and/or modify it under the 5 | # terms of the Do What The Fuck You Want To Public License, Version 2, 6 | # as published by Sam Hocevar. See the COPYING file for more details. 7 | 8 | # Standard Library imports 9 | import json 10 | import sys 11 | import glob 12 | import argparse 13 | 14 | # JsonPath-RW imports 15 | from jsonpath_ng import parse 16 | 17 | def find_matches_for_file(expr, f): 18 | return expr.find(json.load(f)) 19 | 20 | def print_matches(matches): 21 | print('\n'.join(['{0}'.format(match.value) for match in matches])) 22 | 23 | 24 | def main(*argv): 25 | parser = argparse.ArgumentParser( 26 | description='Search JSON files (or stdin) according to a JSONPath expression.', 27 | formatter_class=argparse.RawTextHelpFormatter, 28 | epilog=""" 29 | Quick JSONPath reference (see more at https://github.com/kennknowles/python-jsonpath-rw) 30 | 31 | atomics: 32 | $ - root object 33 | `this` - current object 34 | 35 | operators: 36 | path1.path2 - same as xpath / 37 | path1|path2 - union 38 | path1..path2 - somewhere in between 39 | 40 | fields: 41 | fieldname - field with name 42 | * - any field 43 | [_start_?:_end_?] - array slice 44 | [*] - any array index 45 | """) 46 | 47 | 48 | 49 | parser.add_argument('expression', help='A JSONPath expression.') 50 | parser.add_argument('files', metavar='file', nargs='*', help='Files to search (if none, searches stdin)') 51 | 52 | args = parser.parse_args(argv[1:]) 53 | 54 | expr = parse(args.expression) 55 | glob_patterns = args.files 56 | 57 | if len(glob_patterns) == 0: 58 | # stdin mode 59 | print_matches(find_matches_for_file(expr, sys.stdin)) 60 | else: 61 | # file paths mode 62 | for pattern in glob_patterns: 63 | for filename in glob.glob(pattern): 64 | with open(filename) as f: 65 | print_matches(find_matches_for_file(expr, f)) 66 | 67 | def entry_point(): 68 | main(*sys.argv) 69 | -------------------------------------------------------------------------------- /jsonpath_ng/exceptions.py: -------------------------------------------------------------------------------- 1 | class JSONPathError(Exception): 2 | pass 3 | 4 | 5 | class JsonPathLexerError(JSONPathError): 6 | pass 7 | 8 | 9 | class JsonPathParserError(JSONPathError): 10 | pass 11 | -------------------------------------------------------------------------------- /jsonpath_ng/ext/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from .parser import parse # noqa 16 | -------------------------------------------------------------------------------- /jsonpath_ng/ext/arithmetic.py: -------------------------------------------------------------------------------- 1 | # 2 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 3 | # not use this file except in compliance with the License. You may obtain 4 | # a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 10 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 11 | # License for the specific language governing permissions and limitations 12 | # under the License. 13 | 14 | import operator 15 | from .. import JSONPath, DatumInContext 16 | 17 | 18 | OPERATOR_MAP = { 19 | '+': operator.add, 20 | '-': operator.sub, 21 | '*': operator.mul, 22 | '/': operator.truediv, 23 | } 24 | 25 | 26 | class Operation(JSONPath): 27 | def __init__(self, left, op, right): 28 | self.left = left 29 | self.op = OPERATOR_MAP[op] 30 | self.right = right 31 | 32 | def find(self, datum): 33 | result = [] 34 | if (isinstance(self.left, JSONPath) 35 | and isinstance(self.right, JSONPath)): 36 | left = self.left.find(datum) 37 | right = self.right.find(datum) 38 | if left and right and len(left) == len(right): 39 | for l, r in zip(left, right): 40 | try: 41 | result.append(self.op(l.value, r.value)) 42 | except TypeError: 43 | return [] 44 | else: 45 | return [] 46 | elif isinstance(self.left, JSONPath): 47 | left = self.left.find(datum) 48 | for l in left: 49 | try: 50 | result.append(self.op(l.value, self.right)) 51 | except TypeError: 52 | return [] 53 | elif isinstance(self.right, JSONPath): 54 | right = self.right.find(datum) 55 | for r in right: 56 | try: 57 | result.append(self.op(self.left, r.value)) 58 | except TypeError: 59 | return [] 60 | else: 61 | try: 62 | result.append(self.op(self.left, self.right)) 63 | except TypeError: 64 | return [] 65 | return [DatumInContext.wrap(r) for r in result] 66 | 67 | def __repr__(self): 68 | return '%s(%r%s%r)' % (self.__class__.__name__, self.left, self.op, 69 | self.right) 70 | 71 | def __str__(self): 72 | return '%s%s%s' % (self.left, self.op, self.right) 73 | -------------------------------------------------------------------------------- /jsonpath_ng/ext/filter.py: -------------------------------------------------------------------------------- 1 | # 2 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 3 | # not use this file except in compliance with the License. You may obtain 4 | # a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 10 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 11 | # License for the specific language governing permissions and limitations 12 | # under the License. 13 | 14 | import operator 15 | import re 16 | 17 | from .. import JSONPath, DatumInContext, Index 18 | 19 | 20 | OPERATOR_MAP = { 21 | '!=': operator.ne, 22 | '==': operator.eq, 23 | '=': operator.eq, 24 | '<=': operator.le, 25 | '<': operator.lt, 26 | '>=': operator.ge, 27 | '>': operator.gt, 28 | '=~': lambda a, b: True if isinstance(a, str) and re.search(b, a) else False, 29 | } 30 | 31 | 32 | class Filter(JSONPath): 33 | """The JSONQuery filter""" 34 | 35 | def __init__(self, expressions): 36 | self.expressions = expressions 37 | 38 | def find(self, datum): 39 | if not self.expressions: 40 | return datum 41 | 42 | datum = DatumInContext.wrap(datum) 43 | 44 | if isinstance(datum.value, dict): 45 | datum.value = list(datum.value.values()) 46 | 47 | if not isinstance(datum.value, list): 48 | return [] 49 | 50 | return [DatumInContext(datum.value[i], path=Index(i), context=datum) 51 | for i in range(0, len(datum.value)) 52 | if (len(self.expressions) == 53 | len(list(filter(lambda x: x.find(datum.value[i]), 54 | self.expressions))))] 55 | 56 | def filter(self, fn, data): 57 | # NOTE: We reverse the order just to make sure the indexes are preserved upon 58 | # removal. 59 | for datum in reversed(self.find(data)): 60 | index_obj = datum.path 61 | if isinstance(data, dict): 62 | index_obj.index = list(data)[index_obj.index] 63 | index_obj.filter(fn, data) 64 | return data 65 | 66 | def update(self, data, val): 67 | if type(data) is list: 68 | for index, item in enumerate(data): 69 | shouldUpdate = len(self.expressions) == len(list(filter(lambda x: x.find(item), self.expressions))) 70 | if shouldUpdate: 71 | if hasattr(val, '__call__'): 72 | val.__call__(data[index], data, index) 73 | else: 74 | data[index] = val 75 | return data 76 | 77 | def __repr__(self): 78 | return '%s(%r)' % (self.__class__.__name__, self.expressions) 79 | 80 | def __str__(self): 81 | return '[?%s]' % self.expressions 82 | 83 | def __eq__(self, other): 84 | return (isinstance(other, Filter) 85 | and self.expressions == other.expressions) 86 | 87 | 88 | class Expression(JSONPath): 89 | """The JSONQuery expression""" 90 | 91 | def __init__(self, target, op, value): 92 | self.target = target 93 | self.op = op 94 | self.value = value 95 | 96 | def find(self, datum): 97 | datum = self.target.find(DatumInContext.wrap(datum)) 98 | 99 | if not datum: 100 | return [] 101 | if self.op is None: 102 | return datum 103 | 104 | found = [] 105 | for data in datum: 106 | value = data.value 107 | if isinstance(self.value, int): 108 | try: 109 | value = int(value) 110 | except ValueError: 111 | continue 112 | elif isinstance(self.value, bool): 113 | try: 114 | value = bool(value) 115 | except ValueError: 116 | continue 117 | 118 | if OPERATOR_MAP[self.op](value, self.value): 119 | found.append(data) 120 | 121 | return found 122 | 123 | def __eq__(self, other): 124 | return (isinstance(other, Expression) and 125 | self.target == other.target and 126 | self.op == other.op and 127 | self.value == other.value) 128 | 129 | def __repr__(self): 130 | if self.op is None: 131 | return '%s(%r)' % (self.__class__.__name__, self.target) 132 | else: 133 | return '%s(%r %s %r)' % (self.__class__.__name__, 134 | self.target, self.op, self.value) 135 | 136 | def __str__(self): 137 | if self.op is None: 138 | return '%s' % self.target 139 | else: 140 | return '%s %s %s' % (self.target, self.op, self.value) 141 | -------------------------------------------------------------------------------- /jsonpath_ng/ext/iterable.py: -------------------------------------------------------------------------------- 1 | # 2 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 3 | # not use this file except in compliance with the License. You may obtain 4 | # a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 10 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 11 | # License for the specific language governing permissions and limitations 12 | # under the License. 13 | 14 | import functools 15 | from .. import This, DatumInContext, JSONPath 16 | 17 | 18 | class SortedThis(This): 19 | """The JSONPath referring to the sorted version of the current object. 20 | 21 | Concrete syntax is '`sorted`' or [\\field,/field]. 22 | """ 23 | def __init__(self, expressions=None): 24 | self.expressions = expressions 25 | 26 | def _compare(self, left, right): 27 | left = DatumInContext.wrap(left) 28 | right = DatumInContext.wrap(right) 29 | 30 | for expr in self.expressions: 31 | field, reverse = expr 32 | l_datum = field.find(left) 33 | r_datum = field.find(right) 34 | if (not l_datum or not r_datum or 35 | len(l_datum) > 1 or len(r_datum) > 1 or 36 | l_datum[0].value == r_datum[0].value): 37 | # NOTE(sileht): should we do something if the expression 38 | # match multiple fields, for now ignore them 39 | continue 40 | elif l_datum[0].value < r_datum[0].value: 41 | return 1 if reverse else -1 42 | else: 43 | return -1 if reverse else 1 44 | return 0 45 | 46 | def find(self, datum): 47 | """Return sorted value of This if list or dict.""" 48 | if isinstance(datum.value, dict) and self.expressions: 49 | return datum 50 | 51 | if isinstance(datum.value, dict) or isinstance(datum.value, list): 52 | key = (functools.cmp_to_key(self._compare) 53 | if self.expressions else None) 54 | return [DatumInContext.wrap( 55 | [value for value in sorted(datum.value, key=key)])] 56 | return datum 57 | 58 | def __eq__(self, other): 59 | return isinstance(other, Len) 60 | 61 | def __repr__(self): 62 | return '%s(%r)' % (self.__class__.__name__, self.expressions) 63 | 64 | def __str__(self): 65 | return '[?%s]' % self.expressions 66 | 67 | 68 | class Len(JSONPath): 69 | """The JSONPath referring to the len of the current object. 70 | 71 | Concrete syntax is '`len`'. 72 | """ 73 | 74 | def find(self, datum): 75 | datum = DatumInContext.wrap(datum) 76 | try: 77 | value = len(datum.value) 78 | except TypeError: 79 | return [] 80 | else: 81 | return [DatumInContext(value, 82 | context=None, 83 | path=Len())] 84 | 85 | def __eq__(self, other): 86 | return isinstance(other, Len) 87 | 88 | def __str__(self): 89 | return '`len`' 90 | 91 | def __repr__(self): 92 | return 'Len()' 93 | 94 | 95 | class Keys(JSONPath): 96 | """The JSONPath referring to the keys of the current object. 97 | Concrete syntax is '`keys`'. 98 | """ 99 | 100 | def find(self, datum): 101 | datum = DatumInContext.wrap(datum) 102 | try: 103 | value = list(datum.value.keys()) 104 | except Exception as e: 105 | return [] 106 | else: 107 | return [DatumInContext(value[i], 108 | context=None, 109 | path=Keys()) for i in range (0, len(datum.value))] 110 | 111 | def __eq__(self, other): 112 | return isinstance(other, Keys) 113 | 114 | def __str__(self): 115 | return '`keys`' 116 | 117 | def __repr__(self): 118 | return 'Keys()' 119 | 120 | class Path(JSONPath): 121 | """The JSONPath referring to the path of the current object. 122 | Concrete syntax is 'path`'. 123 | """ 124 | 125 | def find(self, datum): 126 | datum = DatumInContext.wrap(datum) 127 | try: 128 | value = str(datum.path) 129 | except Exception as e: 130 | return [] 131 | else: 132 | return [DatumInContext(value, 133 | context=datum, 134 | path=Path())] 135 | 136 | def __eq__(self, other): 137 | return isinstance(other, Path) 138 | 139 | def __str__(self): 140 | return '`path`' 141 | 142 | def __repr__(self): 143 | return 'Path()' 144 | -------------------------------------------------------------------------------- /jsonpath_ng/ext/parser.py: -------------------------------------------------------------------------------- 1 | # 2 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 3 | # not use this file except in compliance with the License. You may obtain 4 | # a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 10 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 11 | # License for the specific language governing permissions and limitations 12 | # under the License. 13 | 14 | from .. import lexer 15 | from .. import parser 16 | from .. import Fields, This, Child 17 | 18 | from . import arithmetic as _arithmetic 19 | from . import filter as _filter 20 | from . import iterable as _iterable 21 | from . import string as _string 22 | 23 | 24 | class ExtendedJsonPathLexer(lexer.JsonPathLexer): 25 | """Custom LALR-lexer for JsonPath""" 26 | literals = lexer.JsonPathLexer.literals + ['?', '@', '+', '*', '/', '-'] 27 | tokens = (['BOOL'] + 28 | parser.JsonPathLexer.tokens + 29 | ['FILTER_OP', 'SORT_DIRECTION', 'FLOAT']) 30 | 31 | t_FILTER_OP = r'=~|==?|<=|>=|!=|<|>' 32 | 33 | def t_BOOL(self, t): 34 | r'true|false' 35 | t.value = True if t.value == 'true' else False 36 | return t 37 | 38 | def t_SORT_DIRECTION(self, t): 39 | r',?\s*(/|\\)' 40 | t.value = t.value[-1] 41 | return t 42 | 43 | def t_ID(self, t): 44 | r'@?[a-zA-Z_][a-zA-Z0-9_@\-]*' 45 | # NOTE(sileht): This fixes the ID expression to be 46 | # able to use @ for `This` like any json query 47 | t.type = self.reserved_words.get(t.value, 'ID') 48 | return t 49 | 50 | def t_FLOAT(self, t): 51 | r'-?\d+\.\d+' 52 | t.value = float(t.value) 53 | return t 54 | 55 | 56 | class ExtendedJsonPathParser(parser.JsonPathParser): 57 | """Custom LALR-parser for JsonPath""" 58 | 59 | tokens = ExtendedJsonPathLexer.tokens 60 | 61 | def __init__(self, debug=False, lexer_class=None): 62 | lexer_class = lexer_class or ExtendedJsonPathLexer 63 | super(ExtendedJsonPathParser, self).__init__(debug, lexer_class) 64 | 65 | def p_jsonpath_operator_jsonpath(self, p): 66 | """jsonpath : NUMBER operator NUMBER 67 | | FLOAT operator FLOAT 68 | | ID operator ID 69 | | NUMBER operator jsonpath 70 | | FLOAT operator jsonpath 71 | | jsonpath operator NUMBER 72 | | jsonpath operator FLOAT 73 | | jsonpath operator jsonpath 74 | """ 75 | 76 | # NOTE(sileht): If we have choice between a field or a string we 77 | # always choice string, because field can be full qualified 78 | # like $.foo == foo and where string can't. 79 | for i in [1, 3]: 80 | if (isinstance(p[i], Fields) and len(p[i].fields) == 1): # noqa 81 | p[i] = p[i].fields[0] 82 | 83 | p[0] = _arithmetic.Operation(p[1], p[2], p[3]) 84 | 85 | def p_operator(self, p): 86 | """operator : '+' 87 | | '-' 88 | | '*' 89 | | '/' 90 | """ 91 | p[0] = p[1] 92 | 93 | def p_jsonpath_named_operator(self, p): 94 | "jsonpath : NAMED_OPERATOR" 95 | if p[1] == 'len': 96 | p[0] = _iterable.Len() 97 | elif p[1] == 'keys': 98 | p[0] = _iterable.Keys() 99 | elif p[1] == 'path': 100 | p[0] = _iterable.Path() 101 | elif p[1] == 'sorted': 102 | p[0] = _iterable.SortedThis() 103 | elif p[1].startswith("split("): 104 | p[0] = _string.Split(p[1]) 105 | elif p[1].startswith("sub("): 106 | p[0] = _string.Sub(p[1]) 107 | elif p[1].startswith("str("): 108 | p[0] = _string.Str(p[1]) 109 | else: 110 | super(ExtendedJsonPathParser, self).p_jsonpath_named_operator(p) 111 | 112 | def p_expression(self, p): 113 | """expression : jsonpath 114 | | jsonpath FILTER_OP ID 115 | | jsonpath FILTER_OP FLOAT 116 | | jsonpath FILTER_OP NUMBER 117 | | jsonpath FILTER_OP BOOL 118 | """ 119 | if len(p) == 2: 120 | left, op, right = p[1], None, None 121 | else: 122 | __, left, op, right = p 123 | p[0] = _filter.Expression(left, op, right) 124 | 125 | def p_expressions_expression(self, p): 126 | "expressions : expression" 127 | p[0] = [p[1]] 128 | 129 | def p_expressions_and(self, p): 130 | "expressions : expressions '&' expressions" 131 | # TODO(sileht): implements '|' 132 | p[0] = p[1] + p[3] 133 | 134 | def p_expressions_parens(self, p): 135 | "expressions : '(' expressions ')'" 136 | p[0] = p[2] 137 | 138 | def p_filter(self, p): 139 | "filter : '?' expressions " 140 | p[0] = _filter.Filter(p[2]) 141 | 142 | def p_jsonpath_filter(self, p): 143 | "jsonpath : jsonpath '[' filter ']'" 144 | p[0] = Child(p[1], p[3]) 145 | 146 | def p_sort(self, p): 147 | "sort : SORT_DIRECTION jsonpath" 148 | p[0] = (p[2], p[1] != "/") 149 | 150 | def p_sorts_sort(self, p): 151 | "sorts : sort" 152 | p[0] = [p[1]] 153 | 154 | def p_sorts_comma(self, p): 155 | "sorts : sorts sorts" 156 | p[0] = p[1] + p[2] 157 | 158 | def p_jsonpath_sort(self, p): 159 | "jsonpath : jsonpath '[' sorts ']'" 160 | sort = _iterable.SortedThis(p[3]) 161 | p[0] = Child(p[1], sort) 162 | 163 | def p_jsonpath_this(self, p): 164 | "jsonpath : '@'" 165 | p[0] = This() 166 | 167 | precedence = [ 168 | ('left', '+', '-'), 169 | ('left', '*', '/'), 170 | ] + parser.JsonPathParser.precedence + [ 171 | ('nonassoc', 'ID'), 172 | ] 173 | 174 | # XXX This is here for backward compatibility 175 | ExtentedJsonPathParser = ExtendedJsonPathParser 176 | 177 | def parse(path, debug=False): 178 | return ExtendedJsonPathParser(debug=debug).parse(path) 179 | -------------------------------------------------------------------------------- /jsonpath_ng/ext/string.py: -------------------------------------------------------------------------------- 1 | # 2 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 3 | # not use this file except in compliance with the License. You may obtain 4 | # a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 10 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 11 | # License for the specific language governing permissions and limitations 12 | # under the License. 13 | 14 | import re 15 | from .. import DatumInContext, This 16 | 17 | 18 | SUB = re.compile(r"sub\(/(.*)/,\s+(.*)\)") 19 | # Regex generated using the EZRegex package (ezregex.org) 20 | # EZRegex code: 21 | # param1 = group(optional(either("'", '"')), name='quote') + group(chunk) + earlier_group('quote') 22 | # param2 = group(either(optional('-') + number, '*')) 23 | # param3 = group(optional('-') + number) 24 | # pattern = 'split' + ow + '(' + ow + param1 + ow + ',' + ow + param2 + ow + ',' + ow + param3 + ow + ')' 25 | SPLIT = re.compile(r"split(?:\s+)?\((?:\s+)?(?P(?:(?:'|\"))?)(.+)(?P=quote)(?:\s+)?,(?:\s+)?((?:(?:\-)?\d+|\*))(?:\s+)?,(?:\s+)?((?:\-)?\d+)(?:\s+)?\)") 26 | STR = re.compile(r"str\(\)") 27 | 28 | 29 | class DefintionInvalid(Exception): 30 | pass 31 | 32 | 33 | class Sub(This): 34 | """Regex substituor 35 | 36 | Concrete syntax is '`sub(/regex/, repl)`' 37 | """ 38 | 39 | def __init__(self, method=None): 40 | m = SUB.match(method) 41 | if m is None: 42 | raise DefintionInvalid("%s is not valid" % method) 43 | self.expr = m.group(1).strip() 44 | self.repl = m.group(2).strip() 45 | self.regex = re.compile(self.expr) 46 | self.method = method 47 | 48 | def find(self, datum): 49 | datum = DatumInContext.wrap(datum) 50 | value = self.regex.sub(self.repl, datum.value) 51 | if value == datum.value: 52 | return [] 53 | else: 54 | return [DatumInContext.wrap(value)] 55 | 56 | def __eq__(self, other): 57 | return (isinstance(other, Sub) and self.method == other.method) 58 | 59 | def __repr__(self): 60 | return '%s(%r)' % (self.__class__.__name__, self.method) 61 | 62 | def __str__(self): 63 | return '`sub(/%s/, %s)`' % (self.expr, self.repl) 64 | 65 | 66 | class Split(This): 67 | """String splitter 68 | 69 | Concrete syntax is '`split(chars, segment, max_split)`' 70 | `chars` can optionally be surrounded by quotes, to specify things like commas or spaces 71 | `segment` can be `*` to select all 72 | `max_split` can be negative, to indicate no limit 73 | """ 74 | 75 | def __init__(self, method=None): 76 | m = SPLIT.match(method) 77 | if m is None: 78 | raise DefintionInvalid("%s is not valid" % method) 79 | self.chars = m.group(2) 80 | self.segment = m.group(3) 81 | self.max_split = int(m.group(4)) 82 | self.method = method 83 | 84 | def find(self, datum): 85 | datum = DatumInContext.wrap(datum) 86 | try: 87 | if self.segment == '*': 88 | value = datum.value.split(self.chars, self.max_split) 89 | else: 90 | value = datum.value.split(self.chars, self.max_split)[int(self.segment)] 91 | except: 92 | return [] 93 | return [DatumInContext.wrap(value)] 94 | 95 | def __eq__(self, other): 96 | return (isinstance(other, Split) and self.method == other.method) 97 | 98 | def __repr__(self): 99 | return '%s(%r)' % (self.__class__.__name__, self.method) 100 | 101 | def __str__(self): 102 | return '`%s`' % self.method 103 | 104 | 105 | class Str(This): 106 | """String converter 107 | 108 | Concrete syntax is '`str()`' 109 | """ 110 | 111 | def __init__(self, method=None): 112 | m = STR.match(method) 113 | if m is None: 114 | raise DefintionInvalid("%s is not valid" % method) 115 | self.method = method 116 | 117 | def find(self, datum): 118 | datum = DatumInContext.wrap(datum) 119 | value = str(datum.value) 120 | return [DatumInContext.wrap(value)] 121 | 122 | def __eq__(self, other): 123 | return (isinstance(other, Str) and self.method == other.method) 124 | 125 | def __repr__(self): 126 | return '%s(%r)' % (self.__class__.__name__, self.method) 127 | 128 | def __str__(self): 129 | return '`str()`' 130 | -------------------------------------------------------------------------------- /jsonpath_ng/jsonpath.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import List, Optional 3 | import logging 4 | from itertools import * # noqa 5 | from jsonpath_ng.lexer import JsonPathLexer 6 | 7 | # Get logger name 8 | logger = logging.getLogger(__name__) 9 | 10 | # Turn on/off the automatic creation of id attributes 11 | # ... could be a kwarg pervasively but uses are rare and simple today 12 | auto_id_field = None 13 | 14 | NOT_SET = object() 15 | LIST_KEY = object() 16 | 17 | 18 | class JSONPath: 19 | """ 20 | The base class for JSONPath abstract syntax; those 21 | methods stubbed here are the interface to supported 22 | JSONPath semantics. 23 | """ 24 | 25 | def find(self, data) -> List[DatumInContext]: 26 | """ 27 | All `JSONPath` types support `find()`, which returns an iterable of `DatumInContext`s. 28 | They keep track of the path followed to the current location, so if the calling code 29 | has some opinion about that, it can be passed in here as a starting point. 30 | """ 31 | raise NotImplementedError() 32 | 33 | def find_or_create(self, data): 34 | return self.find(data) 35 | 36 | def update(self, data, val): 37 | """ 38 | Returns `data` with the specified path replaced by `val`. Only updates 39 | if the specified path exists. 40 | """ 41 | 42 | raise NotImplementedError() 43 | 44 | def update_or_create(self, data, val): 45 | return self.update(data, val) 46 | 47 | def filter(self, fn, data): 48 | """ 49 | Returns `data` with the specified path filtering nodes according 50 | the filter evaluation result returned by the filter function. 51 | 52 | Arguments: 53 | fn (function): unary function that accepts one argument 54 | and returns bool. 55 | data (dict|list|tuple): JSON object to filter. 56 | """ 57 | 58 | raise NotImplementedError() 59 | 60 | def child(self, child): 61 | """ 62 | Equivalent to Child(self, next) but with some canonicalization 63 | """ 64 | if isinstance(self, This) or isinstance(self, Root): 65 | return child 66 | elif isinstance(child, This): 67 | return self 68 | elif isinstance(child, Root): 69 | return child 70 | else: 71 | return Child(self, child) 72 | 73 | def make_datum(self, value): 74 | if isinstance(value, DatumInContext): 75 | return value 76 | else: 77 | return DatumInContext(value, path=Root(), context=None) 78 | 79 | 80 | class DatumInContext: 81 | """ 82 | Represents a datum along a path from a context. 83 | 84 | Essentially a zipper but with a structure represented by JsonPath, 85 | and where the context is more of a parent pointer than a proper 86 | representation of the context. 87 | 88 | For quick-and-dirty work, this proxies any non-special attributes 89 | to the underlying datum, but the actual datum can (and usually should) 90 | be retrieved via the `value` attribute. 91 | 92 | To place `datum` within another, use `datum.in_context(context=..., path=...)` 93 | which extends the path. If the datum already has a context, it places the entire 94 | context within that passed in, so an object can be built from the inside 95 | out. 96 | """ 97 | @classmethod 98 | def wrap(cls, data): 99 | if isinstance(data, cls): 100 | return data 101 | else: 102 | return cls(data) 103 | 104 | def __init__(self, value, path: Optional[JSONPath]=None, context: Optional[DatumInContext]=None): 105 | self.__value__ = value 106 | self.path = path or This() 107 | self.context = None if context is None else DatumInContext.wrap(context) 108 | 109 | @property 110 | def value(self): 111 | return self.__value__ 112 | 113 | @value.setter 114 | def value(self, value): 115 | if self.context is not None and self.context.value is not None: 116 | self.path.update(self.context.value, value) 117 | self.__value__ = value 118 | 119 | def in_context(self, context, path): 120 | context = DatumInContext.wrap(context) 121 | 122 | if self.context: 123 | return DatumInContext(value=self.value, path=self.path, context=context.in_context(path=path, context=context)) 124 | else: 125 | return DatumInContext(value=self.value, path=path, context=context) 126 | 127 | @property 128 | def full_path(self) -> JSONPath: 129 | return self.path if self.context is None else self.context.full_path.child(self.path) 130 | 131 | @property 132 | def id_pseudopath(self): 133 | """ 134 | Looks like a path, but with ids stuck in when available 135 | """ 136 | try: 137 | pseudopath = Fields(str(self.value[auto_id_field])) 138 | except (TypeError, AttributeError, KeyError): # This may not be all the interesting exceptions 139 | pseudopath = self.path 140 | 141 | if self.context: 142 | return self.context.id_pseudopath.child(pseudopath) 143 | else: 144 | return pseudopath 145 | 146 | def __repr__(self): 147 | return '%s(value=%r, path=%r, context=%r)' % (self.__class__.__name__, self.value, self.path, self.context) 148 | 149 | def __eq__(self, other): 150 | return isinstance(other, DatumInContext) and other.value == self.value and other.path == self.path and self.context == other.context 151 | 152 | 153 | class AutoIdForDatum(DatumInContext): 154 | """ 155 | This behaves like a DatumInContext, but the value is 156 | always the path leading up to it, not including the "id", 157 | and with any "id" fields along the way replacing the prior 158 | segment of the path 159 | 160 | For example, it will make "foo.bar.id" return a datum 161 | that behaves like DatumInContext(value="foo.bar", path="foo.bar.id"). 162 | 163 | This is disabled by default; it can be turned on by 164 | settings the `auto_id_field` global to a value other 165 | than `None`. 166 | """ 167 | 168 | def __init__(self, datum, id_field=None): 169 | """ 170 | Invariant is that datum.path is the path from context to datum. The auto id 171 | will either be the id in the datum (if present) or the id of the context 172 | followed by the path to the datum. 173 | 174 | The path to this datum is always the path to the context, the path to the 175 | datum, and then the auto id field. 176 | """ 177 | self.datum = datum 178 | self.id_field = id_field or auto_id_field 179 | 180 | @property 181 | def value(self): 182 | return str(self.datum.id_pseudopath) 183 | 184 | @property 185 | def path(self): 186 | return self.id_field 187 | 188 | @property 189 | def context(self): 190 | return self.datum 191 | 192 | def __repr__(self): 193 | return '%s(%r)' % (self.__class__.__name__, self.datum) 194 | 195 | def in_context(self, context, path): 196 | return AutoIdForDatum(self.datum.in_context(context=context, path=path)) 197 | 198 | def __eq__(self, other): 199 | return isinstance(other, AutoIdForDatum) and other.datum == self.datum and self.id_field == other.id_field 200 | 201 | 202 | class Root(JSONPath): 203 | """ 204 | The JSONPath referring to the "root" object. Concrete syntax is '$'. 205 | The root is the topmost datum without any context attached. 206 | """ 207 | 208 | def find(self, data) -> List[DatumInContext]: 209 | if not isinstance(data, DatumInContext): 210 | return [DatumInContext(data, path=Root(), context=None)] 211 | else: 212 | if data.context is None: 213 | return [DatumInContext(data.value, context=None, path=Root())] 214 | else: 215 | return Root().find(data.context) 216 | 217 | def update(self, data, val): 218 | return val 219 | 220 | def filter(self, fn, data): 221 | return data if fn(data) else None 222 | 223 | def __str__(self): 224 | return '$' 225 | 226 | def __repr__(self): 227 | return 'Root()' 228 | 229 | def __eq__(self, other): 230 | return isinstance(other, Root) 231 | 232 | def __hash__(self): 233 | return hash('$') 234 | 235 | 236 | class This(JSONPath): 237 | """ 238 | The JSONPath referring to the current datum. Concrete syntax is '@'. 239 | """ 240 | 241 | def find(self, datum): 242 | return [DatumInContext.wrap(datum)] 243 | 244 | def update(self, data, val): 245 | return val 246 | 247 | def filter(self, fn, data): 248 | return data if fn(data) else None 249 | 250 | def __str__(self): 251 | return '`this`' 252 | 253 | def __repr__(self): 254 | return 'This()' 255 | 256 | def __eq__(self, other): 257 | return isinstance(other, This) 258 | 259 | def __hash__(self): 260 | return hash('this') 261 | 262 | 263 | class Child(JSONPath): 264 | """ 265 | JSONPath that first matches the left, then the right. 266 | Concrete syntax is '.' 267 | """ 268 | 269 | def __init__(self, left, right): 270 | self.left = left 271 | self.right = right 272 | 273 | def find(self, datum): 274 | """ 275 | Extra special case: auto ids do not have children, 276 | so cut it off right now rather than auto id the auto id 277 | """ 278 | 279 | return [submatch 280 | for subdata in self.left.find(datum) 281 | if not isinstance(subdata, AutoIdForDatum) 282 | for submatch in self.right.find(subdata)] 283 | 284 | def update(self, data, val): 285 | for datum in self.left.find(data): 286 | self.right.update(datum.value, val) 287 | return data 288 | 289 | def find_or_create(self, datum): 290 | datum = DatumInContext.wrap(datum) 291 | submatches = [] 292 | for subdata in self.left.find_or_create(datum): 293 | if isinstance(subdata, AutoIdForDatum): 294 | # Extra special case: auto ids do not have children, 295 | # so cut it off right now rather than auto id the auto id 296 | continue 297 | for submatch in self.right.find_or_create(subdata): 298 | submatches.append(submatch) 299 | return submatches 300 | 301 | def update_or_create(self, data, val): 302 | for datum in self.left.find_or_create(data): 303 | self.right.update_or_create(datum.value, val) 304 | return _clean_list_keys(data) 305 | 306 | def filter(self, fn, data): 307 | for datum in self.left.find(data): 308 | self.right.filter(fn, datum.value) 309 | return data 310 | 311 | def __eq__(self, other): 312 | return isinstance(other, Child) and self.left == other.left and self.right == other.right 313 | 314 | def __str__(self): 315 | return '%s.%s' % (self.left, self.right) 316 | 317 | def __repr__(self): 318 | return '%s(%r, %r)' % (self.__class__.__name__, self.left, self.right) 319 | 320 | def __hash__(self): 321 | return hash((self.left, self.right)) 322 | 323 | 324 | class Parent(JSONPath): 325 | """ 326 | JSONPath that matches the parent node of the current match. 327 | Will crash if no such parent exists. 328 | Available via named operator `parent`. 329 | """ 330 | 331 | def find(self, datum): 332 | datum = DatumInContext.wrap(datum) 333 | return [datum.context] 334 | 335 | def __eq__(self, other): 336 | return isinstance(other, Parent) 337 | 338 | def __str__(self): 339 | return '`parent`' 340 | 341 | def __repr__(self): 342 | return 'Parent()' 343 | 344 | def __hash__(self): 345 | return hash('parent') 346 | 347 | 348 | class Where(JSONPath): 349 | """ 350 | JSONPath that first matches the left, and then 351 | filters for only those nodes that have 352 | a match on the right. 353 | 354 | WARNING: Subject to change. May want to have "contains" 355 | or some other better word for it. 356 | """ 357 | 358 | def __init__(self, left, right): 359 | self.left = left 360 | self.right = right 361 | 362 | def find(self, data): 363 | return [subdata for subdata in self.left.find(data) if self.right.find(subdata)] 364 | 365 | def update(self, data, val): 366 | for datum in self.find(data): 367 | datum.path.update(data, val) 368 | return data 369 | 370 | def filter(self, fn, data): 371 | for datum in self.find(data): 372 | datum.path.filter(fn, datum.value) 373 | return data 374 | 375 | def __str__(self): 376 | return '%s where %s' % (self.left, self.right) 377 | 378 | def __eq__(self, other): 379 | return isinstance(other, Where) and other.left == self.left and other.right == self.right 380 | 381 | def __hash__(self): 382 | return hash((self.left, self.right)) 383 | 384 | 385 | class WhereNot(Where): 386 | """ 387 | Identical to ``Where``, but filters for only those nodes that 388 | do *not* have a match on the right. 389 | 390 | >>> jsonpath = WhereNot(Fields('spam'), Fields('spam')) 391 | >>> jsonpath.find({"spam": {"spam": 1}}) 392 | [] 393 | >>> matches = jsonpath.find({"spam": 1}) 394 | >>> matches[0].value 395 | 1 396 | 397 | """ 398 | def find(self, data): 399 | return [subdata for subdata in self.left.find(data) 400 | if not self.right.find(subdata)] 401 | 402 | def __str__(self): 403 | return '%s wherenot %s' % (self.left, self.right) 404 | 405 | def __eq__(self, other): 406 | return (isinstance(other, WhereNot) 407 | and other.left == self.left 408 | and other.right == self.right) 409 | 410 | def __hash__(self): 411 | return hash((self.left, self.right)) 412 | 413 | 414 | class Descendants(JSONPath): 415 | """ 416 | JSONPath that matches first the left expression then any descendant 417 | of it which matches the right expression. 418 | """ 419 | 420 | def __init__(self, left, right): 421 | self.left = left 422 | self.right = right 423 | 424 | def find(self, datum): 425 | # .. ==> . ( | *.. | [*]..) 426 | # 427 | # With with a wonky caveat that since Slice() has funky coercions 428 | # we cannot just delegate to that equivalence or we'll hit an 429 | # infinite loop. So right here we implement the coercion-free version. 430 | 431 | # Get all left matches into a list 432 | left_matches = self.left.find(datum) 433 | if not isinstance(left_matches, list): 434 | left_matches = [left_matches] 435 | 436 | def match_recursively(datum): 437 | right_matches = self.right.find(datum) 438 | 439 | # Manually do the * or [*] to avoid coercion and recurse just the right-hand pattern 440 | if isinstance(datum.value, list): 441 | recursive_matches = [submatch 442 | for i in range(0, len(datum.value)) 443 | for submatch in match_recursively(DatumInContext(datum.value[i], context=datum, path=Index(i)))] 444 | 445 | elif isinstance(datum.value, dict): 446 | recursive_matches = [submatch 447 | for field in datum.value.keys() 448 | for submatch in match_recursively(DatumInContext(datum.value[field], context=datum, path=Fields(field)))] 449 | 450 | else: 451 | recursive_matches = [] 452 | 453 | return right_matches + list(recursive_matches) 454 | 455 | # TODO: repeatable iterator instead of list? 456 | return [submatch 457 | for left_match in left_matches 458 | for submatch in match_recursively(left_match)] 459 | 460 | def is_singular(self): 461 | return False 462 | 463 | def update(self, data, val): 464 | # Get all left matches into a list 465 | left_matches = self.left.find(data) 466 | if not isinstance(left_matches, list): 467 | left_matches = [left_matches] 468 | 469 | def update_recursively(data): 470 | # Update only mutable values corresponding to JSON types 471 | if not (isinstance(data, list) or isinstance(data, dict)): 472 | return 473 | 474 | self.right.update(data, val) 475 | 476 | # Manually do the * or [*] to avoid coercion and recurse just the right-hand pattern 477 | if isinstance(data, list): 478 | for i in range(0, len(data)): 479 | update_recursively(data[i]) 480 | 481 | elif isinstance(data, dict): 482 | for field in data.keys(): 483 | update_recursively(data[field]) 484 | 485 | for submatch in left_matches: 486 | update_recursively(submatch.value) 487 | 488 | return data 489 | 490 | def filter(self, fn, data): 491 | # Get all left matches into a list 492 | left_matches = self.left.find(data) 493 | if not isinstance(left_matches, list): 494 | left_matches = [left_matches] 495 | 496 | def filter_recursively(data): 497 | # Update only mutable values corresponding to JSON types 498 | if not (isinstance(data, list) or isinstance(data, dict)): 499 | return 500 | 501 | self.right.filter(fn, data) 502 | 503 | # Manually do the * or [*] to avoid coercion and recurse just the right-hand pattern 504 | if isinstance(data, list): 505 | for i in range(0, len(data)): 506 | filter_recursively(data[i]) 507 | 508 | elif isinstance(data, dict): 509 | for field in data.keys(): 510 | filter_recursively(data[field]) 511 | 512 | for submatch in left_matches: 513 | filter_recursively(submatch.value) 514 | 515 | return data 516 | 517 | def __str__(self): 518 | return '%s..%s' % (self.left, self.right) 519 | 520 | def __eq__(self, other): 521 | return isinstance(other, Descendants) and self.left == other.left and self.right == other.right 522 | 523 | def __repr__(self): 524 | return '%s(%r, %r)' % (self.__class__.__name__, self.left, self.right) 525 | 526 | def __hash__(self): 527 | return hash((self.left, self.right)) 528 | 529 | 530 | class Union(JSONPath): 531 | """ 532 | JSONPath that returns the union of the results of each match. 533 | This is pretty shoddily implemented for now. The nicest semantics 534 | in case of mismatched bits (list vs atomic) is to put 535 | them all in a list, but I haven't done that yet. 536 | 537 | WARNING: Any appearance of this being the _concatenation_ is 538 | coincidence. It may even be a bug! (or laziness) 539 | """ 540 | def __init__(self, left, right): 541 | self.left = left 542 | self.right = right 543 | 544 | def is_singular(self): 545 | return False 546 | 547 | def find(self, data): 548 | return self.left.find(data) + self.right.find(data) 549 | 550 | def __eq__(self, other): 551 | return isinstance(other, Union) and self.left == other.left and self.right == other.right 552 | 553 | def __hash__(self): 554 | return hash((self.left, self.right)) 555 | 556 | class Intersect(JSONPath): 557 | """ 558 | JSONPath for bits that match *both* patterns. 559 | 560 | This can be accomplished a couple of ways. The most 561 | efficient is to actually build the intersected 562 | AST as in building a state machine for matching the 563 | intersection of regular languages. The next 564 | idea is to build a filtered data and match against 565 | that. 566 | """ 567 | def __init__(self, left, right): 568 | self.left = left 569 | self.right = right 570 | 571 | def is_singular(self): 572 | return False 573 | 574 | def find(self, data): 575 | raise NotImplementedError() 576 | 577 | def __eq__(self, other): 578 | return isinstance(other, Intersect) and self.left == other.left and self.right == other.right 579 | 580 | def __hash__(self): 581 | return hash((self.left, self.right)) 582 | 583 | 584 | class Fields(JSONPath): 585 | """ 586 | JSONPath referring to some field of the current object. 587 | Concrete syntax ix comma-separated field names. 588 | 589 | WARNING: If '*' is any of the field names, then they will 590 | all be returned. 591 | """ 592 | 593 | def __init__(self, *fields): 594 | self.fields = fields 595 | 596 | @staticmethod 597 | def get_field_datum(datum, field, create): 598 | if field == auto_id_field: 599 | return AutoIdForDatum(datum) 600 | try: 601 | field_value = datum.value.get(field, NOT_SET) 602 | if field_value is NOT_SET: 603 | if create: 604 | datum.value[field] = field_value = {} 605 | else: 606 | return None 607 | return DatumInContext(field_value, path=Fields(field), context=datum) 608 | except (TypeError, AttributeError): 609 | return None 610 | 611 | def reified_fields(self, datum): 612 | if '*' not in self.fields: 613 | return self.fields 614 | else: 615 | try: 616 | fields = tuple(datum.value.keys()) 617 | return fields if auto_id_field is None else fields + (auto_id_field,) 618 | except AttributeError: 619 | return () 620 | 621 | def find(self, datum): 622 | return self._find_base(datum, create=False) 623 | 624 | def find_or_create(self, datum): 625 | return self._find_base(datum, create=True) 626 | 627 | def _find_base(self, datum, create): 628 | datum = DatumInContext.wrap(datum) 629 | field_data = [self.get_field_datum(datum, field, create) 630 | for field in self.reified_fields(datum)] 631 | return [fd for fd in field_data if fd is not None] 632 | 633 | def update(self, data, val): 634 | return self._update_base(data, val, create=False) 635 | 636 | def update_or_create(self, data, val): 637 | return self._update_base(data, val, create=True) 638 | 639 | def _update_base(self, data, val, create): 640 | if data is not None: 641 | for field in self.reified_fields(DatumInContext.wrap(data)): 642 | if create and field not in data: 643 | data[field] = {} 644 | if type(data) is not bool and field in data: 645 | if hasattr(val, '__call__'): 646 | data[field] = val(data[field], data, field) 647 | else: 648 | data[field] = val 649 | return data 650 | 651 | def filter(self, fn, data): 652 | if data is not None: 653 | for field in self.reified_fields(DatumInContext.wrap(data)): 654 | if field in data: 655 | if fn(data[field]): 656 | data.pop(field) 657 | return data 658 | 659 | def __str__(self): 660 | # If any JsonPathLexer.literals are included in field name need quotes 661 | # This avoids unnecessary quotes to keep strings short. 662 | # Test each field whether it contains a literal and only then add quotes 663 | # The test loops over all literals, could possibly optimize to short circuit if one found 664 | fields_as_str = ("'" + str(f) + "'" if any([l in f for l in JsonPathLexer.literals]) else 665 | str(f) for f in self.fields) 666 | return ','.join(fields_as_str) 667 | 668 | 669 | def __repr__(self): 670 | return '%s(%s)' % (self.__class__.__name__, ','.join(map(repr, self.fields))) 671 | 672 | def __eq__(self, other): 673 | return isinstance(other, Fields) and tuple(self.fields) == tuple(other.fields) 674 | 675 | def __hash__(self): 676 | return hash(tuple(self.fields)) 677 | 678 | 679 | class Index(JSONPath): 680 | """ 681 | JSONPath that matches indices of the current datum, or none if not large enough. 682 | Concrete syntax is brackets. 683 | 684 | WARNING: If the datum is None or not long enough, it will not crash but will not match anything. 685 | NOTE: For the concrete syntax of `[*]`, the abstract syntax is a Slice() with no parameters (equiv to `[:]` 686 | """ 687 | 688 | def __init__(self, *indices): 689 | self.indices = indices 690 | 691 | def find(self, datum): 692 | return self._find_base(datum, create=False) 693 | 694 | def find_or_create(self, datum): 695 | return self._find_base(datum, create=True) 696 | 697 | def _find_base(self, datum, create): 698 | datum = DatumInContext.wrap(datum) 699 | if create: 700 | if datum.value == {}: 701 | datum.value = _create_list_key(datum.value) 702 | self._pad_value(datum.value) 703 | rv = [] 704 | for index in self.indices: 705 | # invalid indices do not crash, return [] instead 706 | if datum.value and len(datum.value) > index: 707 | rv += [DatumInContext(datum.value[index], path=Index(index), context=datum)] 708 | return rv 709 | 710 | def update(self, data, val): 711 | return self._update_base(data, val, create=False) 712 | 713 | def update_or_create(self, data, val): 714 | return self._update_base(data, val, create=True) 715 | 716 | def _update_base(self, data, val, create): 717 | if create: 718 | if data == {}: 719 | data = _create_list_key(data) 720 | self._pad_value(data) 721 | if hasattr(val, '__call__'): 722 | for index in self.indices: 723 | val.__call__(data[index], data, index) 724 | else: 725 | for index in self.indices: 726 | if len(data) > index: 727 | try: 728 | if isinstance(val, list): 729 | # allows somelist[5,1,2] = [some_value, another_value, third_value] 730 | data[index] = val.pop(0) 731 | else: 732 | data[index] = val 733 | except Exception as e: 734 | raise e 735 | return data 736 | 737 | def filter(self, fn, data): 738 | for index in self.indices: 739 | if fn(data[index]): 740 | data.pop(index) # relies on mutation :( 741 | return data 742 | 743 | def __eq__(self, other): 744 | return isinstance(other, Index) and sorted(self.indices) == sorted(other.indices) 745 | 746 | def __str__(self): 747 | return '[%i]' % self.indices 748 | 749 | def __repr__(self): 750 | return '%s(indices=%r)' % (self.__class__.__name__, self.indices) 751 | 752 | def _pad_value(self, value): 753 | _max = max(self.indices) 754 | if len(value) <= _max: 755 | pad = _max - len(value) + 1 756 | value += [{} for __ in range(pad)] 757 | 758 | def __hash__(self): 759 | return hash(self.index) 760 | 761 | 762 | class Slice(JSONPath): 763 | """ 764 | JSONPath matching a slice of an array. 765 | 766 | Because of a mismatch between JSON and XML when schema-unaware, 767 | this always returns an iterable; if the incoming data 768 | was not a list, then it returns a one element list _containing_ that 769 | data. 770 | 771 | Consider these two docs, and their schema-unaware translation to JSON: 772 | 773 | hello ==> {"a": {"b": "hello"}} 774 | hellogoodbye ==> {"a": {"b": ["hello", "goodbye"]}} 775 | 776 | If there were a schema, it would be known that "b" should always be an 777 | array (unless the schema were wonky, but that is too much to fix here) 778 | so when querying with JSON if the one writing the JSON knows that it 779 | should be an array, they can write a slice operator and it will coerce 780 | a non-array value to an array. 781 | 782 | This may be a bit unfortunate because it would be nice to always have 783 | an iterator, but dictionaries and other objects may also be iterable, 784 | so this is the compromise. 785 | """ 786 | def __init__(self, start=None, end=None, step=None): 787 | self.start = start 788 | self.end = end 789 | self.step = step 790 | 791 | def find(self, datum): 792 | datum = DatumInContext.wrap(datum) 793 | 794 | # Used for catching null value instead of empty list in path 795 | if not datum.value: 796 | return [] 797 | # Here's the hack. If it is a dictionary or some kind of constant, 798 | # put it in a single-element list 799 | if (isinstance(datum.value, dict) or isinstance(datum.value, int) or isinstance(datum.value, str)): 800 | return self.find(DatumInContext([datum.value], path=datum.path, context=datum.context)) 801 | 802 | # Some iterators do not support slicing but we can still 803 | # at least work for '*' 804 | if self.start is None and self.end is None and self.step is None: 805 | return [DatumInContext(datum.value[i], path=Index(i), context=datum) for i in range(0, len(datum.value))] 806 | else: 807 | return [DatumInContext(datum.value[i], path=Index(i), context=datum) for i in range(0, len(datum.value))[self.start:self.end:self.step]] 808 | 809 | def update(self, data, val): 810 | for datum in self.find(data): 811 | datum.path.update(data, val) 812 | return data 813 | 814 | def filter(self, fn, data): 815 | while True: 816 | length = len(data) 817 | for datum in self.find(data): 818 | data = datum.path.filter(fn, data) 819 | if len(data) < length: 820 | break 821 | 822 | if length == len(data): 823 | break 824 | return data 825 | 826 | def __str__(self): 827 | if self.start is None and self.end is None and self.step is None: 828 | return '[*]' 829 | else: 830 | return '[%s%s%s]' % (self.start or '', 831 | ':%d'%self.end if self.end else '', 832 | ':%d'%self.step if self.step else '') 833 | 834 | def __repr__(self): 835 | return '%s(start=%r,end=%r,step=%r)' % (self.__class__.__name__, self.start, self.end, self.step) 836 | 837 | def __eq__(self, other): 838 | return isinstance(other, Slice) and other.start == self.start and self.end == other.end and other.step == self.step 839 | 840 | def __hash__(self): 841 | return hash((self.start, self.end, self.step)) 842 | 843 | 844 | def _create_list_key(dict_): 845 | """ 846 | Adds a list to a dictionary by reference and returns the list. 847 | 848 | See `_clean_list_keys()` 849 | """ 850 | dict_[LIST_KEY] = new_list = [{}] 851 | return new_list 852 | 853 | 854 | def _clean_list_keys(struct_): 855 | """ 856 | Replace {LIST_KEY: ['foo', 'bar']} with ['foo', 'bar']. 857 | 858 | >>> _clean_list_keys({LIST_KEY: ['foo', 'bar']}) 859 | ['foo', 'bar'] 860 | 861 | """ 862 | if(isinstance(struct_, list)): 863 | for ind, value in enumerate(struct_): 864 | struct_[ind] = _clean_list_keys(value) 865 | elif(isinstance(struct_, dict)): 866 | if(LIST_KEY in struct_): 867 | return _clean_list_keys(struct_[LIST_KEY]) 868 | else: 869 | for key, value in struct_.items(): 870 | struct_[key] = _clean_list_keys(value) 871 | return struct_ 872 | -------------------------------------------------------------------------------- /jsonpath_ng/lexer.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import logging 3 | 4 | import ply.lex 5 | 6 | from jsonpath_ng.exceptions import JsonPathLexerError 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class JsonPathLexer: 12 | ''' 13 | A Lexical analyzer for JsonPath. 14 | ''' 15 | 16 | def __init__(self, debug=False): 17 | self.debug = debug 18 | if self.__doc__ is None: 19 | raise JsonPathLexerError('Docstrings have been removed! By design of PLY, jsonpath-rw requires docstrings. You must not use PYTHONOPTIMIZE=2 or python -OO.') 20 | 21 | def tokenize(self, string): 22 | ''' 23 | Maps a string to an iterator over tokens. In other words: [char] -> [token] 24 | ''' 25 | 26 | new_lexer = ply.lex.lex(module=self, debug=self.debug, errorlog=logger) 27 | new_lexer.latest_newline = 0 28 | new_lexer.string_value = None 29 | new_lexer.input(string) 30 | 31 | while True: 32 | t = new_lexer.token() 33 | if t is None: 34 | break 35 | t.col = t.lexpos - new_lexer.latest_newline 36 | yield t 37 | 38 | if new_lexer.string_value is not None: 39 | raise JsonPathLexerError('Unexpected EOF in string literal or identifier') 40 | 41 | # ============== PLY Lexer specification ================== 42 | # 43 | # This probably should be private but: 44 | # - the parser requires access to `tokens` (perhaps they should be defined in a third, shared dependency) 45 | # - things like `literals` might be a legitimate part of the public interface. 46 | # 47 | # Anyhow, it is pythonic to give some rope to hang oneself with :-) 48 | 49 | literals = ['*', '.', '[', ']', '(', ')', '$', ',', ':', '|', '&', '~'] 50 | 51 | reserved_words = { 52 | 'where': 'WHERE', 53 | 'wherenot': 'WHERENOT', 54 | } 55 | 56 | tokens = ['DOUBLEDOT', 'NUMBER', 'ID', 'NAMED_OPERATOR'] + list(reserved_words.values()) 57 | 58 | states = [ ('singlequote', 'exclusive'), 59 | ('doublequote', 'exclusive'), 60 | ('backquote', 'exclusive') ] 61 | 62 | # Normal lexing, rather easy 63 | t_DOUBLEDOT = r'\.\.' 64 | t_ignore = ' \t' 65 | 66 | def t_ID(self, t): 67 | # CJK: [\u4E00-\u9FA5] 68 | # EMOJI: [\U0001F600-\U0001F64F] 69 | r'([a-zA-Z_@]|[\u4E00-\u9FA5]|[\U0001F600-\U0001F64F])([a-zA-Z0-9_@\-]|[\u4E00-\u9FA5]|[\U0001F600-\U0001F64F])*' 70 | t.type = self.reserved_words.get(t.value, 'ID') 71 | return t 72 | 73 | def t_NUMBER(self, t): 74 | r'-?\d+' 75 | t.value = int(t.value) 76 | return t 77 | 78 | 79 | # Single-quoted strings 80 | t_singlequote_ignore = '' 81 | def t_singlequote(self, t): 82 | r"'" 83 | t.lexer.string_start = t.lexer.lexpos 84 | t.lexer.string_value = '' 85 | t.lexer.push_state('singlequote') 86 | 87 | def t_singlequote_content(self, t): 88 | r"[^'\\]+" 89 | t.lexer.string_value += t.value 90 | 91 | def t_singlequote_escape(self, t): 92 | r'\\.' 93 | t.lexer.string_value += t.value[1] 94 | 95 | def t_singlequote_end(self, t): 96 | r"'" 97 | t.value = t.lexer.string_value 98 | t.type = 'ID' 99 | t.lexer.string_value = None 100 | t.lexer.pop_state() 101 | return t 102 | 103 | def t_singlequote_error(self, t): 104 | raise JsonPathLexerError('Error on line %s, col %s while lexing singlequoted field: Unexpected character: %s ' % (t.lexer.lineno, t.lexpos - t.lexer.latest_newline, t.value[0])) 105 | 106 | 107 | # Double-quoted strings 108 | t_doublequote_ignore = '' 109 | def t_doublequote(self, t): 110 | r'"' 111 | t.lexer.string_start = t.lexer.lexpos 112 | t.lexer.string_value = '' 113 | t.lexer.push_state('doublequote') 114 | 115 | def t_doublequote_content(self, t): 116 | r'[^"\\]+' 117 | t.lexer.string_value += t.value 118 | 119 | def t_doublequote_escape(self, t): 120 | r'\\.' 121 | t.lexer.string_value += t.value[1] 122 | 123 | def t_doublequote_end(self, t): 124 | r'"' 125 | t.value = t.lexer.string_value 126 | t.type = 'ID' 127 | t.lexer.string_value = None 128 | t.lexer.pop_state() 129 | return t 130 | 131 | def t_doublequote_error(self, t): 132 | raise JsonPathLexerError('Error on line %s, col %s while lexing doublequoted field: Unexpected character: %s ' % (t.lexer.lineno, t.lexpos - t.lexer.latest_newline, t.value[0])) 133 | 134 | 135 | # Back-quoted "magic" operators 136 | t_backquote_ignore = '' 137 | def t_backquote(self, t): 138 | r'`' 139 | t.lexer.string_start = t.lexer.lexpos 140 | t.lexer.string_value = '' 141 | t.lexer.push_state('backquote') 142 | 143 | def t_backquote_escape(self, t): 144 | r'\\.' 145 | t.lexer.string_value += t.value[1] 146 | 147 | def t_backquote_content(self, t): 148 | r"[^`\\]+" 149 | t.lexer.string_value += t.value 150 | 151 | def t_backquote_end(self, t): 152 | r'`' 153 | t.value = t.lexer.string_value 154 | t.type = 'NAMED_OPERATOR' 155 | t.lexer.string_value = None 156 | t.lexer.pop_state() 157 | return t 158 | 159 | def t_backquote_error(self, t): 160 | raise JsonPathLexerError('Error on line %s, col %s while lexing backquoted operator: Unexpected character: %s ' % (t.lexer.lineno, t.lexpos - t.lexer.latest_newline, t.value[0])) 161 | 162 | 163 | # Counting lines, handling errors 164 | def t_newline(self, t): 165 | r'\n' 166 | t.lexer.lineno += 1 167 | t.lexer.latest_newline = t.lexpos 168 | 169 | def t_error(self, t): 170 | raise JsonPathLexerError('Error on line %s, col %s: Unexpected character: %s ' % (t.lexer.lineno, t.lexpos - t.lexer.latest_newline, t.value[0])) 171 | 172 | if __name__ == '__main__': 173 | logging.basicConfig() 174 | lexer = JsonPathLexer(debug=True) 175 | for token in lexer.tokenize(sys.stdin.read()): 176 | print('%-20s%s' % (token.value, token.type)) 177 | -------------------------------------------------------------------------------- /jsonpath_ng/parser.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | import os.path 4 | 5 | import ply.yacc 6 | 7 | from jsonpath_ng.exceptions import JsonPathParserError 8 | from jsonpath_ng.jsonpath import * 9 | from jsonpath_ng.lexer import JsonPathLexer 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def parse(string): 15 | return JsonPathParser().parse(string) 16 | 17 | 18 | class JsonPathParser: 19 | ''' 20 | An LALR-parser for JsonPath 21 | ''' 22 | 23 | tokens = JsonPathLexer.tokens 24 | 25 | def __init__(self, debug=False, lexer_class=None): 26 | if self.__doc__ is None: 27 | raise JsonPathParserError( 28 | 'Docstrings have been removed! By design of PLY, ' 29 | 'jsonpath-rw requires docstrings. You must not use ' 30 | 'PYTHONOPTIMIZE=2 or python -OO.' 31 | ) 32 | 33 | self.debug = debug 34 | self.lexer_class = lexer_class or JsonPathLexer # Crufty but works around statefulness in PLY 35 | 36 | # Since PLY has some crufty aspects and dumps files, we try to keep them local 37 | # However, we need to derive the name of the output Python file :-/ 38 | output_directory = os.path.dirname(__file__) 39 | try: 40 | module_name = os.path.splitext(os.path.split(__file__)[1])[0] 41 | except: 42 | module_name = __name__ 43 | 44 | start_symbol = 'jsonpath' 45 | parsing_table_module = '_'.join([module_name, start_symbol, 'parsetab']) 46 | 47 | # Generate the parse table 48 | self.parser = ply.yacc.yacc(module=self, 49 | debug=self.debug, 50 | tabmodule = parsing_table_module, 51 | outputdir = output_directory, 52 | write_tables=0, 53 | start = start_symbol, 54 | errorlog = logger) 55 | 56 | def parse(self, string, lexer = None) -> JSONPath: 57 | lexer = lexer or self.lexer_class() 58 | return self.parse_token_stream(lexer.tokenize(string)) 59 | 60 | def parse_token_stream(self, token_iterator): 61 | return self.parser.parse(lexer = IteratorToTokenStream(token_iterator)) 62 | 63 | # ===================== PLY Parser specification ===================== 64 | 65 | precedence = [ 66 | ('left', ','), 67 | ('left', 'DOUBLEDOT'), 68 | ('left', '.'), 69 | ('left', '|'), 70 | ('left', '&'), 71 | ('left', 'WHERE'), 72 | ('left', 'WHERENOT'), 73 | ] 74 | 75 | def p_error(self, t): 76 | if t is None: 77 | raise JsonPathParserError('Parse error near the end of string!') 78 | raise JsonPathParserError('Parse error at %s:%s near token %s (%s)' 79 | % (t.lineno, t.col, t.value, t.type)) 80 | 81 | def p_jsonpath_binop(self, p): 82 | """jsonpath : jsonpath '.' jsonpath 83 | | jsonpath DOUBLEDOT jsonpath 84 | | jsonpath WHERE jsonpath 85 | | jsonpath WHERENOT jsonpath 86 | | jsonpath '|' jsonpath 87 | | jsonpath '&' jsonpath""" 88 | op = p[2] 89 | 90 | if op == '.': 91 | p[0] = Child(p[1], p[3]) 92 | elif op == '..': 93 | p[0] = Descendants(p[1], p[3]) 94 | elif op == 'where': 95 | p[0] = Where(p[1], p[3]) 96 | elif op == 'wherenot': 97 | p[0] = WhereNot(p[1], p[3]) 98 | elif op == '|': 99 | p[0] = Union(p[1], p[3]) 100 | elif op == '&': 101 | p[0] = Intersect(p[1], p[3]) 102 | 103 | def p_jsonpath_fields(self, p): 104 | "jsonpath : fields_or_any" 105 | p[0] = Fields(*p[1]) 106 | 107 | def p_jsonpath_named_operator(self, p): 108 | "jsonpath : NAMED_OPERATOR" 109 | if p[1] == 'this': 110 | p[0] = This() 111 | elif p[1] == 'parent': 112 | p[0] = Parent() 113 | else: 114 | raise JsonPathParserError('Unknown named operator `%s` at %s:%s' 115 | % (p[1], p.lineno(1), p.lexpos(1))) 116 | 117 | def p_jsonpath_root(self, p): 118 | "jsonpath : '$'" 119 | p[0] = Root() 120 | 121 | def p_jsonpath_idx(self, p): 122 | "jsonpath : '[' idx ']'" 123 | p[0] = Index(*p[2]) 124 | 125 | def p_jsonpath_slice(self, p): 126 | "jsonpath : '[' slice ']'" 127 | p[0] = p[2] 128 | 129 | def p_jsonpath_fieldbrackets(self, p): 130 | "jsonpath : '[' fields ']'" 131 | p[0] = Fields(*p[2]) 132 | 133 | def p_jsonpath_child_fieldbrackets(self, p): 134 | "jsonpath : jsonpath '[' fields ']'" 135 | p[0] = Child(p[1], Fields(*p[3])) 136 | 137 | def p_jsonpath_child_idxbrackets(self, p): 138 | "jsonpath : jsonpath '[' idx ']'" 139 | p[0] = Child(p[1], Index(*p[3])) 140 | 141 | def p_jsonpath_child_slicebrackets(self, p): 142 | "jsonpath : jsonpath '[' slice ']'" 143 | p[0] = Child(p[1], p[3]) 144 | 145 | def p_jsonpath_parens(self, p): 146 | "jsonpath : '(' jsonpath ')'" 147 | p[0] = p[2] 148 | 149 | # Because fields in brackets cannot be '*' - that is reserved for array indices 150 | def p_fields_or_any(self, p): 151 | """fields_or_any : fields 152 | | '*' 153 | | NUMBER""" 154 | if p[1] == '*': 155 | p[0] = ['*'] 156 | elif isinstance(p[1], int): 157 | p[0] = str(p[1]) 158 | else: 159 | p[0] = p[1] 160 | 161 | def p_fields_id(self, p): 162 | "fields : ID" 163 | p[0] = [p[1]] 164 | 165 | def p_fields_comma(self, p): 166 | "fields : fields ',' fields" 167 | p[0] = p[1] + p[3] 168 | 169 | def p_idx(self, p): 170 | "idx : NUMBER" 171 | p[0] = [p[1]] 172 | 173 | def p_idx_comma(self, p): 174 | "idx : idx ',' idx " 175 | p[0] = p[1] + p[3] 176 | 177 | def p_slice_any(self, p): 178 | "slice : '*'" 179 | p[0] = Slice() 180 | 181 | def p_slice(self, p): # Currently does not support `step` 182 | """slice : maybe_int ':' maybe_int 183 | | maybe_int ':' maybe_int ':' maybe_int """ 184 | p[0] = Slice(*p[1::2]) 185 | 186 | def p_maybe_int(self, p): 187 | """maybe_int : NUMBER 188 | | empty""" 189 | p[0] = p[1] 190 | 191 | def p_empty(self, p): 192 | 'empty :' 193 | p[0] = None 194 | 195 | class IteratorToTokenStream: 196 | def __init__(self, iterator): 197 | self.iterator = iterator 198 | 199 | def token(self): 200 | try: 201 | return next(self.iterator) 202 | except StopIteration: 203 | return None 204 | 205 | 206 | if __name__ == '__main__': 207 | logging.basicConfig() 208 | parser = JsonPathParser(debug=True) 209 | print(parser.parse(sys.stdin.read())) 210 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.pytest.ini_options] 2 | filterwarnings = [ 3 | # Escalate warnings to errors. 4 | "error", 5 | 6 | # The ply package doesn't close its debug log file. Ignore this warning. 7 | "ignore::ResourceWarning", 8 | ] 9 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | tox 2 | flake8 3 | pytest 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ply 2 | setuptools>=18.5 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import io 2 | import setuptools 3 | 4 | 5 | setuptools.setup( 6 | name='jsonpath-ng', 7 | version='1.7.0', 8 | description=( 9 | 'A final implementation of JSONPath for Python that aims to be ' 10 | 'standard compliant, including arithmetic and binary comparison ' 11 | 'operators and providing clear AST for metaprogramming.' 12 | ), 13 | author='Tomas Aparicio', 14 | author_email='tomas@aparicio.me', 15 | url='https://github.com/h2non/jsonpath-ng', 16 | license='Apache 2.0', 17 | long_description=io.open('README.rst', encoding='utf-8').read(), 18 | packages=['jsonpath_ng', 'jsonpath_ng.bin', 'jsonpath_ng.ext'], 19 | entry_points={ 20 | 'console_scripts': [ 21 | 'jsonpath_ng=jsonpath_ng.bin.jsonpath:entry_point' 22 | ], 23 | }, 24 | test_suite='tests', 25 | install_requires=[ 26 | 'ply' 27 | ], 28 | classifiers=[ 29 | 'Development Status :: 5 - Production/Stable', 30 | 'Intended Audience :: Developers', 31 | 'License :: OSI Approved :: Apache Software License', 32 | 'Programming Language :: Python :: 3', 33 | 'Programming Language :: Python :: 3.8', 34 | 'Programming Language :: Python :: 3.9', 35 | 'Programming Language :: Python :: 3.10', 36 | 'Programming Language :: Python :: 3.11', 37 | 'Programming Language :: Python :: 3.12', 38 | 'Programming Language :: Python :: 3.13', 39 | ], 40 | ) 41 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/h2non/jsonpath-ng/ca251d50a404aa5a608e42e800e8fa435338ad7e/tests/__init__.py -------------------------------------------------------------------------------- /tests/bin/test1.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": { 3 | "baz": 1, 4 | "bizzle": { 5 | "baz": 2 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /tests/bin/test2.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": { 3 | "foo": { 4 | "baz": 3, 5 | "merp": { 6 | "baz": 4 7 | } 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /tests/bin/test_jsonpath.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the jsonpath.py command line interface. 3 | """ 4 | 5 | import io 6 | import json 7 | import os 8 | import sys 9 | 10 | from jsonpath_ng.bin.jsonpath import main 11 | 12 | 13 | def test_stdin_mode(monkeypatch, capsys): 14 | stdin_text = json.dumps( 15 | { 16 | "foo": { 17 | "baz": 1, 18 | "bizzle": {"baz": 2}, 19 | }, 20 | } 21 | ) 22 | monkeypatch.setattr(sys, "stdin", io.StringIO(stdin_text)) 23 | 24 | main("jsonpath.py", "foo..baz") 25 | 26 | stdout, _ = capsys.readouterr() 27 | assert stdout == "1\n2\n" 28 | 29 | 30 | def test_filename_mode(capsys): 31 | test1 = os.path.join(os.path.dirname(__file__), "test1.json") 32 | test2 = os.path.join(os.path.dirname(__file__), "test2.json") 33 | 34 | main("jsonpath.py", "foo..baz", test1, test2) 35 | 36 | stdout, _ = capsys.readouterr() 37 | assert stdout == "1\n2\n3\n4\n" 38 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture(autouse=True) 5 | def disable_auto_id_field(monkeypatch): 6 | monkeypatch.setattr("jsonpath_ng.jsonpath.auto_id_field", None) 7 | 8 | 9 | @pytest.fixture() 10 | def auto_id_field(monkeypatch, disable_auto_id_field): 11 | """Enable `jsonpath_ng.jsonpath.auto_id_field`.""" 12 | 13 | field_name = "id" 14 | monkeypatch.setattr("jsonpath_ng.jsonpath.auto_id_field", field_name) 15 | return field_name 16 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | def assert_value_equality(results, expected_values): 2 | """Assert equality between two objects. 3 | 4 | *results* must be a list of results as returned by `.find()` methods. 5 | 6 | If *expected_values* is a list, then value equality and ordering will be checked. 7 | If *expected_values* is a set, value equality and container length will be checked. 8 | Otherwise, the value of the results will be compared to the expected values. 9 | """ 10 | 11 | left_values = [result.value for result in results] 12 | if isinstance(expected_values, list): 13 | assert left_values == expected_values 14 | elif isinstance(expected_values, set): 15 | assert len(left_values) == len(expected_values) 16 | assert set(left_values) == expected_values 17 | else: 18 | assert results[0].value == expected_values 19 | 20 | 21 | def assert_full_path_equality(results, expected_full_paths): 22 | """Assert equality between two objects. 23 | 24 | *results* must be a list or set of results as returned by `.find()` methods. 25 | 26 | If *expected_full_paths* is a list, then path equality and ordering will be checked. 27 | If *expected_full_paths* is a set, then path equality and length will be checked. 28 | """ 29 | 30 | full_paths = [str(result.full_path) for result in results] 31 | if isinstance(expected_full_paths, list): 32 | assert full_paths == expected_full_paths, full_paths 33 | else: # isinstance(expected_full_paths, set): 34 | assert len(full_paths) == len(expected_full_paths) 35 | assert set(full_paths) == expected_full_paths 36 | -------------------------------------------------------------------------------- /tests/test_create.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from contextlib import nullcontext as does_not_raise 3 | 4 | import pytest 5 | 6 | from jsonpath_ng.ext import parse 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "string, initial_data, expected_result", 11 | ( 12 | ("$.foo", {}, {"foo": 42}), 13 | ("$.foo.bar", {}, {"foo": {"bar": 42}}), 14 | ("$.foo[0]", {}, {"foo": [42]}), 15 | ("$.foo[1]", {}, {"foo": [{}, 42]}), 16 | ("$.foo[0].bar", {}, {"foo": [{"bar": 42}]}), 17 | ("$.foo[1].bar", {}, {"foo": [{}, {"bar": 42}]}), 18 | ("$.foo[0][0]", {}, {"foo": [[42]]}), 19 | ("$.foo[1][1]", {}, {"foo": [{}, [{}, 42]]}), 20 | ("foo[0]", {}, {"foo": [42]}), 21 | ("foo[1]", {}, {"foo": [{}, 42]}), 22 | ("foo", {}, {"foo": 42}), 23 | # 24 | # Initial data can be a list if we expect a list back. 25 | ("[0]", [], [42]), 26 | ("[1]", [], [{}, 42]), 27 | # 28 | # Convert initial data to a list, if necessary. 29 | ("[0]", {}, [42]), 30 | ("[1]", {}, [{}, 42]), 31 | # 32 | ( 33 | 'foo[?bar="baz"].qux', 34 | { 35 | "foo": [ 36 | {"bar": "baz"}, 37 | {"bar": "bizzle"}, 38 | ] 39 | }, 40 | {"foo": [{"bar": "baz", "qux": 42}, {"bar": "bizzle"}]}, 41 | ), 42 | ("[1].foo", [{"foo": 1}, {"bar": 2}], [{"foo": 1}, {"foo": 42, "bar": 2}]), 43 | ), 44 | ) 45 | def test_update_or_create(string, initial_data, expected_result): 46 | jsonpath = parse(string) 47 | result = jsonpath.update_or_create(initial_data, 42) 48 | assert result == expected_result 49 | 50 | 51 | @pytest.mark.parametrize( 52 | "string, initial_data, expectation", 53 | ( 54 | # Slice not supported 55 | ("foo[0:1]", {}, does_not_raise()), 56 | # 57 | # Filter does not create items to meet criteria 58 | ('foo[?bar="baz"].qux', {}, does_not_raise()), 59 | # 60 | # Does not convert initial data to a dictionary 61 | ("foo", [], pytest.raises(TypeError)), 62 | ), 63 | ) 64 | def test_unsupported_classes(string, initial_data, expectation): 65 | copied_initial_data = copy.copy(initial_data) 66 | jsonpath = parse(string) 67 | with expectation: 68 | result = jsonpath.update_or_create(initial_data, 42) 69 | assert result != copied_initial_data 70 | -------------------------------------------------------------------------------- /tests/test_examples.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from jsonpath_ng.ext import parse 4 | from jsonpath_ng.ext.filter import Expression, Filter 5 | from jsonpath_ng.jsonpath import Child, Descendants, Fields, Index, Root, Slice, This 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "string, parsed", 10 | [ 11 | # The authors of all books in the store 12 | ( 13 | "$.store.book[*].author", 14 | Child( 15 | Child(Child(Child(Root(), Fields("store")), Fields("book")), Slice()), 16 | Fields("author"), 17 | ), 18 | ), 19 | # 20 | # All authors 21 | ("$..author", Descendants(Root(), Fields("author"))), 22 | # 23 | # All things in the store 24 | ("$.store.*", Child(Child(Root(), Fields("store")), Fields("*"))), 25 | # 26 | # The price of everything in the store 27 | ( 28 | "$.store..price", 29 | Descendants(Child(Root(), Fields("store")), Fields("price")), 30 | ), 31 | # 32 | # The third book 33 | ("$..book[2]", Child(Descendants(Root(), Fields("book")), Index(2))), 34 | # 35 | # The last book in order 36 | # "$..book[(@.length-1)]" # Not implemented 37 | ("$..book[-1:]", Child(Descendants(Root(), Fields("book")), Slice(start=-1))), 38 | # 39 | # The first two books 40 | ("$..book[0,1]", Child(Descendants(Root(), Fields("book")), Index(0,1))), 41 | ("$..book[:2]", Child(Descendants(Root(), Fields("book")), Slice(end=2))), 42 | # 43 | # Categories and authors of all books 44 | ( 45 | "$..book[0][category,author]", 46 | Child(Child(Descendants(Root(), Fields('book')), Index(0)), Fields('category','author')), 47 | ), 48 | # 49 | # Filter all books with an ISBN 50 | ( 51 | "$..book[?(@.isbn)]", 52 | Child( 53 | Descendants(Root(), Fields("book")), 54 | Filter([Expression(Child(This(), Fields("isbn")), None, None)]), 55 | ), 56 | ), 57 | # 58 | # Filter all books cheaper than 10 59 | ( 60 | "$..book[?(@.price<10)]", 61 | Child( 62 | Descendants(Root(), Fields("book")), 63 | Filter([Expression(Child(This(), Fields("price")), "<", 10)]), 64 | ), 65 | ), 66 | # 67 | # All members of JSON structure 68 | ("$..*", Descendants(Root(), Fields("*"))), 69 | ], 70 | ) 71 | def test_goessner_examples(string, parsed): 72 | """ 73 | Test Stefan Goessner's `examples`_ 74 | 75 | .. _examples: https://goessner.net/articles/JsonPath/index.html#e3 76 | """ 77 | assert parse(string, debug=True) == parsed 78 | 79 | 80 | def test_attribute_and_dict_syntax(): 81 | """Verify that attribute and dict syntax result in identical parse trees.""" 82 | 83 | assert parse("$.store.book[0].title") == parse("$['store']['book'][0]['title']") 84 | -------------------------------------------------------------------------------- /tests/test_exceptions.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from jsonpath_ng import parse as base_parse 4 | from jsonpath_ng.exceptions import JsonPathParserError 5 | from jsonpath_ng.ext import parse as ext_parse 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "path", 10 | ( 11 | "foo[*.bar.baz", 12 | "foo.bar.`grandparent`.baz", 13 | "foo[*", 14 | # `len` extension not available in the base parser 15 | "foo.bar.`len`", 16 | ), 17 | ) 18 | def test_rw_exception_subclass(path): 19 | with pytest.raises(JsonPathParserError): 20 | base_parse(path) 21 | 22 | 23 | @pytest.mark.parametrize( 24 | "path", 25 | ( 26 | "foo[*.bar.baz", 27 | "foo.bar.`grandparent`.baz", 28 | "foo[*", 29 | ), 30 | ) 31 | def test_ext_exception_subclass(path): 32 | with pytest.raises(JsonPathParserError): 33 | ext_parse(path) 34 | -------------------------------------------------------------------------------- /tests/test_jsonpath.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | import pytest 4 | from typing import Callable 5 | from jsonpath_ng.ext.parser import parse as ext_parse 6 | from jsonpath_ng.jsonpath import DatumInContext, Fields, Root, This 7 | from jsonpath_ng.lexer import JsonPathLexerError 8 | from jsonpath_ng.parser import parse as base_parse 9 | from jsonpath_ng import JSONPath 10 | 11 | from .helpers import assert_full_path_equality, assert_value_equality 12 | 13 | 14 | @pytest.mark.parametrize( 15 | "path_arg, context_arg, expected_path, expected_full_path", 16 | ( 17 | (None, None, This(), This()), 18 | (Root(), None, Root(), Root()), 19 | (Fields("foo"), "unimportant", Fields("foo"), Fields("foo")), 20 | ( 21 | Fields("foo"), 22 | DatumInContext("unimportant", path=Fields("baz"), context="unimportant"), 23 | Fields("foo"), 24 | Fields("baz").child(Fields("foo")), 25 | ), 26 | ), 27 | ) 28 | def test_datumincontext_init(path_arg, context_arg, expected_path, expected_full_path): 29 | datum = DatumInContext(3, path=path_arg, context=context_arg) 30 | assert datum.path == expected_path 31 | assert datum.full_path == expected_full_path 32 | 33 | 34 | def test_datumincontext_in_context(): 35 | d1 = DatumInContext(3, path=Fields("foo"), context=DatumInContext("bar")) 36 | d2 = DatumInContext(3).in_context(path=Fields("foo"), context=DatumInContext("bar")) 37 | assert d1 == d2 38 | 39 | 40 | def test_datumincontext_in_context_nested(): 41 | sequential_calls = ( 42 | DatumInContext(3) 43 | .in_context(path=Fields("foo"), context="whatever") 44 | .in_context(path=Fields("baz"), context="whatever") 45 | ) 46 | nested_calls = DatumInContext(3).in_context( 47 | path=Fields("foo"), 48 | context=DatumInContext("whatever").in_context( 49 | path=Fields("baz"), context="whatever" 50 | ), 51 | ) 52 | assert sequential_calls == nested_calls 53 | 54 | 55 | parsers = pytest.mark.parametrize( 56 | "parse", 57 | ( 58 | pytest.param(base_parse, id="parse=jsonpath_ng.parser.parse"), 59 | pytest.param(ext_parse, id="parse=jsonpath_ng.ext.parser.parse"), 60 | ), 61 | ) 62 | 63 | 64 | update_test_cases = ( 65 | # 66 | # Fields 67 | # ------ 68 | # 69 | ("foo", {"foo": 1}, 5, {"foo": 5}), 70 | ("$.*", {"foo": 1, "bar": 2}, 3, {"foo": 3, "bar": 3}), 71 | # 72 | # Indexes 73 | # ------- 74 | # 75 | ("[0]", ["foo", "bar", "baz"], "test", ["test", "bar", "baz"]), 76 | ("[0, 1]", ["foo", "bar", "baz"], "test", ["test", "test", "baz"]), 77 | ("[0, 1]", ["foo", "bar", "baz"], ["test", "test 1"], ["test", "test 1", "baz"]), 78 | # 79 | # Slices 80 | # ------ 81 | # 82 | ("[0:2]", ["foo", "bar", "baz"], "test", ["test", "test", "baz"]), 83 | # 84 | # Root 85 | # ---- 86 | # 87 | ("$", "foo", "bar", "bar"), 88 | # 89 | # This 90 | # ---- 91 | # 92 | ("`this`", "foo", "bar", "bar"), 93 | # 94 | # Children 95 | # -------- 96 | # 97 | ("$.foo", {"foo": "bar"}, "baz", {"foo": "baz"}), 98 | ("foo.bar", {"foo": {"bar": 1}}, "baz", {"foo": {"bar": "baz"}}), 99 | # 100 | # Descendants 101 | # ----------- 102 | # 103 | ("$..somefield", {"somefield": 1}, 42, {"somefield": 42}), 104 | ( 105 | "$..nestedfield", 106 | {"outer": {"nestedfield": 1}}, 107 | 42, 108 | {"outer": {"nestedfield": 42}}, 109 | ), 110 | ( 111 | "$..bar", 112 | {"outs": {"bar": 1, "ins": {"bar": 9}}, "outs2": {"bar": 2}}, 113 | 42, 114 | {"outs": {"bar": 42, "ins": {"bar": 42}}, "outs2": {"bar": 42}}, 115 | ), 116 | # 117 | # Where 118 | # ----- 119 | # 120 | ( 121 | "*.bar where baz", 122 | {"foo": {"bar": {"baz": 1}}, "bar": {"baz": 2}}, 123 | 5, 124 | {"foo": {"bar": 5}, "bar": {"baz": 2}}, 125 | ), 126 | ( 127 | "(* where flag) .. bar", 128 | {"foo": {"bar": 1, "flag": 1}, "baz": {"bar": 2}}, 129 | 3, 130 | {"foo": {"bar": 3, "flag": 1}, "baz": {"bar": 2}}, 131 | ), 132 | # 133 | # WhereNot 134 | # -------- 135 | # 136 | ( 137 | '(* wherenot flag) .. bar', 138 | {'foo': {'bar': 1, 'flag': 1}, 'baz': {'bar': 2}}, 139 | 4, 140 | {'foo': {'bar': 1, 'flag': 1}, 'baz': {'bar': 4}}, 141 | ), 142 | # 143 | # Lambdas 144 | # ------- 145 | # 146 | ( 147 | "foo[*].baz", 148 | {'foo': [{'baz': 1}, {'baz': 2}]}, 149 | lambda x, y, z: x + 1, 150 | {'foo': [{'baz': 2}, {'baz': 3}]} 151 | ), 152 | # 153 | # Update with Boolean in data 154 | # --------------------------- 155 | # 156 | ( 157 | "$.*.number", 158 | {'foo': ['abc', 'def'], 'bar': {'number': 123456}, 'boolean': True}, 159 | '98765', 160 | {'foo': ['abc', 'def'], 'bar': {'number': '98765'}, 'boolean': True}, 161 | ), 162 | ) 163 | 164 | 165 | @pytest.mark.parametrize( 166 | "expression, data, update_value, expected_value", 167 | update_test_cases, 168 | ) 169 | @parsers 170 | def test_update(parse: Callable[[str], JSONPath], expression: str, data, update_value, expected_value): 171 | data_copy = copy.deepcopy(data) 172 | update_value_copy = copy.deepcopy(update_value) 173 | result = parse(expression).update(data_copy, update_value_copy) 174 | assert result == expected_value 175 | 176 | # inplace update testing 177 | data_copy2 = copy.deepcopy(data) 178 | update_value_copy2 = copy.deepcopy(update_value) 179 | datums = parse(expression).find(data_copy2) 180 | batch_update = isinstance(update_value, list) and len(datums) == len(update_value) 181 | for i, datum in enumerate(datums): 182 | if batch_update: 183 | datum.value = update_value_copy2[i] 184 | else: 185 | datum.value = update_value_copy2 186 | if isinstance(datum.full_path, (Root, This)): # when the type of `data` is str, int, float etc. 187 | data_copy2 = datum.value 188 | assert data_copy2 == expected_value 189 | 190 | 191 | find_test_cases = ( 192 | # 193 | # * (star) 194 | # -------- 195 | # 196 | ("*", {"foo": 1, "baz": 2}, {1, 2}, {"foo", "baz"}), 197 | # 198 | # Fields 199 | # ------ 200 | # 201 | ("foo", {"foo": "baz"}, ["baz"], ["foo"]), 202 | ("foo,baz", {"foo": 1, "baz": 2}, [1, 2], ["foo", "baz"]), 203 | ("@foo", {"@foo": 1}, [1], ["@foo"]), 204 | # 205 | # Roots 206 | # ----- 207 | # 208 | ("$", {"foo": "baz"}, [{"foo": "baz"}], ["$"]), 209 | ("foo.$", {"foo": "baz"}, [{"foo": "baz"}], ["$"]), 210 | ("foo.$.foo", {"foo": "baz"}, ["baz"], ["foo"]), 211 | # 212 | # This 213 | # ---- 214 | # 215 | ("`this`", {"foo": "baz"}, [{"foo": "baz"}], ["`this`"]), 216 | ("foo.`this`", {"foo": "baz"}, ["baz"], ["foo"]), 217 | ("foo.`this`.baz", {"foo": {"baz": 3}}, [3], ["foo.baz"]), 218 | # 219 | # Indexes 220 | # ------- 221 | # 222 | ("[0]", [42], [42], ["[0]"]), 223 | ("[5]", [42], [], []), 224 | ("[2]", [34, 65, 29, 59], [29], ["[2]"]), 225 | ("[0]", None, [], []), 226 | # 227 | # Slices 228 | # ------ 229 | # 230 | ("[*]", [1, 2, 3], [1, 2, 3], ["[0]", "[1]", "[2]"]), 231 | ("[*]", range(1, 4), [1, 2, 3], ["[0]", "[1]", "[2]"]), 232 | ("[1:]", [1, 2, 3, 4], [2, 3, 4], ["[1]", "[2]", "[3]"]), 233 | ("[1:3]", [1, 2, 3, 4], [2, 3], ["[1]", "[2]"]), 234 | ("[:2]", [1, 2, 3, 4], [1, 2], ["[0]", "[1]"]), 235 | ("[:3:2]", [1, 2, 3, 4], [1, 3], ["[0]", "[2]"]), 236 | ("[1::2]", [1, 2, 3, 4], [2, 4], ["[1]", "[3]"]), 237 | ("[1:6:3]", range(1, 10), [2, 5], ["[1]", "[4]"]), 238 | ("[::-2]", [1, 2, 3, 4, 5], [5, 3, 1], ["[4]", "[2]", "[0]"]), 239 | # 240 | # Slices (funky hacks) 241 | # -------------------- 242 | # 243 | ("[*]", 1, [1], ["[0]"]), 244 | ("[0:]", 1, [1], ["[0]"]), 245 | ("[*]", {"foo": 1}, [{"foo": 1}], ["[0]"]), 246 | ("[*].foo", {"foo": 1}, [1], ["[0].foo"]), 247 | # 248 | # Children 249 | # -------- 250 | # 251 | ("foo.baz", {"foo": {"baz": 3}}, [3], ["foo.baz"]), 252 | ("foo.baz", {"foo": {"baz": [3]}}, [[3]], ["foo.baz"]), 253 | ("foo.baz.qux", {"foo": {"baz": {"qux": 5}}}, [5], ["foo.baz.qux"]), 254 | # 255 | # Descendants 256 | # ----------- 257 | # 258 | ( 259 | "foo..baz", 260 | {"foo": {"baz": 1, "bing": {"baz": 2}}}, 261 | [1, 2], 262 | ["foo.baz", "foo.bing.baz"], 263 | ), 264 | ( 265 | "foo..baz", 266 | {"foo": [{"baz": 1}, {"baz": 2}]}, 267 | [1, 2], 268 | ["foo.[0].baz", "foo.[1].baz"], 269 | ), 270 | # 271 | # Parents 272 | # ------- 273 | # 274 | ("foo.baz.`parent`", {"foo": {"baz": 3}}, [{"baz": 3}], ["foo"]), 275 | ( 276 | "foo.`parent`.foo.baz.`parent`.baz.qux", 277 | {"foo": {"baz": {"qux": 5}}}, 278 | [5], 279 | ["foo.baz.qux"], 280 | ), 281 | # 282 | # Hyphens 283 | # ------- 284 | # 285 | ("foo.bar-baz", {"foo": {"bar-baz": 3}}, [3], ["foo.bar-baz"]), 286 | ( 287 | "foo.[bar-baz,blah-blah]", 288 | {"foo": {"bar-baz": 3, "blah-blah": 5}}, 289 | [3, 5], 290 | ["foo.bar-baz", "foo.blah-blah"], 291 | ), 292 | # 293 | # Literals 294 | # -------- 295 | # 296 | ("A.'a.c'", {"A": {"a.c": "d"}}, ["d"], ["A.'a.c'"]), 297 | # 298 | # Numeric keys 299 | # -------- 300 | # 301 | ("1", {"1": "foo"}, ["foo"], ["1"]), 302 | ) 303 | 304 | 305 | @pytest.mark.parametrize( 306 | "path, data, expected_values, expected_full_paths", find_test_cases 307 | ) 308 | @parsers 309 | def test_find(parse, path, data, expected_values, expected_full_paths): 310 | results = parse(path).find(data) 311 | 312 | # Verify result values and full paths match expectations. 313 | assert_value_equality(results, expected_values) 314 | assert_full_path_equality(results, expected_full_paths) 315 | 316 | 317 | find_test_cases_with_auto_id = ( 318 | # 319 | # * (star) 320 | # -------- 321 | # 322 | ("*", {"foo": 1, "baz": 2}, {1, 2, "`this`"}), 323 | # 324 | # Fields 325 | # ------ 326 | # 327 | ("foo.id", {"foo": "baz"}, ["foo"]), 328 | ("foo.id", {"foo": {"id": "baz"}}, ["baz"]), 329 | ("foo,baz.id", {"foo": 1, "baz": 2}, ["foo", "baz"]), 330 | ("*.id", {"foo": {"id": 1}, "baz": 2}, {"1", "baz"}), 331 | # 332 | # Roots 333 | # ----- 334 | # 335 | ("$.id", {"foo": "baz"}, ["$"]), 336 | ("foo.$.id", {"foo": "baz", "id": "bizzle"}, ["bizzle"]), 337 | ("foo.$.baz.id", {"foo": 4, "baz": 3}, ["baz"]), 338 | # 339 | # This 340 | # ---- 341 | # 342 | ("id", {"foo": "baz"}, ["`this`"]), 343 | ("foo.`this`.id", {"foo": "baz"}, ["foo"]), 344 | ("foo.`this`.baz.id", {"foo": {"baz": 3}}, ["foo.baz"]), 345 | # 346 | # Indexes 347 | # ------- 348 | # 349 | ("[0].id", [42], ["[0]"]), 350 | ("[2].id", [34, 65, 29, 59], ["[2]"]), 351 | # 352 | # Slices 353 | # ------ 354 | # 355 | ("[*].id", [1, 2, 3], ["[0]", "[1]", "[2]"]), 356 | ("[1:].id", [1, 2, 3, 4], ["[1]", "[2]", "[3]"]), 357 | # 358 | # Children 359 | # -------- 360 | # 361 | ("foo.baz.id", {"foo": {"baz": 3}}, ["foo.baz"]), 362 | ("foo.baz.id", {"foo": {"baz": [3]}}, ["foo.baz"]), 363 | ("foo.baz.id", {"foo": {"id": "bizzle", "baz": 3}}, ["bizzle.baz"]), 364 | ("foo.baz.id", {"foo": {"baz": {"id": "hi"}}}, ["foo.hi"]), 365 | ("foo.baz.bizzle.id", {"foo": {"baz": {"bizzle": 5}}}, ["foo.baz.bizzle"]), 366 | # 367 | # Descendants 368 | # ----------- 369 | # 370 | ( 371 | "foo..baz.id", 372 | {"foo": {"baz": 1, "bing": {"baz": 2}}}, 373 | ["foo.baz", "foo.bing.baz"], 374 | ), 375 | ) 376 | 377 | 378 | @pytest.mark.parametrize("path, data, expected_values", find_test_cases_with_auto_id) 379 | @parsers 380 | def test_find_values_auto_id(auto_id_field, parse, path, data, expected_values): 381 | result = parse(path).find(data) 382 | assert_value_equality(result, expected_values) 383 | 384 | 385 | @parsers 386 | def test_find_full_paths_auto_id(auto_id_field, parse): 387 | results = parse("*").find({"foo": 1, "baz": 2}) 388 | assert_full_path_equality(results, {"foo", "baz", "id"}) 389 | 390 | 391 | @pytest.mark.parametrize( 392 | "string, target", 393 | ( 394 | ("m.[1].id", ["1.m.a2id"]), 395 | ("m.[1].$.b.id", ["1.bid"]), 396 | ("m.[0].id", ["1.m.[0]"]), 397 | ), 398 | ) 399 | @parsers 400 | def test_nested_index_auto_id(auto_id_field, parse, string, target): 401 | data = { 402 | "id": 1, 403 | "b": {"id": "bid", "name": "bob"}, 404 | "m": [{"a": "a1"}, {"a": "a2", "id": "a2id"}], 405 | } 406 | result = parse(string).find(data) 407 | assert_value_equality(result, target) 408 | 409 | 410 | def test_invalid_hyphenation_in_key(): 411 | with pytest.raises(JsonPathLexerError): 412 | base_parse("foo.-baz") 413 | -------------------------------------------------------------------------------- /tests/test_jsonpath_rw_ext.py: -------------------------------------------------------------------------------- 1 | """ 2 | test_jsonpath_ng_ext 3 | ---------------------------------- 4 | 5 | Tests for `jsonpath_ng_ext` module. 6 | """ 7 | 8 | import pytest 9 | 10 | from jsonpath_ng.exceptions import JsonPathParserError 11 | from jsonpath_ng.ext import parser 12 | 13 | from .helpers import assert_value_equality 14 | 15 | test_cases = ( 16 | pytest.param( 17 | "objects.`sorted`", 18 | {"objects": ["alpha", "gamma", "beta"]}, 19 | [["alpha", "beta", "gamma"]], 20 | id="sorted_list", 21 | ), 22 | pytest.param( 23 | "objects.`sorted`[1]", 24 | {"objects": ["alpha", "gamma", "beta"]}, 25 | "beta", 26 | id="sorted_list_indexed", 27 | ), 28 | pytest.param( 29 | "objects.`sorted`", 30 | {"objects": {"cow": "moo", "horse": "neigh", "cat": "meow"}}, 31 | [["cat", "cow", "horse"]], 32 | id="sorted_dict", 33 | ), 34 | pytest.param( 35 | "objects.`sorted`[0]", 36 | {"objects": {"cow": "moo", "horse": "neigh", "cat": "meow"}}, 37 | "cat", 38 | id="sorted_dict_indexed", 39 | ), 40 | pytest.param( 41 | "objects.`len`", {"objects": ["alpha", "gamma", "beta"]}, 3, id="len_list" 42 | ), 43 | pytest.param( 44 | "objects.`len`", {"objects": {"cow": "moo", "cat": "neigh"}}, 2, id="len_dict" 45 | ), 46 | pytest.param("objects[0].`len`", {"objects": ["alpha", "gamma"]}, 5, id="len_str"), 47 | pytest.param( 48 | 'objects[?@="alpha"]', 49 | {"objects": ["alpha", "gamma", "beta"]}, 50 | ["alpha"], 51 | id="filter_list", 52 | ), 53 | pytest.param( 54 | 'objects[?@ =~ "a.+"]', 55 | {"objects": ["alpha", "gamma", "beta"]}, 56 | ["alpha", "gamma"], 57 | id="filter_list_2", 58 | ), 59 | pytest.param( 60 | 'objects[?@ =~ "a.+"]', {"objects": [1, 2, 3]}, [], id="filter_list_3" 61 | ), 62 | pytest.param( 63 | "objects.`keys`", {"objects": ["alpha", "gamma", "beta"]}, [], id="keys_list" 64 | ), 65 | pytest.param( 66 | "objects.`keys`", 67 | {"objects": {"cow": "moo", "cat": "neigh"}}, 68 | ["cow", "cat"], 69 | id="keys_dict", 70 | ), 71 | pytest.param( 72 | "objects.cow.`path`", 73 | {"objects": {"cow": "moo", "cat": "neigh"}}, 74 | "cow", 75 | id="path_dict", 76 | ), 77 | pytest.param( 78 | "objects[?cow]", 79 | {"objects": [{"cow": "moo"}, {"cat": "neigh"}]}, 80 | [{"cow": "moo"}], 81 | id="filter_exists_syntax1", 82 | ), 83 | pytest.param( 84 | "objects[?@.cow]", 85 | {"objects": [{"cow": "moo"}, {"cat": "neigh"}]}, 86 | [{"cow": "moo"}], 87 | id="filter_exists_syntax2", 88 | ), 89 | pytest.param( 90 | "objects[?(@.cow)]", 91 | {"objects": [{"cow": "moo"}, {"cat": "neigh"}]}, 92 | [{"cow": "moo"}], 93 | id="filter_exists_syntax3", 94 | ), 95 | pytest.param( 96 | 'objects[?(@."cow!?cat")]', 97 | {"objects": [{"cow!?cat": "moo"}, {"cat": "neigh"}]}, 98 | [{"cow!?cat": "moo"}], 99 | id="filter_exists_syntax4", 100 | ), 101 | pytest.param( 102 | 'objects[?cow="moo"]', 103 | {"objects": [{"cow": "moo"}, {"cow": "neigh"}, {"cat": "neigh"}]}, 104 | [{"cow": "moo"}], 105 | id="filter_eq1", 106 | ), 107 | pytest.param( 108 | 'objects[?(@.["cow"]="moo")]', 109 | {"objects": [{"cow": "moo"}, {"cow": "neigh"}, {"cat": "neigh"}]}, 110 | [{"cow": "moo"}], 111 | id="filter_eq2", 112 | ), 113 | pytest.param( 114 | 'objects[?cow=="moo"]', 115 | {"objects": [{"cow": "moo"}, {"cow": "neigh"}, {"cat": "neigh"}]}, 116 | [{"cow": "moo"}], 117 | id="filter_eq3", 118 | ), 119 | pytest.param( 120 | "objects[?cow>5]", 121 | {"objects": [{"cow": 8}, {"cow": 7}, {"cow": 5}, {"cow": "neigh"}]}, 122 | [{"cow": 8}, {"cow": 7}], 123 | id="filter_gt", 124 | ), 125 | pytest.param( 126 | "objects[?cow>5&cat=2]", 127 | { 128 | "objects": [ 129 | {"cow": 8, "cat": 2}, 130 | {"cow": 7, "cat": 2}, 131 | {"cow": 2, "cat": 2}, 132 | {"cow": 5, "cat": 3}, 133 | {"cow": 8, "cat": 3}, 134 | ] 135 | }, 136 | [{"cow": 8, "cat": 2}, {"cow": 7, "cat": 2}], 137 | id="filter_and", 138 | ), 139 | pytest.param( 140 | "objects[?confidence>=0.5].prediction", 141 | { 142 | "objects": [ 143 | {"confidence": 0.42, "prediction": "Good"}, 144 | {"confidence": 0.58, "prediction": "Bad"}, 145 | ] 146 | }, 147 | ["Bad"], 148 | id="filter_float_gt", 149 | ), 150 | pytest.param( 151 | "objects[/cow]", 152 | { 153 | "objects": [ 154 | {"cat": 1, "cow": 2}, 155 | {"cat": 2, "cow": 1}, 156 | {"cat": 3, "cow": 3}, 157 | ] 158 | }, 159 | [[{"cat": 2, "cow": 1}, {"cat": 1, "cow": 2}, {"cat": 3, "cow": 3}]], 160 | id="sort1", 161 | ), 162 | pytest.param( 163 | "objects[/cow][0].cat", 164 | { 165 | "objects": [ 166 | {"cat": 1, "cow": 2}, 167 | {"cat": 2, "cow": 1}, 168 | {"cat": 3, "cow": 3}, 169 | ] 170 | }, 171 | 2, 172 | id="sort1_indexed", 173 | ), 174 | pytest.param( 175 | "objects[\\cat]", 176 | {"objects": [{"cat": 2}, {"cat": 1}, {"cat": 3}]}, 177 | [[{"cat": 3}, {"cat": 2}, {"cat": 1}]], 178 | id="sort2", 179 | ), 180 | pytest.param( 181 | "objects[\\cat][-1].cat", 182 | {"objects": [{"cat": 2}, {"cat": 1}, {"cat": 3}]}, 183 | 1, 184 | id="sort2_indexed", 185 | ), 186 | pytest.param( 187 | "objects[/cow,\\cat]", 188 | { 189 | "objects": [ 190 | {"cat": 1, "cow": 2}, 191 | {"cat": 2, "cow": 1}, 192 | {"cat": 3, "cow": 1}, 193 | {"cat": 3, "cow": 3}, 194 | ] 195 | }, 196 | [ 197 | [ 198 | {"cat": 3, "cow": 1}, 199 | {"cat": 2, "cow": 1}, 200 | {"cat": 1, "cow": 2}, 201 | {"cat": 3, "cow": 3}, 202 | ] 203 | ], 204 | id="sort3", 205 | ), 206 | pytest.param( 207 | "objects[/cow,\\cat][0].cat", 208 | { 209 | "objects": [ 210 | {"cat": 1, "cow": 2}, 211 | {"cat": 2, "cow": 1}, 212 | {"cat": 3, "cow": 1}, 213 | {"cat": 3, "cow": 3}, 214 | ] 215 | }, 216 | 3, 217 | id="sort3_indexed", 218 | ), 219 | pytest.param( 220 | "objects[/cat.cow]", 221 | { 222 | "objects": [ 223 | {"cat": {"dog": 1, "cow": 2}}, 224 | {"cat": {"dog": 2, "cow": 1}}, 225 | {"cat": {"dog": 3, "cow": 3}}, 226 | ] 227 | }, 228 | [ 229 | [ 230 | {"cat": {"dog": 2, "cow": 1}}, 231 | {"cat": {"dog": 1, "cow": 2}}, 232 | {"cat": {"dog": 3, "cow": 3}}, 233 | ] 234 | ], 235 | id="sort4", 236 | ), 237 | pytest.param( 238 | "objects[/cat.cow][0].cat.dog", 239 | { 240 | "objects": [ 241 | {"cat": {"dog": 1, "cow": 2}}, 242 | {"cat": {"dog": 2, "cow": 1}}, 243 | {"cat": {"dog": 3, "cow": 3}}, 244 | ] 245 | }, 246 | 2, 247 | id="sort4_indexed", 248 | ), 249 | pytest.param( 250 | "objects[/cat.(cow,bow)]", 251 | { 252 | "objects": [ 253 | {"cat": {"dog": 1, "bow": 3}}, 254 | {"cat": {"dog": 2, "cow": 1}}, 255 | {"cat": {"dog": 2, "bow": 2}}, 256 | {"cat": {"dog": 3, "cow": 2}}, 257 | ] 258 | }, 259 | [ 260 | [ 261 | {"cat": {"dog": 2, "cow": 1}}, 262 | {"cat": {"dog": 2, "bow": 2}}, 263 | {"cat": {"dog": 3, "cow": 2}}, 264 | {"cat": {"dog": 1, "bow": 3}}, 265 | ] 266 | ], 267 | id="sort5_twofields", 268 | ), 269 | pytest.param( 270 | "objects[/cat.(cow,bow)][0].cat.dog", 271 | { 272 | "objects": [ 273 | {"cat": {"dog": 1, "bow": 3}}, 274 | {"cat": {"dog": 2, "cow": 1}}, 275 | {"cat": {"dog": 2, "bow": 2}}, 276 | {"cat": {"dog": 3, "cow": 2}}, 277 | ] 278 | }, 279 | 2, 280 | id="sort5_indexed", 281 | ), 282 | pytest.param("3 * 3", {}, [9], id="arithmetic_number_only"), 283 | pytest.param("$.foo * 10", {"foo": 4}, [40], id="arithmetic_mul1"), 284 | pytest.param("10 * $.foo", {"foo": 4}, [40], id="arithmetic_mul2"), 285 | pytest.param("$.foo * 10", {"foo": 4}, [40], id="arithmetic_mul3"), 286 | pytest.param("$.foo * 3", {"foo": "f"}, ["fff"], id="arithmetic_mul4"), 287 | pytest.param("foo * 3", {"foo": "f"}, ["foofoofoo"], id="arithmetic_mul5"), 288 | pytest.param("($.foo * 10 * $.foo) + 2", {"foo": 4}, [162], id="arithmetic_mul6"), 289 | pytest.param("$.foo * 10 * $.foo + 2", {"foo": 4}, [240], id="arithmetic_mul7"), 290 | pytest.param( 291 | "foo + bar", {"foo": "name", "bar": "node"}, ["foobar"], id="arithmetic_str0" 292 | ), 293 | pytest.param( 294 | 'foo + "_" + bar', 295 | {"foo": "name", "bar": "node"}, 296 | ["foo_bar"], 297 | id="arithmetic_str1", 298 | ), 299 | pytest.param( 300 | '$.foo + "_" + $.bar', 301 | {"foo": "name", "bar": "node"}, 302 | ["name_node"], 303 | id="arithmetic_str2", 304 | ), 305 | pytest.param( 306 | "$.foo + $.bar", 307 | {"foo": "name", "bar": "node"}, 308 | ["namenode"], 309 | id="arithmetic_str3", 310 | ), 311 | pytest.param( 312 | "foo.cow + bar.cow", 313 | {"foo": {"cow": "name"}, "bar": {"cow": "node"}}, 314 | ["namenode"], 315 | id="arithmetic_str4", 316 | ), 317 | pytest.param( 318 | "$.objects[*].cow * 2", 319 | {"objects": [{"cow": 1}, {"cow": 2}, {"cow": 3}]}, 320 | [2, 4, 6], 321 | id="arithmetic_list1", 322 | ), 323 | pytest.param( 324 | "$.objects[*].cow * $.objects[*].cow", 325 | {"objects": [{"cow": 1}, {"cow": 2}, {"cow": 3}]}, 326 | [1, 4, 9], 327 | id="arithmetic_list2", 328 | ), 329 | pytest.param( 330 | "$.objects[*].cow * $.objects2[*].cow", 331 | {"objects": [{"cow": 1}, {"cow": 2}, {"cow": 3}], "objects2": [{"cow": 5}]}, 332 | [], 333 | id="arithmetic_list_err1", 334 | ), 335 | pytest.param('$.objects * "foo"', {"objects": []}, [], id="arithmetic_err1"), 336 | pytest.param('"bar" * "foo"', {}, [], id="arithmetic_err2"), 337 | pytest.param( 338 | "payload.metrics[?(@.name='cpu.frequency')].value * 100", 339 | { 340 | "payload": { 341 | "metrics": [ 342 | { 343 | "timestamp": "2013-07-29T06:51:34.472416", 344 | "name": "cpu.frequency", 345 | "value": 1600, 346 | "source": "libvirt.LibvirtDriver", 347 | }, 348 | { 349 | "timestamp": "2013-07-29T06:51:34.472416", 350 | "name": "cpu.user.time", 351 | "value": 17421440000000, 352 | "source": "libvirt.LibvirtDriver", 353 | }, 354 | ] 355 | } 356 | }, 357 | [160000], 358 | id="real_life_example1", 359 | ), 360 | pytest.param( 361 | "payload.(id|(resource.id))", 362 | {"payload": {"id": "foobar"}}, 363 | ["foobar"], 364 | id="real_life_example2", 365 | ), 366 | pytest.param( 367 | "payload.id|(resource.id)", 368 | {"payload": {"resource": {"id": "foobar"}}}, 369 | ["foobar"], 370 | id="real_life_example3", 371 | ), 372 | pytest.param( 373 | "payload.id|(resource.id)", 374 | {"payload": {"id": "yes", "resource": {"id": "foobar"}}}, 375 | ["yes", "foobar"], 376 | id="real_life_example4", 377 | ), 378 | pytest.param( 379 | "payload.`sub(/(foo\\\\d+)\\\\+(\\\\d+bar)/, \\\\2-\\\\1)`", 380 | {"payload": "foo5+3bar"}, 381 | ["3bar-foo5"], 382 | id="sub1", 383 | ), 384 | pytest.param( 385 | "payload.`sub(/foo\\\\+bar/, repl)`", 386 | {"payload": "foo+bar"}, 387 | ["repl"], 388 | id="sub2", 389 | ), 390 | pytest.param("payload.`str()`", {"payload": 1}, ["1"], id="str1"), 391 | pytest.param( 392 | "payload.`split(-, 2, -1)`", 393 | {"payload": "foo-bar-cat-bow"}, 394 | ["cat"], 395 | id="split1", 396 | ), 397 | pytest.param( 398 | "payload.`split(-, 2, 2)`", 399 | {"payload": "foo-bar-cat-bow"}, 400 | ["cat-bow"], 401 | id="split2", 402 | ), 403 | pytest.param( 404 | "payload.`split(',', 2, -1)`", 405 | {"payload": "foo,bar,baz"}, 406 | ["baz"], 407 | id="split3", 408 | ), 409 | pytest.param( 410 | 'payload.`split(", ", 2, -1)`', 411 | {"payload": "foo, bar, baz"}, 412 | ["baz"], 413 | id="split4", 414 | ), 415 | pytest.param( 416 | 'payload.`split(", ", *, -1)`', 417 | {"payload": "foo, bar, baz"}, 418 | [["foo", "bar", "baz"]], 419 | id="split5", 420 | ), 421 | pytest.param( 422 | 'payload.`split(", ", -1, -1)`', 423 | {"payload": "foo, bar, baz"}, 424 | ["baz"], 425 | id="split6", 426 | ), 427 | pytest.param( 428 | "payload.`split(|, -1, 1)`", 429 | {"payload": "foo|bar|baz"}, 430 | ["bar|baz"], 431 | id="split7", 432 | ), 433 | pytest.param( 434 | "foo[?(@.baz==1)]", 435 | {"foo": [{"baz": 1}, {"baz": 2}]}, 436 | [{"baz": 1}], 437 | id="bug-#2-correct", 438 | ), 439 | pytest.param( 440 | "foo[*][?(@.baz==1)]", {"foo": [{"baz": 1}, {"baz": 2}]}, [], id="bug-#2-wrong" 441 | ), 442 | pytest.param( 443 | "foo[?flag = true].color", 444 | { 445 | "foo": [ 446 | {"color": "blue", "flag": True}, 447 | {"color": "green", "flag": False}, 448 | ] 449 | }, 450 | ["blue"], 451 | id="boolean-filter-true", 452 | ), 453 | pytest.param( 454 | "foo[?flag = false].color", 455 | { 456 | "foo": [ 457 | {"color": "blue", "flag": True}, 458 | {"color": "green", "flag": False}, 459 | ] 460 | }, 461 | ["green"], 462 | id="boolean-filter-false", 463 | ), 464 | pytest.param( 465 | "foo[?flag = true].color", 466 | { 467 | "foo": [ 468 | {"color": "blue", "flag": True}, 469 | {"color": "green", "flag": 2}, 470 | {"color": "red", "flag": "hi"}, 471 | ] 472 | }, 473 | ["blue"], 474 | id="boolean-filter-other-datatypes-involved", 475 | ), 476 | pytest.param( 477 | 'foo[?flag = "true"].color', 478 | { 479 | "foo": [ 480 | {"color": "blue", "flag": True}, 481 | {"color": "green", "flag": "true"}, 482 | ] 483 | }, 484 | ["green"], 485 | id="boolean-filter-string-true-string-literal", 486 | ), 487 | ) 488 | 489 | 490 | @pytest.mark.parametrize("path, data, expected_values", test_cases) 491 | def test_values(path, data, expected_values): 492 | results = parser.parse(path).find(data) 493 | assert_value_equality(results, expected_values) 494 | 495 | 496 | def test_invalid_hyphenation_in_key(): 497 | # This test is almost copied-and-pasted directly from `test_jsonpath.py`. 498 | # However, the parsers generate different exceptions for this syntax error. 499 | # This discrepancy needs to be resolved. 500 | with pytest.raises(JsonPathParserError): 501 | parser.parse("foo.-baz") 502 | -------------------------------------------------------------------------------- /tests/test_lexer.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from jsonpath_ng.lexer import JsonPathLexer, JsonPathLexerError 4 | 5 | token_test_cases = ( 6 | ("$", (("$", "$"),)), 7 | ('"hello"', (("hello", "ID"),)), 8 | ("'goodbye'", (("goodbye", "ID"),)), 9 | ("'doublequote\"'", (('doublequote"', "ID"),)), 10 | (r'"doublequote\""', (('doublequote"', "ID"),)), 11 | (r"'singlequote\''", (("singlequote'", "ID"),)), 12 | ('"singlequote\'"', (("singlequote'", "ID"),)), 13 | ("fuzz", (("fuzz", "ID"),)), 14 | ("1", ((1, "NUMBER"),)), 15 | ("45", ((45, "NUMBER"),)), 16 | ("-1", ((-1, "NUMBER"),)), 17 | (" -13 ", ((-13, "NUMBER"),)), 18 | ('"fuzz.bang"', (("fuzz.bang", "ID"),)), 19 | ("fuzz.bang", (("fuzz", "ID"), (".", "."), ("bang", "ID"))), 20 | ("fuzz.*", (("fuzz", "ID"), (".", "."), ("*", "*"))), 21 | ("fuzz..bang", (("fuzz", "ID"), ("..", "DOUBLEDOT"), ("bang", "ID"))), 22 | ("&", (("&", "&"),)), 23 | ("@", (("@", "ID"),)), 24 | ("`this`", (("this", "NAMED_OPERATOR"),)), 25 | ("|", (("|", "|"),)), 26 | ("where", (("where", "WHERE"),)), 27 | ("wherenot", (("wherenot", "WHERENOT"),)), 28 | ) 29 | 30 | 31 | @pytest.mark.parametrize("string, expected_token_info", token_test_cases) 32 | def test_lexer(string, expected_token_info): 33 | lexer = JsonPathLexer(debug=True) 34 | tokens = list(lexer.tokenize(string)) 35 | assert len(tokens) == len(expected_token_info) 36 | for token, (expected_value, expected_type) in zip(tokens, expected_token_info): 37 | assert token.type == expected_type 38 | assert token.value == expected_value 39 | 40 | 41 | invalid_token_test_cases = ( 42 | "'\"", 43 | "\"'", 44 | '`"', 45 | "`'", 46 | '"`', 47 | "'`", 48 | "?", 49 | "$.foo.bar.#", 50 | ) 51 | 52 | 53 | @pytest.mark.parametrize("string", invalid_token_test_cases) 54 | def test_lexer_errors(string): 55 | with pytest.raises(JsonPathLexerError): 56 | list(JsonPathLexer().tokenize(string)) 57 | -------------------------------------------------------------------------------- /tests/test_parser.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from jsonpath_ng.jsonpath import Child, Descendants, Fields, Index, Slice, Where, WhereNot 4 | from jsonpath_ng.lexer import JsonPathLexer 5 | from jsonpath_ng.parser import JsonPathParser 6 | 7 | # Format: (string, expected_object) 8 | parser_test_cases = ( 9 | # 10 | # Atomic 11 | # ------ 12 | # 13 | ("😀", Fields("😀")), 14 | ("你好", Fields("你好")), 15 | ("foo", Fields("foo")), 16 | ("*", Fields("*")), 17 | ("1", Fields("1")), 18 | ("baz,bizzle", Fields("baz", "bizzle")), 19 | ("[1]", Index(1)), 20 | ("[1:]", Slice(start=1)), 21 | ("[:]", Slice()), 22 | ("[*]", Slice()), 23 | ("[:2]", Slice(end=2)), 24 | ("[1:2]", Slice(start=1, end=2)), 25 | ("[5:-2]", Slice(start=5, end=-2)), 26 | # 27 | # Nested 28 | # ------ 29 | # 30 | ("foo.baz", Child(Fields("foo"), Fields("baz"))), 31 | ("foo.baz,bizzle", Child(Fields("foo"), Fields("baz", "bizzle"))), 32 | ("foo where baz", Where(Fields("foo"), Fields("baz"))), 33 | ("foo wherenot baz", WhereNot(Fields("foo"), Fields("baz"))), 34 | ("foo..baz", Descendants(Fields("foo"), Fields("baz"))), 35 | ("foo..baz.bing", Descendants(Fields("foo"), Child(Fields("baz"), Fields("bing")))), 36 | ) 37 | 38 | 39 | @pytest.mark.parametrize("string, expected_object", parser_test_cases) 40 | def test_parser(string, expected_object): 41 | parser = JsonPathParser(lexer_class=lambda: JsonPathLexer()) 42 | assert parser.parse(string) == expected_object 43 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | min_version = 4.3.5 3 | 4 | envlist = 5 | coverage-erase 6 | py{3.13, 3.12, 3.11, 3.10, 3.9, 3.8} 7 | coverage-report 8 | 9 | skip_missing_interpreters = True 10 | isolated_build = True 11 | 12 | 13 | [testenv] 14 | package = wheel 15 | wheel_build_env = build_wheel 16 | 17 | depends = 18 | py{3.13, 3.12, 3.11, 3.10, 3.9, 3.8}: coverage-erase 19 | deps = 20 | coverage[toml] 21 | pytest 22 | pytest-randomly 23 | commands = 24 | coverage run -m pytest 25 | 26 | 27 | [testenv:coverage-erase] 28 | no_package = true 29 | skip_install = true 30 | deps = 31 | coverage[toml] 32 | commands = 33 | coverage erase 34 | 35 | 36 | [testenv:coverage-report] 37 | depends = 38 | py{3.13, 3.12, 3.11, 3.10, 3.9, 3.8} 39 | no_package = true 40 | skip_install = true 41 | deps = 42 | coverage[toml] 43 | commands_pre = 44 | - coverage combine 45 | commands = 46 | coverage report 47 | command_post = 48 | coverage html --fail-under=0 49 | --------------------------------------------------------------------------------