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.
- pytest_loco_json-1.3.1/LICENSE +24 -0
- pytest_loco_json-1.3.1/PKG-INFO +261 -0
- pytest_loco_json-1.3.1/README.md +236 -0
- pytest_loco_json-1.3.1/pyproject.toml +164 -0
- pytest_loco_json-1.3.1/src/pytest_loco_json/__init__.py +11 -0
- pytest_loco_json-1.3.1/src/pytest_loco_json/format.py +105 -0
- pytest_loco_json-1.3.1/src/pytest_loco_json/instructions.py +73 -0
- pytest_loco_json-1.3.1/src/pytest_loco_json/plugin.py +16 -0
- pytest_loco_json-1.3.1/src/pytest_loco_json/transforms.py +99 -0
|
@@ -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
|
+
)
|