pytest-loco-json 1.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ BSD 2-Clause License
2
+
3
+ Copyright (c) 2025, Loco
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
16
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
17
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
18
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
19
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
20
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
21
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
22
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
23
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
24
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,261 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytest-loco-json
3
+ Version: 1.3.1
4
+ Summary: JSON support for pytest-loco
5
+ License-Expression: BSD-2-Clause
6
+ License-File: LICENSE
7
+ Author: Mikhalev Oleg
8
+ Author-email: mhalairt@gmail.com
9
+ Requires-Python: >3.13,<4
10
+ Classifier: Development Status :: 2 - Pre-Alpha
11
+ Classifier: Environment :: Plugins
12
+ Classifier: Framework :: Pytest
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Classifier: Topic :: Utilities
18
+ Requires-Dist: orjson (>=3.11.7,<4.0.0)
19
+ Requires-Dist: pytest-loco (>=1.3.1)
20
+ Requires-Dist: python-jsonpath (>=2.0.2,<3.0.0)
21
+ Project-URL: Issues, https://github.com/pytest-loco/pytest-loco-json/issues
22
+ Project-URL: Source, https://github.com/pytest-loco/pytest-loco-json
23
+ Description-Content-Type: text/markdown
24
+
25
+ # pytest-loco-json
26
+
27
+ JSON extension for `pytest-loco`.
28
+
29
+ The `pytest-loco-json` extension adds first-class JSON support to the
30
+ `pytest-loco` DSL. It provides facilities for decoding, encoding, and
31
+ querying JSON data as part of test execution.
32
+
33
+ This extension is designed to integrate seamlessly with the `pytest-loco`
34
+ plugin system and can be enabled by registering the `json` plugin.
35
+ Once enabled, JSON becomes a native data format within the DSL, suitable
36
+ for validation, transformation, and data-driven testing scenarios.
37
+
38
+ ## Encode
39
+
40
+ The **dump** feature serializes a value from the execution context into a
41
+ JSON string using the high-performance `orjson` backend.
42
+
43
+ Encoding is typically used when preparing request payloads, exporting
44
+ structured data, or normalizing values for comparison. The encoder
45
+ accepts an optional `sortKeys` parameter that controls serialization
46
+ behavior and enables deterministic key ordering.
47
+
48
+ Encoded values are returned as UTF-8 JSON strings and can be passed
49
+ directly to actions (for example, HTTP requests) or stored in the
50
+ execution context.
51
+
52
+ For example:
53
+
54
+ ```yaml
55
+ ---
56
+ spec: case
57
+ title: Example of encoding a value
58
+ vars:
59
+ value:
60
+ name: Molecule Man
61
+ secretIdentity: Dan Jukes
62
+ age: 29
63
+
64
+ ---
65
+ action: pass
66
+ export:
67
+ jsonText: !dump
68
+ source: !var value
69
+ format: json
70
+ ```
71
+
72
+ The result of executing this case will be the assignment of the
73
+ following value (without indentation) to the variable `jsonText`:
74
+
75
+ ```json
76
+ {
77
+ "name": "Molecule Man",
78
+ "age": 29,
79
+ "secretIdentity": "Dan Jukes"
80
+ }
81
+ ```
82
+
83
+ Optionally, you can use the `sortKeys` option:
84
+
85
+ ```yaml
86
+ ...
87
+ action: pass
88
+ export:
89
+ jsonText: !dump
90
+ source: !var value
91
+ format: json
92
+ sortKeys: yes
93
+ ```
94
+
95
+ In this case, the keys will be sorted alphabetically:
96
+
97
+ ```json
98
+ {
99
+ "age": 29,
100
+ "name": "Molecule Man",
101
+ "secretIdentity": "Dan Jukes"
102
+ }
103
+ ```
104
+
105
+ ## Decode
106
+
107
+ The **load** feature parses a JSON string into native Python objects and
108
+ stores them as values in the execution context.
109
+
110
+ Decoding allows JSON payloads to become fully addressable within the
111
+ DSL execution context. Once decoded, values can be inspected, exported,
112
+ validated, or transformed by subsequent steps.
113
+
114
+ Decoder parameters are intentionally minimal: decoding focuses on
115
+ correctness and performance, while transformation logic is handled
116
+ separately.
117
+
118
+ For example:
119
+
120
+ ```yaml
121
+ ---
122
+ spec: case
123
+ title: Example of decoding a value
124
+
125
+ ---
126
+ action: pass
127
+ title: Read JSON content from file
128
+ export:
129
+ jsonText: !textFile test.json
130
+
131
+ ---
132
+ action: pass
133
+ title: Try to decode JSON
134
+ export:
135
+ jsonValue: !load
136
+ source: !var jsonText
137
+ format: json
138
+ ```
139
+
140
+ The result of executing this case will be the assignment of the decoded
141
+ JSON object to the variable `jsonValue`.
142
+
143
+ ## Transform by JSONPath
144
+
145
+ The **load** feature can be extended with an optional transformer that
146
+ enables extracting data from decoded JSON structures using JSONPath
147
+ expressions.
148
+
149
+ Transforms are applied after decoding and allow selecting either a
150
+ single value or multiple values from complex, deeply nested documents.
151
+ Behavior is configurable to return the first match, the last match, or
152
+ a full list of matches.
153
+
154
+ This makes it possible to work with large or variable JSON payloads
155
+ without hard-coding structural assumptions into the test logic.
156
+
157
+ Use the following `test.json` file contents as an example:
158
+
159
+ ```json
160
+ [
161
+ {
162
+ "name": "Molecule Man",
163
+ "age": 29,
164
+ "secretIdentity": "Dan Jukes",
165
+ "powers": [
166
+ "Radiation resistance",
167
+ "Turning tiny",
168
+ "Radiation blast"
169
+ ]
170
+ },
171
+ {
172
+ "name": "Madame Uppercut",
173
+ "age": 39,
174
+ "secretIdentity": "Jane Wilson",
175
+ "powers": [
176
+ "Million tonne punch",
177
+ "Damage resistance",
178
+ "Superhuman reflexes"
179
+ ]
180
+ }
181
+ ]
182
+ ```
183
+
184
+ Optionally, you can use the `query` transformer with **load**:
185
+
186
+ ```yaml
187
+ ...
188
+
189
+ action: pass
190
+ title: Decode JSON and select the first match
191
+ export:
192
+ jsonValue: !load
193
+ source: !var jsonText
194
+ format: json
195
+ query: '$[*].name'
196
+ expect:
197
+ - title: Check first selected name
198
+ value: !var jsonValue
199
+ match: Molecule Man
200
+ ```
201
+
202
+ By default, the first match of the JSONPath query is selected.
203
+ This behavior can be controlled using the `exactOne` parameter
204
+ (`true` or `false`; when `false`, the query returns a full list of
205
+ matches) and the `exactMode` parameter (`first` or `last` on a
206
+ single mode querying).
207
+
208
+ For example:
209
+
210
+ ```yaml
211
+ ...
212
+
213
+ action: pass
214
+ title: Try to decode JSON and select all
215
+ export:
216
+ jsonValue: !load
217
+ source: !var jsonText
218
+ format: json
219
+ query: '$[?(@.age<30)].powers[*]'
220
+ exactOne: no
221
+ expect:
222
+ - title: Check result is list of powers
223
+ value: !var jsonValue
224
+ match:
225
+ - Radiation resistance
226
+ - Turning tiny
227
+ - Radiation blast
228
+ ```
229
+
230
+ ## Inline querying
231
+
232
+ Inline querying allows JSONPath expressions to be defined directly
233
+ inside the DSL using the dedicated `!jsonpath` instruction.
234
+
235
+ Inline queries are compiled during schema loading rather than at runtime,
236
+ which improves error reporting and ensures invalid paths fail early.
237
+ Compiled queries can be stored in variables and reused across steps.
238
+
239
+ This feature is especially useful for complex test suites where the
240
+ same JSONPath expressions appear in multiple places, or when clarity
241
+ and reuse are more important than brevity.
242
+
243
+ For example:
244
+
245
+ ```yaml
246
+ ---
247
+ action: pass
248
+ title: Try to select from context by instruction
249
+ export:
250
+ resultValues: !jsonpath jsonValue $[?(@.age<_.ageLimit)].powers[*]
251
+ expect:
252
+ - title: Check result is list of powers
253
+ value: !var resultValues
254
+ match:
255
+ - Radiation resistance
256
+ - Turning tiny
257
+ - Radiation blast
258
+ ```
259
+
260
+ The result is always a list.
261
+
@@ -0,0 +1,236 @@
1
+ # pytest-loco-json
2
+
3
+ JSON extension for `pytest-loco`.
4
+
5
+ The `pytest-loco-json` extension adds first-class JSON support to the
6
+ `pytest-loco` DSL. It provides facilities for decoding, encoding, and
7
+ querying JSON data as part of test execution.
8
+
9
+ This extension is designed to integrate seamlessly with the `pytest-loco`
10
+ plugin system and can be enabled by registering the `json` plugin.
11
+ Once enabled, JSON becomes a native data format within the DSL, suitable
12
+ for validation, transformation, and data-driven testing scenarios.
13
+
14
+ ## Encode
15
+
16
+ The **dump** feature serializes a value from the execution context into a
17
+ JSON string using the high-performance `orjson` backend.
18
+
19
+ Encoding is typically used when preparing request payloads, exporting
20
+ structured data, or normalizing values for comparison. The encoder
21
+ accepts an optional `sortKeys` parameter that controls serialization
22
+ behavior and enables deterministic key ordering.
23
+
24
+ Encoded values are returned as UTF-8 JSON strings and can be passed
25
+ directly to actions (for example, HTTP requests) or stored in the
26
+ execution context.
27
+
28
+ For example:
29
+
30
+ ```yaml
31
+ ---
32
+ spec: case
33
+ title: Example of encoding a value
34
+ vars:
35
+ value:
36
+ name: Molecule Man
37
+ secretIdentity: Dan Jukes
38
+ age: 29
39
+
40
+ ---
41
+ action: pass
42
+ export:
43
+ jsonText: !dump
44
+ source: !var value
45
+ format: json
46
+ ```
47
+
48
+ The result of executing this case will be the assignment of the
49
+ following value (without indentation) to the variable `jsonText`:
50
+
51
+ ```json
52
+ {
53
+ "name": "Molecule Man",
54
+ "age": 29,
55
+ "secretIdentity": "Dan Jukes"
56
+ }
57
+ ```
58
+
59
+ Optionally, you can use the `sortKeys` option:
60
+
61
+ ```yaml
62
+ ...
63
+ action: pass
64
+ export:
65
+ jsonText: !dump
66
+ source: !var value
67
+ format: json
68
+ sortKeys: yes
69
+ ```
70
+
71
+ In this case, the keys will be sorted alphabetically:
72
+
73
+ ```json
74
+ {
75
+ "age": 29,
76
+ "name": "Molecule Man",
77
+ "secretIdentity": "Dan Jukes"
78
+ }
79
+ ```
80
+
81
+ ## Decode
82
+
83
+ The **load** feature parses a JSON string into native Python objects and
84
+ stores them as values in the execution context.
85
+
86
+ Decoding allows JSON payloads to become fully addressable within the
87
+ DSL execution context. Once decoded, values can be inspected, exported,
88
+ validated, or transformed by subsequent steps.
89
+
90
+ Decoder parameters are intentionally minimal: decoding focuses on
91
+ correctness and performance, while transformation logic is handled
92
+ separately.
93
+
94
+ For example:
95
+
96
+ ```yaml
97
+ ---
98
+ spec: case
99
+ title: Example of decoding a value
100
+
101
+ ---
102
+ action: pass
103
+ title: Read JSON content from file
104
+ export:
105
+ jsonText: !textFile test.json
106
+
107
+ ---
108
+ action: pass
109
+ title: Try to decode JSON
110
+ export:
111
+ jsonValue: !load
112
+ source: !var jsonText
113
+ format: json
114
+ ```
115
+
116
+ The result of executing this case will be the assignment of the decoded
117
+ JSON object to the variable `jsonValue`.
118
+
119
+ ## Transform by JSONPath
120
+
121
+ The **load** feature can be extended with an optional transformer that
122
+ enables extracting data from decoded JSON structures using JSONPath
123
+ expressions.
124
+
125
+ Transforms are applied after decoding and allow selecting either a
126
+ single value or multiple values from complex, deeply nested documents.
127
+ Behavior is configurable to return the first match, the last match, or
128
+ a full list of matches.
129
+
130
+ This makes it possible to work with large or variable JSON payloads
131
+ without hard-coding structural assumptions into the test logic.
132
+
133
+ Use the following `test.json` file contents as an example:
134
+
135
+ ```json
136
+ [
137
+ {
138
+ "name": "Molecule Man",
139
+ "age": 29,
140
+ "secretIdentity": "Dan Jukes",
141
+ "powers": [
142
+ "Radiation resistance",
143
+ "Turning tiny",
144
+ "Radiation blast"
145
+ ]
146
+ },
147
+ {
148
+ "name": "Madame Uppercut",
149
+ "age": 39,
150
+ "secretIdentity": "Jane Wilson",
151
+ "powers": [
152
+ "Million tonne punch",
153
+ "Damage resistance",
154
+ "Superhuman reflexes"
155
+ ]
156
+ }
157
+ ]
158
+ ```
159
+
160
+ Optionally, you can use the `query` transformer with **load**:
161
+
162
+ ```yaml
163
+ ...
164
+
165
+ action: pass
166
+ title: Decode JSON and select the first match
167
+ export:
168
+ jsonValue: !load
169
+ source: !var jsonText
170
+ format: json
171
+ query: '$[*].name'
172
+ expect:
173
+ - title: Check first selected name
174
+ value: !var jsonValue
175
+ match: Molecule Man
176
+ ```
177
+
178
+ By default, the first match of the JSONPath query is selected.
179
+ This behavior can be controlled using the `exactOne` parameter
180
+ (`true` or `false`; when `false`, the query returns a full list of
181
+ matches) and the `exactMode` parameter (`first` or `last` on a
182
+ single mode querying).
183
+
184
+ For example:
185
+
186
+ ```yaml
187
+ ...
188
+
189
+ action: pass
190
+ title: Try to decode JSON and select all
191
+ export:
192
+ jsonValue: !load
193
+ source: !var jsonText
194
+ format: json
195
+ query: '$[?(@.age<30)].powers[*]'
196
+ exactOne: no
197
+ expect:
198
+ - title: Check result is list of powers
199
+ value: !var jsonValue
200
+ match:
201
+ - Radiation resistance
202
+ - Turning tiny
203
+ - Radiation blast
204
+ ```
205
+
206
+ ## Inline querying
207
+
208
+ Inline querying allows JSONPath expressions to be defined directly
209
+ inside the DSL using the dedicated `!jsonpath` instruction.
210
+
211
+ Inline queries are compiled during schema loading rather than at runtime,
212
+ which improves error reporting and ensures invalid paths fail early.
213
+ Compiled queries can be stored in variables and reused across steps.
214
+
215
+ This feature is especially useful for complex test suites where the
216
+ same JSONPath expressions appear in multiple places, or when clarity
217
+ and reuse are more important than brevity.
218
+
219
+ For example:
220
+
221
+ ```yaml
222
+ ---
223
+ action: pass
224
+ title: Try to select from context by instruction
225
+ export:
226
+ resultValues: !jsonpath jsonValue $[?(@.age<_.ageLimit)].powers[*]
227
+ expect:
228
+ - title: Check result is list of powers
229
+ value: !var resultValues
230
+ match:
231
+ - Radiation resistance
232
+ - Turning tiny
233
+ - Radiation blast
234
+ ```
235
+
236
+ The result is always a list.
@@ -0,0 +1,164 @@
1
+ [project]
2
+ name = "pytest-loco-json"
3
+ version = "1.3.1"
4
+ description = "JSON support for pytest-loco"
5
+ classifiers = [
6
+ "Development Status :: 2 - Pre-Alpha",
7
+ "Environment :: Plugins",
8
+ "Framework :: Pytest",
9
+ "Intended Audience :: Developers",
10
+ "Programming Language :: Python :: 3 :: Only",
11
+ "Programming Language :: Python :: 3.13",
12
+ "Topic :: Software Development :: Testing",
13
+ "Topic :: Utilities",
14
+ ]
15
+ authors = [
16
+ {name = "Mikhalev Oleg", email = "mhalairt@gmail.com"}
17
+ ]
18
+ license = "BSD-2-Clause"
19
+ license-files = ["LICENSE"]
20
+ readme = "README.md"
21
+ requires-python = ">3.13,<4"
22
+ dependencies = [
23
+ "orjson (>=3.11.7,<4.0.0)",
24
+ "pytest-loco (>=1.3.1)",
25
+ "python-jsonpath (>=2.0.2,<3.0.0)",
26
+ ]
27
+
28
+ [project.urls]
29
+ "Source" = "https://github.com/pytest-loco/pytest-loco-json"
30
+ "Issues" = "https://github.com/pytest-loco/pytest-loco-json/issues"
31
+
32
+ [project.entry-points.loco_plugins]
33
+ pytest_loco_json = "pytest_loco_json.plugin:json"
34
+
35
+ [tool.poetry]
36
+ packages = [{include = "pytest_loco_json", from = "src"}]
37
+
38
+ [tool.poetry.group.dev]
39
+ optional = true
40
+
41
+ [tool.poetry.group.dev.dependencies]
42
+ mypy = {extras = ["mypyc", "reports", "stubgen"], version = "^1.19.1"}
43
+ pre-commit = "^4.5.1"
44
+ ruff = ">=0.14.11,<0.15.0"
45
+
46
+ [tool.poetry.group.test]
47
+ optional = true
48
+
49
+ [tool.poetry.group.test.dependencies]
50
+ pytest = "^9.0.2"
51
+ pytest-cov = "^7.0.0"
52
+
53
+ [tool.ruff]
54
+ target-version = "py313"
55
+ line-length = 120
56
+
57
+ [tool.ruff.format]
58
+ docstring-code-format = true
59
+ quote-style = "single"
60
+
61
+ [tool.ruff.lint]
62
+ select = [
63
+ "ANN", # flake8-annotations
64
+ "ASYNC", # flake8-async
65
+ "S", # flake8-bandit
66
+ "A", # flake8-builtins
67
+ "B", # flake8-bugbear
68
+ "COM", # flake8-commas
69
+ "C4", # flake8-comprehensions
70
+ "T20", # flake8-print
71
+ "Q", # flake8-quotes
72
+ "RSE", # flake8-raise
73
+ "SLF", # flake8-self
74
+ "SIM", # flake8-simplify
75
+ "TC", # flake8-type-checking
76
+ "ARG", # flake8-unused-arguments
77
+ "I", # isort
78
+ "E", "W", # pycodestyle
79
+ "D", # pydocstyle
80
+ "F", # pyflakes
81
+ "PL", # pylint
82
+ "UP", # pyupgrade
83
+ "RUF", # ruff
84
+ ]
85
+
86
+ [tool.ruff.lint.isort]
87
+ section-order = [
88
+ "future",
89
+ "standard-library",
90
+ "third-party",
91
+ "loco",
92
+ "first-party",
93
+ "local-folder",
94
+ ]
95
+ known-first-party = ["pytest_loco_json"]
96
+
97
+ [tool.ruff.lint.isort.sections]
98
+ loco = ["pytest_loco"]
99
+
100
+ [tool.ruff.lint.flake8-annotations]
101
+ allow-star-arg-any = true
102
+
103
+ [tool.ruff.lint.flake8-quotes]
104
+ inline-quotes = "single"
105
+ multiline-quotes = "single"
106
+
107
+ [tool.ruff.lint.flake8-type-checking]
108
+ quote-annotations = true
109
+
110
+ [tool.ruff.lint.pydocstyle]
111
+ convention = "google"
112
+
113
+ [tool.mypy]
114
+ python_version = "3.13"
115
+ mypy_path = ["src"]
116
+ packages = ["pytest_loco_json"]
117
+ strict = true
118
+ exclude_gitignore = true
119
+ explicit_package_bases = true
120
+ pretty = true
121
+ show_error_codes = true
122
+ color_output = true
123
+
124
+ [[tool.mypy.overrides]]
125
+ module = "yaml.*"
126
+ ignore_missing_imports = true
127
+
128
+ [tool.pytest.ini_options]
129
+ minversion = "9.0.0"
130
+ testpaths = ["tests"]
131
+ addopts = [
132
+ "-q",
133
+ "--cov=pytest_loco_json",
134
+ "--cov-report=term",
135
+ "--cov-report=html",
136
+ "--strict-markers",
137
+ "--strict-config",
138
+ ]
139
+
140
+ [tool.coverage.run]
141
+ source = ["pytest_loco_json"]
142
+ branch = true
143
+ parallel = true
144
+ concurrency = ["multiprocessing"]
145
+
146
+ [tool.coverage.paths]
147
+ source = [
148
+ "src/pytest_loco_json",
149
+ "**/site-packages/pytest_loco_json"
150
+ ]
151
+
152
+ [tool.coverage.report]
153
+ exclude_lines = [
154
+ "pragma: no cover",
155
+ "if typing.TYPE_CHECKING:",
156
+ "if TYPE_CHECKING:",
157
+ ]
158
+ fail_under = 80
159
+ show_missing = true
160
+
161
+
162
+ [build-system]
163
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
164
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,11 @@
1
+ """JSON extension for pytest-loco.
2
+
3
+ The `pytest-loco-json` extension adds first-class JSON support to the
4
+ pytest-loco DSL. It provides facilities for decoding, encoding, and
5
+ querying JSON data as part of test execution.
6
+
7
+ This extension is designed to integrate seamlessly with the pytest-loco
8
+ plugin system and can be enabled by registering the `json` plugin.
9
+ Once enabled, JSON becomes a native data format within the DSL, suitable
10
+ for validation, transformation, and data-driven testing scenarios.
11
+ """
@@ -0,0 +1,105 @@
1
+ """JSON content type support.
2
+
3
+ This module defines JSON decoding and encoding support for the DSL,
4
+ based on the `orjson` library. It also integrates JSONPath-based
5
+ transformations to allow querying decoded JSON structures.
6
+ """
7
+
8
+ from functools import cache
9
+ from typing import TYPE_CHECKING
10
+
11
+ import orjson
12
+
13
+ from pytest_loco.extensions import (
14
+ Attribute,
15
+ ContentDecoder,
16
+ ContentEncoder,
17
+ ContentType,
18
+ Schema,
19
+ )
20
+
21
+ from .transforms import query
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Mapping
25
+
26
+ if TYPE_CHECKING:
27
+ from pytest_loco.values import RuntimeValue
28
+
29
+
30
+ @cache
31
+ def _json_decode(value: bytes | str) -> 'RuntimeValue':
32
+ """Decode a JSON document with caching."""
33
+ if not isinstance(value, (bytes, str)):
34
+ raise TypeError('wrong type to decode')
35
+
36
+ return orjson.loads(value)
37
+
38
+
39
+ def json_decode(value: 'RuntimeValue',
40
+ params: 'Mapping[str, RuntimeValue]') -> 'RuntimeValue': # noqa: ARG001
41
+ """Decode a JSON string into a Python value.
42
+
43
+ This decoder parses a JSON document into native Python objects
44
+ using `orjson`. Decoding results are cached for reuse.
45
+ Decoder parameters are ignored.
46
+
47
+ Args:
48
+ value: JSON string to decode.
49
+ params: Decoder parameters (unused).
50
+
51
+ Returns:
52
+ Parsed Python representation of the JSON document.
53
+ """
54
+ return _json_decode(value)
55
+
56
+
57
+ def json_encode(value: 'RuntimeValue',
58
+ params: 'Mapping[str, RuntimeValue]') -> str:
59
+ """Encode a Python value into a JSON string.
60
+
61
+ This encoder serializes a Python value into JSON using `orjson`.
62
+ Optional parameters control serialization behavior.
63
+
64
+ Args:
65
+ value: Python value to serialize.
66
+ params: Encoder parameters.
67
+
68
+ Returns:
69
+ JSON string representation of the value.
70
+ """
71
+ options = 0
72
+ if params.get('sort_keys'):
73
+ options |= orjson.OPT_SORT_KEYS
74
+
75
+ return orjson.dumps(value, option=options).decode()
76
+
77
+
78
+ json_decoder = ContentDecoder(
79
+ decoder=json_decode,
80
+ transformers=[
81
+ query,
82
+ ],
83
+ )
84
+
85
+ json_encoder = ContentEncoder(
86
+ encoder=json_encode,
87
+ parameters=Schema({
88
+ 'sort_keys': Attribute(
89
+ base=bool,
90
+ aliases=['sortKeys'],
91
+ default=False,
92
+ title='Sort JSON object keys',
93
+ description=(
94
+ 'If enabled, JSON object keys are sorted alphabetically '
95
+ 'during serialization.'
96
+ ),
97
+ ),
98
+ }),
99
+ )
100
+
101
+ json_format = ContentType(
102
+ name='json',
103
+ decoder=json_decoder,
104
+ encoder=json_encoder,
105
+ )
@@ -0,0 +1,73 @@
1
+ """JSONPath instruction for the DSL.
2
+
3
+ This module defines a DSL instruction `jq` that allows compiling
4
+ JSONPath expressions from YAML scalar nodes. The resulting instruction
5
+ can be used to query values from complex data structures at runtime.
6
+ It ensures that parsing and compilation errors are wrapped in
7
+ `DSLSchemaError` with proper YAML context.
8
+ """
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ import jsonpath
13
+ import yaml
14
+
15
+ from pytest_loco.errors import DSLSchemaError
16
+ from pytest_loco.extensions import Instruction
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Mapping
20
+
21
+ if TYPE_CHECKING:
22
+ from yaml import BaseLoader
23
+ from yaml.nodes import ScalarNode
24
+
25
+ if TYPE_CHECKING:
26
+ from pytest_loco.values import Deferred, RuntimeValue
27
+
28
+
29
+ def jsonpath_query_constructor(loader: 'BaseLoader', node: 'ScalarNode') -> 'Deferred[RuntimeValue]':
30
+ """Construct a precompiled JSONPath expression from a YAML scalar node.
31
+
32
+ This function is intended to be used as a YAML instruction constructor
33
+ in the DSL. It reads a scalar string from the YAML node, compiles it
34
+ into a JSONPath expression, and returns a deferred callable that can
35
+ be executed at runtime to query a value.
36
+
37
+ Args:
38
+ loader: YAML loader instance used to parse the document.
39
+ node: YAML scalar node containing the JSONPath expression.
40
+
41
+ Returns:
42
+ A deferred JSONPath object that can be called with a target value
43
+ to evaluate the expression.
44
+
45
+ Raises:
46
+ DSLSchemaError: If the JSONPath expression is invalid or
47
+ the YAML node cannot be processed.
48
+ """
49
+ try:
50
+ variable, query_string = loader.construct_scalar(node).split(' ', 1)
51
+ query = jsonpath.compile(query_string)
52
+
53
+ except yaml.MarkedYAMLError as base:
54
+ raise DSLSchemaError.from_yaml_error(base) from base
55
+
56
+ except Exception as base:
57
+ raise DSLSchemaError.from_yaml_node('Invalid variable or JSONPath', node) from base
58
+
59
+ def resolver(context: 'Mapping[str, RuntimeValue]') -> 'RuntimeValue':
60
+ """Wrapper for resolve a values by the JSONPath selector."""
61
+ value = context.get(variable)
62
+ if not value:
63
+ return []
64
+
65
+ return query.findall(value, filter_context=context)
66
+
67
+ return resolver
68
+
69
+
70
+ jsonpath_query = Instruction(
71
+ constructor=jsonpath_query_constructor,
72
+ name='jsonpath',
73
+ )
@@ -0,0 +1,16 @@
1
+ """JSON plugin for pytest-loco DSL.
2
+
3
+ This module defines the `json` plugin that provides JSON content
4
+ support and JSONPath-based instructions for querying data structures.
5
+ """
6
+
7
+ from pytest_loco.extensions import Plugin
8
+
9
+ from .format import json_format
10
+ from .instructions import jsonpath_query
11
+
12
+ json = Plugin(
13
+ name='json',
14
+ content_types=[json_format],
15
+ instructions=[jsonpath_query],
16
+ )
@@ -0,0 +1,99 @@
1
+ """JSON content transformers.
2
+
3
+ This module provides a DSL extension that allows extracting data
4
+ from structured values using JSONPath expressions. The transformer
5
+ can return either all matched items or a single selected value
6
+ depending on configuration.
7
+ """
8
+
9
+ from typing import TYPE_CHECKING, Literal
10
+
11
+ import jsonpath
12
+
13
+ from pytest_loco.extensions import Attribute, ContentTransformer, Schema
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Mapping
17
+
18
+ if TYPE_CHECKING:
19
+ from jsonpath import JSON
20
+
21
+ if TYPE_CHECKING:
22
+ from pytest_loco.values import RuntimeValue
23
+
24
+
25
+ def jsonpath_query(value: 'JSON', params: 'Mapping[str, RuntimeValue]') -> 'RuntimeValue':
26
+ """Query a value using a JSONPath expression.
27
+
28
+ The function applies a JSONPath query to the provided value and
29
+ returns either all matched items or a single item based on
30
+ transformer parameters.
31
+
32
+ Args:
33
+ value: Input value to query.
34
+ params: Transformer parameters.
35
+
36
+ Returns:
37
+ Query results.
38
+ """
39
+ query = params.get('query')
40
+ if not isinstance(query, str):
41
+ return None
42
+
43
+ items = jsonpath.findall(query, value)
44
+ if not params.get('exact_one'):
45
+ return items
46
+
47
+ if not items:
48
+ return None
49
+
50
+ result = None
51
+ match params.get('exact_mode'):
52
+ case 'first':
53
+ result = items[0]
54
+ case 'last':
55
+ result = items[-1]
56
+
57
+ return result
58
+
59
+
60
+ query = ContentTransformer(
61
+ transformer=jsonpath_query,
62
+ name='query',
63
+ field=Attribute(
64
+ base=str,
65
+ aliases=['find', 'select'],
66
+ required=True,
67
+ title='JSONPath expression',
68
+ description=(
69
+ 'JSONPath expression used to extract values from the input '
70
+ 'data structure.'
71
+ ),
72
+ ),
73
+ parameters=Schema({
74
+ 'exact_one': Attribute(
75
+ base=bool,
76
+ aliases=['exactOne', 'selectOne'],
77
+ default=True,
78
+ title='Return a single value',
79
+ description=(
80
+ 'If enabled, the transformer returns a single matched '
81
+ 'value instead of a list. If multiple matches are found, '
82
+ 'the selection strategy is controlled by `exact_mode`. '
83
+ 'If no matches are found, `None` is returned.'
84
+ ),
85
+ ),
86
+ 'exact_mode': Attribute(
87
+ base=Literal['first', 'last'],
88
+ aliases=['exactMode', 'selectMode', 'mode'],
89
+ default='first',
90
+ examples=['first', 'last'],
91
+ title='Single-value selection strategy',
92
+ description=(
93
+ 'Defines how a single value is selected when multiple '
94
+ 'matches are found and ``exact_one`` is enabled. '
95
+ 'Possible values are ``first`` and ``last``.'
96
+ ),
97
+ ),
98
+ }),
99
+ )