├── .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 |
--------------------------------------------------------------------------------