pytest-httpchain 0.2.0__tar.gz → 0.2.4__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_httpchain-0.2.0 → pytest_httpchain-0.2.4}/PKG-INFO +61 -68
- {pytest_httpchain-0.2.0 → pytest_httpchain-0.2.4}/README.md +58 -65
- {pytest_httpchain-0.2.0 → pytest_httpchain-0.2.4}/pyproject.toml +9 -4
- {pytest_httpchain-0.2.0 → pytest_httpchain-0.2.4}/src/pytest_httpchain/carrier.py +13 -8
- pytest_httpchain-0.2.4/src/pytest_httpchain/cli.py +67 -0
- {pytest_httpchain-0.2.0 → pytest_httpchain-0.2.4}/src/pytest_httpchain/constants.py +5 -0
- {pytest_httpchain-0.2.0 → pytest_httpchain-0.2.4}/src/pytest_httpchain/exceptions.py +2 -1
- pytest_httpchain-0.2.4/src/pytest_httpchain/har_writer.py +244 -0
- {pytest_httpchain-0.2.0 → pytest_httpchain-0.2.4}/src/pytest_httpchain/plugin.py +38 -8
- pytest_httpchain-0.2.4/src/pytest_httpchain/skill.md +252 -0
- {pytest_httpchain-0.2.0 → pytest_httpchain-0.2.4}/src/pytest_httpchain/utils.py +18 -0
- {pytest_httpchain-0.2.0 → pytest_httpchain-0.2.4}/LICENSE +0 -0
- {pytest_httpchain-0.2.0 → pytest_httpchain-0.2.4}/src/pytest_httpchain/__init__.py +0 -0
- {pytest_httpchain-0.2.0 → pytest_httpchain-0.2.4}/src/pytest_httpchain/report_formatter.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-httpchain
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: pytest plugin for HTTP testing using JSON files
|
|
5
5
|
Keywords: testing,pytest,requests
|
|
6
6
|
Author: Alexander Eresov
|
|
@@ -18,11 +18,11 @@ Requires-Dist: httpx[http2]>=0.27.0
|
|
|
18
18
|
Requires-Dist: pydantic>=2.11.7
|
|
19
19
|
Requires-Dist: pyrate-limiter>=3.0.0
|
|
20
20
|
Requires-Dist: pytest-httpchain-jsonref
|
|
21
|
+
Requires-Dist: pytest-httpchain-mcp
|
|
21
22
|
Requires-Dist: pytest-httpchain-models
|
|
22
23
|
Requires-Dist: pytest-order>=1.3.0
|
|
23
|
-
Requires-Dist:
|
|
24
|
+
Requires-Dist: typer>=0.16.0
|
|
24
25
|
Requires-Python: >=3.13, <4.0
|
|
25
|
-
Provides-Extra: mcp
|
|
26
26
|
Description-Content-Type: text/markdown
|
|
27
27
|
|
|
28
28
|
[](https://pypi.python.org/pypi/pytest-httpchain)
|
|
@@ -38,66 +38,44 @@ A pytest plugin for testing HTTP endpoints.
|
|
|
38
38
|
`pytest-httpchain` is an integration testing framework for HTTP APIs based on [httpx](https://www.python-httpx.org) lib.
|
|
39
39
|
It aims at helping with common HTTP API testing scenarios, where user needs to make several calls in specific order using data obtained along the way, like auth tokens or resource ids.
|
|
40
40
|
|
|
41
|
-
##
|
|
42
|
-
|
|
43
|
-
Install normally via package manager of your choice from PyPi:
|
|
41
|
+
## Why pytest-httpchain?
|
|
44
42
|
|
|
45
|
-
|
|
46
|
-
pip install pytest-httpchain
|
|
47
|
-
```
|
|
43
|
+
Testing HTTP APIs with plain pytest often leads to these pain points:
|
|
48
44
|
|
|
49
|
-
|
|
45
|
+
- **Boilerplate accumulates** — Every test repeats the same setup: create client, set headers, make request, parse response, assert. The actual test intent gets buried.
|
|
46
|
+
- **Data threading is manual** — When one call returns a token or ID needed by the next, you end up with fragile helper functions passing state around.
|
|
47
|
+
- **Common patterns get copy-pasted** — Auth flows, base URLs, shared headers end up duplicated across test files. Fixtures might help, but they are not designed for that.
|
|
48
|
+
- **Code reviews are noisy** — The actual test logic is rarely clear because of all the boilerplate and helpers, following changes gets overwhelming quickly.
|
|
50
49
|
|
|
51
|
-
|
|
52
|
-
pip install 'git+https://github.com/aeresov/pytest-httpchain@main'
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
### Optional dependencies
|
|
56
|
-
|
|
57
|
-
The following optional dependencies are available:
|
|
58
|
-
|
|
59
|
-
- `mcp`: installs MCP server package and its starting script. Details in [MCP Server](#mcp-server).
|
|
50
|
+
`pytest-httpchain` offers a more structured approach.
|
|
60
51
|
|
|
61
52
|
## Features
|
|
62
53
|
|
|
63
|
-
###
|
|
64
|
-
|
|
65
|
-
Most of pytest magic can be used: markers, fixtures, other plugins.
|
|
66
|
-
|
|
67
|
-
### Declarative format
|
|
54
|
+
### Declarative JSON format
|
|
68
55
|
|
|
69
|
-
Test scenarios are
|
|
70
|
-
`pytest-httpchain` supports JSONRef, so use can reuse arbitrary parts of your scenarios with `$ref` directive.
|
|
71
|
-
Properties are merged in a greedy way with type checking.
|
|
56
|
+
Test scenarios are JSON documents that describe _what_ to test, not _how_. No setup code to scroll through — the request and assertions are right there.
|
|
72
57
|
|
|
73
|
-
###
|
|
58
|
+
### `$ref` with deep merging
|
|
74
59
|
|
|
75
|
-
|
|
76
|
-
`pytest-httpchain` executes stages in the order they are listed in scenario file; one stage failure stops the execution chain.
|
|
60
|
+
Reuse arbitrary parts of your scenarios with JSONRef. Properties merge with type checking, so you can compose scenarios from shared fragments (auth flows, common headers, base URLs).
|
|
77
61
|
|
|
78
|
-
###
|
|
62
|
+
### Multi-stage execution
|
|
79
63
|
|
|
80
|
-
`
|
|
81
|
-
This storage ("common data context") is populated with declared variables, fixtures and data saved by stages. The data remains there throughout the scenario execution.
|
|
82
|
-
Writing scenarios, you can use Jinja-style expressions like `"{{ var }}"` for JSON values. `pytest-httpchain` does variable substitution dynamically right before executing a stage, and uses common data context keys as variables in these expressions.
|
|
83
|
-
Values from common data context also might be verified during verified/asserted.
|
|
64
|
+
Each scenario contains 1+ stages executed in order. One stage failure stops the chain. Use `always_run` for cleanup stages that should execute regardless.
|
|
84
65
|
|
|
85
|
-
###
|
|
66
|
+
### Common data context
|
|
86
67
|
|
|
87
|
-
|
|
68
|
+
A key-value store persists throughout scenario execution. Variables, fixtures, and saved response data all live here. Use template expressions (`{{ var }}`) anywhere in your requests — substitution happens dynamically before each stage.
|
|
88
69
|
|
|
89
|
-
|
|
90
|
-
- to verify HTTP response and values in common data context
|
|
91
|
-
- to provide [custom authentication for requests](https://requests.readthedocs.io/en/latest/user/advanced/#custom-authentication)
|
|
92
|
-
- to call in substitution expressions
|
|
70
|
+
### Response processing
|
|
93
71
|
|
|
94
|
-
|
|
72
|
+
- **JMESPath** — Extract values from JSON responses directly
|
|
73
|
+
- **JSON Schema** — Validate response structure against a schema
|
|
74
|
+
- **User functions** — Call Python functions for custom extraction, verification, or [authentication](https://requests.readthedocs.io/en/latest/user/advanced/#custom-authentication)
|
|
95
75
|
|
|
96
|
-
|
|
76
|
+
### Full pytest integration
|
|
97
77
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
`pytest-httpchain` can verify JSON reponses against user-defined JSON schema.
|
|
78
|
+
Markers, fixtures, parametrization, and other plugins work as expected. You're not locked into a separate ecosystem.
|
|
101
79
|
|
|
102
80
|
## Quick Start
|
|
103
81
|
|
|
@@ -122,9 +100,8 @@ def now_utc():
|
|
|
122
100
|
}
|
|
123
101
|
}
|
|
124
102
|
],
|
|
125
|
-
"stages":
|
|
126
|
-
{
|
|
127
|
-
"name": "get_user",
|
|
103
|
+
"stages": {
|
|
104
|
+
"get_user": {
|
|
128
105
|
"request": {
|
|
129
106
|
"url": "https://api.example.com/users/{{ user_id }}"
|
|
130
107
|
},
|
|
@@ -143,8 +120,7 @@ def now_utc():
|
|
|
143
120
|
}
|
|
144
121
|
]
|
|
145
122
|
},
|
|
146
|
-
{
|
|
147
|
-
"name": "update_user",
|
|
123
|
+
"update_user": {
|
|
148
124
|
"fixtures": ["now_utc"],
|
|
149
125
|
"request": {
|
|
150
126
|
"url": "https://api.example.com/users/{{ user_id }}",
|
|
@@ -166,15 +142,14 @@ def now_utc():
|
|
|
166
142
|
}
|
|
167
143
|
]
|
|
168
144
|
},
|
|
169
|
-
{
|
|
170
|
-
"name": "cleanup",
|
|
145
|
+
"cleanup": {
|
|
171
146
|
"always_run": true,
|
|
172
147
|
"request": {
|
|
173
148
|
"url": "https://api.example.com/cleanup",
|
|
174
149
|
"method": "POST"
|
|
175
150
|
}
|
|
176
151
|
}
|
|
177
|
-
|
|
152
|
+
}
|
|
178
153
|
}
|
|
179
154
|
```
|
|
180
155
|
|
|
@@ -196,34 +171,51 @@ Scenario we created:
|
|
|
196
171
|
finalizing call meant for graceful exit
|
|
197
172
|
`always_run` parameter means this stage will be executed regardless of errors in previous stages
|
|
198
173
|
|
|
199
|
-
For detailed
|
|
174
|
+
For detailed usage guide see the [full documentation](https://aeresov.github.io/pytest-httpchain).
|
|
175
|
+
|
|
176
|
+
## Installation
|
|
177
|
+
|
|
178
|
+
Install normally via package manager of your choice from PyPi:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
pip install pytest-httpchain
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
or directly from Github, in case you need a particular ref:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
pip install 'git+https://github.com/aeresov/pytest-httpchain@main'
|
|
188
|
+
```
|
|
200
189
|
|
|
201
190
|
## Configuration
|
|
202
191
|
|
|
203
|
-
- Test file discovery is based on this name pattern: `test_<name>.<suffix>.json`.
|
|
192
|
+
- Test file discovery is based on this name pattern: `test_<name>.<suffix>.json`.
|
|
204
193
|
The `suffix` is configurable as pytest ini option, default value is **http**.
|
|
205
|
-
- `$ref` instructions can point to other files; absolute and relative paths are supported.
|
|
194
|
+
- `$ref` instructions can point to other files; absolute and relative paths are supported.
|
|
206
195
|
You can limit the depth of relative path traversal using `ref_parent_traversal_depth` ini option, default value is **3**.
|
|
196
|
+
- Template expressions support list/dict comprehensions. You can limit the maximum comprehension length using `max_comprehension_length` ini option, default value is **50000**.
|
|
197
|
+
- Parallel stage iterations (repeat/foreach) have a safety limit configurable via `max_parallel_iterations` ini option, default value is **10000**.
|
|
207
198
|
|
|
208
199
|
## MCP Server
|
|
209
200
|
|
|
210
201
|
`pytest-httpchain` includes an MCP (Model Context Protocol) server to aid AI code assistants.
|
|
211
202
|
|
|
212
|
-
###
|
|
203
|
+
### Setup
|
|
213
204
|
|
|
214
|
-
|
|
215
|
-
Use this script as call target for your MCP configuration.
|
|
205
|
+
Install the MCP server config and Claude Code skill into your project:
|
|
216
206
|
|
|
217
|
-
|
|
207
|
+
```bash
|
|
208
|
+
uvx pytest-httpchain install --skill --mcp
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Or configure manually in `.mcp.json`:
|
|
218
212
|
|
|
219
213
|
```json
|
|
220
214
|
{
|
|
221
215
|
"mcpServers": {
|
|
222
216
|
"pytest-httpchain": {
|
|
223
|
-
"
|
|
224
|
-
"
|
|
225
|
-
"args": ["run", "pytest-httpchain-mcp"],
|
|
226
|
-
"env": {}
|
|
217
|
+
"command": "uvx",
|
|
218
|
+
"args": ["pytest-httpchain", "mcp"]
|
|
227
219
|
}
|
|
228
220
|
}
|
|
229
221
|
}
|
|
@@ -234,16 +226,17 @@ Claude Code `.mcp.json` example:
|
|
|
234
226
|
The MCP server provides:
|
|
235
227
|
|
|
236
228
|
- **Scenario validation** - validate test scenario and scan for possible problems
|
|
229
|
+
- **Claude Code skill** - authoring guidance for writing test scenarios
|
|
237
230
|
|
|
238
231
|
## Documentation
|
|
239
232
|
|
|
240
|
-
- [
|
|
241
|
-
- [Full Documentation](https://aeresov.github.io/pytest-httpchain) - Complete guide
|
|
233
|
+
- [Full Documentation](https://aeresov.github.io/pytest-httpchain) - Complete usage guide
|
|
242
234
|
- [Changelog](CHANGELOG.md) - Release notes
|
|
243
235
|
|
|
244
236
|
## Thanks
|
|
245
237
|
|
|
246
|
-
|
|
238
|
+
This project was inspired by [Tavern](https://github.com/taverntesting/tavern) and [pytest-play](https://github.com/davidemoro/pytest-play).
|
|
239
|
+
|
|
247
240
|
[httpx](https://www.python-httpx.org) does comms.
|
|
248
241
|
[Pydantic](https://docs.pydantic.dev) keeps structure.
|
|
249
242
|
[simpleeval](https://github.com/danthedeckie/simpleeval) powers templates.
|
|
@@ -11,66 +11,44 @@ A pytest plugin for testing HTTP endpoints.
|
|
|
11
11
|
`pytest-httpchain` is an integration testing framework for HTTP APIs based on [httpx](https://www.python-httpx.org) lib.
|
|
12
12
|
It aims at helping with common HTTP API testing scenarios, where user needs to make several calls in specific order using data obtained along the way, like auth tokens or resource ids.
|
|
13
13
|
|
|
14
|
-
##
|
|
15
|
-
|
|
16
|
-
Install normally via package manager of your choice from PyPi:
|
|
14
|
+
## Why pytest-httpchain?
|
|
17
15
|
|
|
18
|
-
|
|
19
|
-
pip install pytest-httpchain
|
|
20
|
-
```
|
|
16
|
+
Testing HTTP APIs with plain pytest often leads to these pain points:
|
|
21
17
|
|
|
22
|
-
|
|
18
|
+
- **Boilerplate accumulates** — Every test repeats the same setup: create client, set headers, make request, parse response, assert. The actual test intent gets buried.
|
|
19
|
+
- **Data threading is manual** — When one call returns a token or ID needed by the next, you end up with fragile helper functions passing state around.
|
|
20
|
+
- **Common patterns get copy-pasted** — Auth flows, base URLs, shared headers end up duplicated across test files. Fixtures might help, but they are not designed for that.
|
|
21
|
+
- **Code reviews are noisy** — The actual test logic is rarely clear because of all the boilerplate and helpers, following changes gets overwhelming quickly.
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
pip install 'git+https://github.com/aeresov/pytest-httpchain@main'
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
### Optional dependencies
|
|
29
|
-
|
|
30
|
-
The following optional dependencies are available:
|
|
31
|
-
|
|
32
|
-
- `mcp`: installs MCP server package and its starting script. Details in [MCP Server](#mcp-server).
|
|
23
|
+
`pytest-httpchain` offers a more structured approach.
|
|
33
24
|
|
|
34
25
|
## Features
|
|
35
26
|
|
|
36
|
-
###
|
|
37
|
-
|
|
38
|
-
Most of pytest magic can be used: markers, fixtures, other plugins.
|
|
39
|
-
|
|
40
|
-
### Declarative format
|
|
27
|
+
### Declarative JSON format
|
|
41
28
|
|
|
42
|
-
Test scenarios are
|
|
43
|
-
`pytest-httpchain` supports JSONRef, so use can reuse arbitrary parts of your scenarios with `$ref` directive.
|
|
44
|
-
Properties are merged in a greedy way with type checking.
|
|
29
|
+
Test scenarios are JSON documents that describe _what_ to test, not _how_. No setup code to scroll through — the request and assertions are right there.
|
|
45
30
|
|
|
46
|
-
###
|
|
31
|
+
### `$ref` with deep merging
|
|
47
32
|
|
|
48
|
-
|
|
49
|
-
`pytest-httpchain` executes stages in the order they are listed in scenario file; one stage failure stops the execution chain.
|
|
33
|
+
Reuse arbitrary parts of your scenarios with JSONRef. Properties merge with type checking, so you can compose scenarios from shared fragments (auth flows, common headers, base URLs).
|
|
50
34
|
|
|
51
|
-
###
|
|
35
|
+
### Multi-stage execution
|
|
52
36
|
|
|
53
|
-
`
|
|
54
|
-
This storage ("common data context") is populated with declared variables, fixtures and data saved by stages. The data remains there throughout the scenario execution.
|
|
55
|
-
Writing scenarios, you can use Jinja-style expressions like `"{{ var }}"` for JSON values. `pytest-httpchain` does variable substitution dynamically right before executing a stage, and uses common data context keys as variables in these expressions.
|
|
56
|
-
Values from common data context also might be verified during verified/asserted.
|
|
37
|
+
Each scenario contains 1+ stages executed in order. One stage failure stops the chain. Use `always_run` for cleanup stages that should execute regardless.
|
|
57
38
|
|
|
58
|
-
###
|
|
39
|
+
### Common data context
|
|
59
40
|
|
|
60
|
-
|
|
41
|
+
A key-value store persists throughout scenario execution. Variables, fixtures, and saved response data all live here. Use template expressions (`{{ var }}`) anywhere in your requests — substitution happens dynamically before each stage.
|
|
61
42
|
|
|
62
|
-
|
|
63
|
-
- to verify HTTP response and values in common data context
|
|
64
|
-
- to provide [custom authentication for requests](https://requests.readthedocs.io/en/latest/user/advanced/#custom-authentication)
|
|
65
|
-
- to call in substitution expressions
|
|
43
|
+
### Response processing
|
|
66
44
|
|
|
67
|
-
|
|
45
|
+
- **JMESPath** — Extract values from JSON responses directly
|
|
46
|
+
- **JSON Schema** — Validate response structure against a schema
|
|
47
|
+
- **User functions** — Call Python functions for custom extraction, verification, or [authentication](https://requests.readthedocs.io/en/latest/user/advanced/#custom-authentication)
|
|
68
48
|
|
|
69
|
-
|
|
49
|
+
### Full pytest integration
|
|
70
50
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
`pytest-httpchain` can verify JSON reponses against user-defined JSON schema.
|
|
51
|
+
Markers, fixtures, parametrization, and other plugins work as expected. You're not locked into a separate ecosystem.
|
|
74
52
|
|
|
75
53
|
## Quick Start
|
|
76
54
|
|
|
@@ -95,9 +73,8 @@ def now_utc():
|
|
|
95
73
|
}
|
|
96
74
|
}
|
|
97
75
|
],
|
|
98
|
-
"stages":
|
|
99
|
-
{
|
|
100
|
-
"name": "get_user",
|
|
76
|
+
"stages": {
|
|
77
|
+
"get_user": {
|
|
101
78
|
"request": {
|
|
102
79
|
"url": "https://api.example.com/users/{{ user_id }}"
|
|
103
80
|
},
|
|
@@ -116,8 +93,7 @@ def now_utc():
|
|
|
116
93
|
}
|
|
117
94
|
]
|
|
118
95
|
},
|
|
119
|
-
{
|
|
120
|
-
"name": "update_user",
|
|
96
|
+
"update_user": {
|
|
121
97
|
"fixtures": ["now_utc"],
|
|
122
98
|
"request": {
|
|
123
99
|
"url": "https://api.example.com/users/{{ user_id }}",
|
|
@@ -139,15 +115,14 @@ def now_utc():
|
|
|
139
115
|
}
|
|
140
116
|
]
|
|
141
117
|
},
|
|
142
|
-
{
|
|
143
|
-
"name": "cleanup",
|
|
118
|
+
"cleanup": {
|
|
144
119
|
"always_run": true,
|
|
145
120
|
"request": {
|
|
146
121
|
"url": "https://api.example.com/cleanup",
|
|
147
122
|
"method": "POST"
|
|
148
123
|
}
|
|
149
124
|
}
|
|
150
|
-
|
|
125
|
+
}
|
|
151
126
|
}
|
|
152
127
|
```
|
|
153
128
|
|
|
@@ -169,34 +144,51 @@ Scenario we created:
|
|
|
169
144
|
finalizing call meant for graceful exit
|
|
170
145
|
`always_run` parameter means this stage will be executed regardless of errors in previous stages
|
|
171
146
|
|
|
172
|
-
For detailed
|
|
147
|
+
For detailed usage guide see the [full documentation](https://aeresov.github.io/pytest-httpchain).
|
|
148
|
+
|
|
149
|
+
## Installation
|
|
150
|
+
|
|
151
|
+
Install normally via package manager of your choice from PyPi:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
pip install pytest-httpchain
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
or directly from Github, in case you need a particular ref:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
pip install 'git+https://github.com/aeresov/pytest-httpchain@main'
|
|
161
|
+
```
|
|
173
162
|
|
|
174
163
|
## Configuration
|
|
175
164
|
|
|
176
|
-
- Test file discovery is based on this name pattern: `test_<name>.<suffix>.json`.
|
|
165
|
+
- Test file discovery is based on this name pattern: `test_<name>.<suffix>.json`.
|
|
177
166
|
The `suffix` is configurable as pytest ini option, default value is **http**.
|
|
178
|
-
- `$ref` instructions can point to other files; absolute and relative paths are supported.
|
|
167
|
+
- `$ref` instructions can point to other files; absolute and relative paths are supported.
|
|
179
168
|
You can limit the depth of relative path traversal using `ref_parent_traversal_depth` ini option, default value is **3**.
|
|
169
|
+
- Template expressions support list/dict comprehensions. You can limit the maximum comprehension length using `max_comprehension_length` ini option, default value is **50000**.
|
|
170
|
+
- Parallel stage iterations (repeat/foreach) have a safety limit configurable via `max_parallel_iterations` ini option, default value is **10000**.
|
|
180
171
|
|
|
181
172
|
## MCP Server
|
|
182
173
|
|
|
183
174
|
`pytest-httpchain` includes an MCP (Model Context Protocol) server to aid AI code assistants.
|
|
184
175
|
|
|
185
|
-
###
|
|
176
|
+
### Setup
|
|
186
177
|
|
|
187
|
-
|
|
188
|
-
Use this script as call target for your MCP configuration.
|
|
178
|
+
Install the MCP server config and Claude Code skill into your project:
|
|
189
179
|
|
|
190
|
-
|
|
180
|
+
```bash
|
|
181
|
+
uvx pytest-httpchain install --skill --mcp
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Or configure manually in `.mcp.json`:
|
|
191
185
|
|
|
192
186
|
```json
|
|
193
187
|
{
|
|
194
188
|
"mcpServers": {
|
|
195
189
|
"pytest-httpchain": {
|
|
196
|
-
"
|
|
197
|
-
"
|
|
198
|
-
"args": ["run", "pytest-httpchain-mcp"],
|
|
199
|
-
"env": {}
|
|
190
|
+
"command": "uvx",
|
|
191
|
+
"args": ["pytest-httpchain", "mcp"]
|
|
200
192
|
}
|
|
201
193
|
}
|
|
202
194
|
}
|
|
@@ -207,16 +199,17 @@ Claude Code `.mcp.json` example:
|
|
|
207
199
|
The MCP server provides:
|
|
208
200
|
|
|
209
201
|
- **Scenario validation** - validate test scenario and scan for possible problems
|
|
202
|
+
- **Claude Code skill** - authoring guidance for writing test scenarios
|
|
210
203
|
|
|
211
204
|
## Documentation
|
|
212
205
|
|
|
213
|
-
- [
|
|
214
|
-
- [Full Documentation](https://aeresov.github.io/pytest-httpchain) - Complete guide
|
|
206
|
+
- [Full Documentation](https://aeresov.github.io/pytest-httpchain) - Complete usage guide
|
|
215
207
|
- [Changelog](CHANGELOG.md) - Release notes
|
|
216
208
|
|
|
217
209
|
## Thanks
|
|
218
210
|
|
|
219
|
-
|
|
211
|
+
This project was inspired by [Tavern](https://github.com/taverntesting/tavern) and [pytest-play](https://github.com/davidemoro/pytest-play).
|
|
212
|
+
|
|
220
213
|
[httpx](https://www.python-httpx.org) does comms.
|
|
221
214
|
[Pydantic](https://docs.pydantic.dev) keeps structure.
|
|
222
215
|
[simpleeval](https://github.com/danthedeckie/simpleeval) powers templates.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pytest-httpchain"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.4"
|
|
4
4
|
description = "pytest plugin for HTTP testing using JSON files"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13,<4.0"
|
|
@@ -10,8 +10,10 @@ dependencies = [
|
|
|
10
10
|
"pydantic>=2.11.7",
|
|
11
11
|
"pyrate-limiter>=3.0.0",
|
|
12
12
|
"pytest-httpchain-jsonref",
|
|
13
|
+
"pytest-httpchain-mcp",
|
|
13
14
|
"pytest-httpchain-models",
|
|
14
15
|
"pytest-order>=1.3.0",
|
|
16
|
+
"typer>=0.16.0",
|
|
15
17
|
]
|
|
16
18
|
keywords = ["testing", "pytest", "requests"]
|
|
17
19
|
license = "MIT"
|
|
@@ -26,9 +28,6 @@ classifiers = [
|
|
|
26
28
|
"Topic :: Software Development :: Testing",
|
|
27
29
|
]
|
|
28
30
|
|
|
29
|
-
[project.optional-dependencies]
|
|
30
|
-
mcp = ["pytest-httpchain-mcp"]
|
|
31
|
-
|
|
32
31
|
[dependency-groups]
|
|
33
32
|
dev = [
|
|
34
33
|
"flask-httpauth>=4.8.0",
|
|
@@ -52,6 +51,7 @@ default-groups = ["dev", "docs"]
|
|
|
52
51
|
members = ["packages/*"]
|
|
53
52
|
|
|
54
53
|
[tool.uv.sources]
|
|
54
|
+
pytest-httpchain-core = { workspace = true }
|
|
55
55
|
pytest-httpchain-jsonref = { workspace = true }
|
|
56
56
|
pytest-httpchain-templates = { workspace = true }
|
|
57
57
|
pytest-httpchain-mcp = { workspace = true }
|
|
@@ -62,6 +62,9 @@ pytest-httpchain-userfunc = { workspace = true }
|
|
|
62
62
|
requires = ["uv_build>=0.7.21,<0.8.0"]
|
|
63
63
|
build-backend = "uv_build"
|
|
64
64
|
|
|
65
|
+
[project.scripts]
|
|
66
|
+
pytest-httpchain = "pytest_httpchain.cli:app"
|
|
67
|
+
|
|
65
68
|
[project.entry-points.pytest11]
|
|
66
69
|
pytest_httpchain = "pytest_httpchain.plugin"
|
|
67
70
|
|
|
@@ -97,6 +100,7 @@ pytester_example_dir = "tests/integration/examples"
|
|
|
97
100
|
addopts = ["--log-disable=werkzeug", "--import-mode=importlib"]
|
|
98
101
|
pythonpath = [
|
|
99
102
|
".",
|
|
103
|
+
"packages/pytest-httpchain-core/src",
|
|
100
104
|
"packages/pytest-httpchain-jsonref/src",
|
|
101
105
|
"packages/pytest-httpchain-templates/src",
|
|
102
106
|
"packages/pytest-httpchain-mcp/src",
|
|
@@ -117,6 +121,7 @@ pythonPlatform = "Linux"
|
|
|
117
121
|
[tool.coverage.run]
|
|
118
122
|
source = [
|
|
119
123
|
"src",
|
|
124
|
+
"packages/pytest-httpchain-core/src",
|
|
120
125
|
"packages/pytest-httpchain-jsonref/src",
|
|
121
126
|
"packages/pytest-httpchain-templates/src",
|
|
122
127
|
"packages/pytest-httpchain-mcp/src",
|
|
@@ -47,10 +47,9 @@ from pytest_httpchain_models import (
|
|
|
47
47
|
)
|
|
48
48
|
from pytest_httpchain_templates import TemplatesError, walk
|
|
49
49
|
from pytest_httpchain_userfunc import UserFunctionError
|
|
50
|
-
from simpleeval import EvalWithCompoundTypes
|
|
51
50
|
|
|
52
51
|
from .exceptions import RequestError, SaveError, StageExecutionError, VerificationError
|
|
53
|
-
from .utils import call_user_function, process_substitutions
|
|
52
|
+
from .utils import call_user_function, make_marker, process_substitutions
|
|
54
53
|
|
|
55
54
|
logger = logging.getLogger(__name__)
|
|
56
55
|
|
|
@@ -77,6 +76,7 @@ class Carrier:
|
|
|
77
76
|
last_response: ClassVar[httpx.Response | None] = None
|
|
78
77
|
global_context: ClassVar[ChainMap[str, Any]] = ChainMap()
|
|
79
78
|
active_context_managers: ClassVar[list[AbstractContextManager]] = []
|
|
79
|
+
max_parallel_iterations: ClassVar[int] = 10_000
|
|
80
80
|
|
|
81
81
|
@classmethod
|
|
82
82
|
def execute_stage(cls, stage: Stage, fixture_kwargs: dict[str, Any]) -> None:
|
|
@@ -120,14 +120,21 @@ class Carrier:
|
|
|
120
120
|
combos: list[dict[str, Any] | SimpleNamespace] = [vars(item) if isinstance(item, SimpleNamespace) else item for item in combinations]
|
|
121
121
|
iteration_substitutions = [{**existing, **combo} for combo in combos for existing in iteration_substitutions]
|
|
122
122
|
|
|
123
|
+
if len(iteration_substitutions) > cls.max_parallel_iterations:
|
|
124
|
+
raise StageExecutionError(
|
|
125
|
+
f"Parallel iteration count ({len(iteration_substitutions)}) exceeds maximum ({cls.max_parallel_iterations}). "
|
|
126
|
+
f"Set 'max_parallel_iterations' in pytest.ini to increase the limit."
|
|
127
|
+
)
|
|
128
|
+
|
|
123
129
|
# execute iterations
|
|
124
130
|
max_concurrency = parallel_config.max_concurrency if parallel_config else 1
|
|
125
131
|
calls_per_sec = parallel_config.calls_per_sec if parallel_config else None
|
|
132
|
+
max_rate_limit_delay = parallel_config.max_rate_limit_delay if parallel_config else 60
|
|
126
133
|
|
|
127
134
|
total = len(iteration_substitutions)
|
|
128
135
|
results: list[ParallelIterationResult | None] = [None] * total
|
|
129
136
|
first_error: tuple[int, Exception] | None = None
|
|
130
|
-
limiter = Limiter(Rate(calls_per_sec, Duration.SECOND), max_delay=Duration.
|
|
137
|
+
limiter = Limiter(Rate(calls_per_sec, Duration.SECOND), max_delay=Duration.SECOND * max_rate_limit_delay) if calls_per_sec else None
|
|
131
138
|
|
|
132
139
|
if total == 1:
|
|
133
140
|
try:
|
|
@@ -438,7 +445,7 @@ class Carrier:
|
|
|
438
445
|
cls.client = None
|
|
439
446
|
|
|
440
447
|
|
|
441
|
-
def create_test_class(scenario: Scenario, class_name: str) -> type[Carrier]:
|
|
448
|
+
def create_test_class(scenario: Scenario, class_name: str, max_parallel_iterations: int = 10_000) -> type[Carrier]:
|
|
442
449
|
"""Create a dynamic test class from a scenario definition."""
|
|
443
450
|
scenario_context = process_substitutions(scenario.substitutions)
|
|
444
451
|
|
|
@@ -467,6 +474,7 @@ def create_test_class(scenario: Scenario, class_name: str) -> type[Carrier]:
|
|
|
467
474
|
"last_response": None,
|
|
468
475
|
"global_context": ChainMap(scenario_context),
|
|
469
476
|
"active_context_managers": [],
|
|
477
|
+
"max_parallel_iterations": max_parallel_iterations,
|
|
470
478
|
},
|
|
471
479
|
)
|
|
472
480
|
|
|
@@ -519,12 +527,9 @@ def create_test_class(scenario: Scenario, class_name: str) -> type[Carrier]:
|
|
|
519
527
|
stage_method.__signature__ = inspect.Signature([inspect.Parameter(name, inspect.Parameter.POSITIONAL_OR_KEYWORD) for name in all_fixtures]) # type: ignore[assignment]
|
|
520
528
|
|
|
521
529
|
all_marks = [f"order({i})"] + stage.marks
|
|
522
|
-
evaluator = EvalWithCompoundTypes(names={"pytest": pytest})
|
|
523
530
|
for mark_str in all_marks:
|
|
524
531
|
try:
|
|
525
|
-
|
|
526
|
-
if marker:
|
|
527
|
-
stage_method = marker(stage_method)
|
|
532
|
+
stage_method = make_marker(mark_str)(stage_method)
|
|
528
533
|
except Exception as e:
|
|
529
534
|
logger.warning(f"Failed to create marker '{mark_str}': {e}")
|
|
530
535
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
app = typer.Typer()
|
|
8
|
+
|
|
9
|
+
SKILL_FILE = Path(__file__).parent / "skill.md"
|
|
10
|
+
|
|
11
|
+
MCP_SERVER_CONFIG = {
|
|
12
|
+
"command": "uv",
|
|
13
|
+
"args": ["run", "pytest-httpchain", "mcp"],
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.command()
|
|
18
|
+
def mcp() -> None:
|
|
19
|
+
"""Run the MCP server."""
|
|
20
|
+
from pytest_httpchain_mcp.server import mcp as server
|
|
21
|
+
|
|
22
|
+
server.run()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command()
|
|
26
|
+
def install(
|
|
27
|
+
skill: bool = typer.Option(..., "--skill/--no-skill", "-s/-S", help="Install Claude Code skill"),
|
|
28
|
+
mcp_config: bool = typer.Option(..., "--mcp/--no-mcp", "-m/-M", help="Install MCP server config"),
|
|
29
|
+
global_: bool = typer.Option(False, "--global", "-g", help="Install to ~/.claude (personal scope) instead of project"),
|
|
30
|
+
project_dir: Annotated[Path, typer.Option(help="Project directory (ignored with --global)")] = Path("."),
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Install MCP server config and/or Claude Code skill."""
|
|
33
|
+
if global_:
|
|
34
|
+
if skill:
|
|
35
|
+
_install_skill(Path.home() / ".claude" / "skills" / "pytest-httpchain")
|
|
36
|
+
if mcp_config:
|
|
37
|
+
typer.echo("To add the MCP server globally, run:")
|
|
38
|
+
typer.echo(" claude mcp add --scope user pytest-httpchain -- uv run pytest-httpchain mcp")
|
|
39
|
+
else:
|
|
40
|
+
project_dir = project_dir.resolve()
|
|
41
|
+
if skill:
|
|
42
|
+
_install_skill(project_dir / ".claude" / "skills" / "pytest-httpchain")
|
|
43
|
+
if mcp_config:
|
|
44
|
+
_install_mcp_config(project_dir / ".mcp.json")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _install_skill(skill_dir: Path) -> None:
|
|
48
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
dest = skill_dir / "SKILL.md"
|
|
50
|
+
dest.write_text(SKILL_FILE.read_text())
|
|
51
|
+
typer.echo(f"Installed skill to {dest}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _install_mcp_config(mcp_file: Path) -> None:
|
|
55
|
+
if mcp_file.exists():
|
|
56
|
+
config = json.loads(mcp_file.read_text())
|
|
57
|
+
else:
|
|
58
|
+
config = {}
|
|
59
|
+
|
|
60
|
+
config.setdefault("mcpServers", {})
|
|
61
|
+
config["mcpServers"]["pytest-httpchain"] = MCP_SERVER_CONFIG
|
|
62
|
+
mcp_file.write_text(json.dumps(config, indent=2) + "\n")
|
|
63
|
+
typer.echo(f"Installed MCP server config to {mcp_file}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
app()
|
|
@@ -19,8 +19,13 @@ class ConfigOptions(StrEnum):
|
|
|
19
19
|
in $ref paths for security (default: "3").
|
|
20
20
|
MAX_COMPREHENSION_LENGTH: Maximum length for list/dict comprehensions
|
|
21
21
|
in template expressions (default: "50000").
|
|
22
|
+
MAX_PARALLEL_ITERATIONS: Maximum number of parallel iterations allowed
|
|
23
|
+
per stage (default: "10000").
|
|
24
|
+
OUTPUT_DIR: Directory path for test output files (HAR, etc).
|
|
22
25
|
"""
|
|
23
26
|
|
|
24
27
|
SUFFIX = "suffix"
|
|
25
28
|
REF_PARENT_TRAVERSAL_DEPTH = "ref_parent_traversal_depth"
|
|
26
29
|
MAX_COMPREHENSION_LENGTH = "max_comprehension_length"
|
|
30
|
+
MAX_PARALLEL_ITERATIONS = "max_parallel_iterations"
|
|
31
|
+
OUTPUT_DIR = "output_dir"
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import httpx
|
|
2
|
+
from pytest_httpchain_core import HttpChainError
|
|
2
3
|
|
|
3
4
|
|
|
4
|
-
class StageExecutionError(
|
|
5
|
+
class StageExecutionError(HttpChainError):
|
|
5
6
|
"""Base exception for stage execution errors.
|
|
6
7
|
|
|
7
8
|
Optionally carries HTTP request/response for debugging failed stages.
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""HAR (HTTP Archive) format writer for pytest-httpchain.
|
|
2
|
+
|
|
3
|
+
This module converts httpx Request/Response objects to HAR 1.2 format
|
|
4
|
+
and writes them to files for external analysis.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import parse_qs, urlparse
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _get_version() -> str:
|
|
17
|
+
"""Get pytest-httpchain version for HAR creator info."""
|
|
18
|
+
try:
|
|
19
|
+
from importlib.metadata import version
|
|
20
|
+
|
|
21
|
+
return version("pytest-httpchain")
|
|
22
|
+
except Exception:
|
|
23
|
+
return "unknown"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _format_cookies(cookies: httpx.Cookies) -> list[dict[str, Any]]:
|
|
27
|
+
"""Convert httpx Cookies to HAR cookie format."""
|
|
28
|
+
result = []
|
|
29
|
+
for name, value in cookies.items():
|
|
30
|
+
result.append({"name": name, "value": value})
|
|
31
|
+
return result
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse_cookie_header(cookie_header: str) -> list[dict[str, str]]:
|
|
35
|
+
"""Parse Cookie header string into HAR cookie format."""
|
|
36
|
+
if not cookie_header:
|
|
37
|
+
return []
|
|
38
|
+
result = []
|
|
39
|
+
for pair in cookie_header.split(";"):
|
|
40
|
+
pair = pair.strip()
|
|
41
|
+
if "=" in pair:
|
|
42
|
+
name, value = pair.split("=", 1)
|
|
43
|
+
result.append({"name": name.strip(), "value": value.strip()})
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _format_headers(headers: httpx.Headers) -> list[dict[str, str]]:
|
|
48
|
+
"""Convert httpx Headers to HAR header format."""
|
|
49
|
+
return [{"name": name, "value": value} for name, value in headers.items()]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _format_query_string(url: httpx.URL) -> list[dict[str, str]]:
|
|
53
|
+
"""Extract query string parameters from URL."""
|
|
54
|
+
parsed = urlparse(str(url))
|
|
55
|
+
params = parse_qs(parsed.query, keep_blank_values=True)
|
|
56
|
+
result = []
|
|
57
|
+
for name, values in params.items():
|
|
58
|
+
for value in values:
|
|
59
|
+
result.append({"name": name, "value": value})
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _format_post_data(request: httpx.Request) -> dict[str, Any] | None:
|
|
64
|
+
"""Format request body as HAR postData."""
|
|
65
|
+
if not request.content:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
content_type = request.headers.get("content-type", "")
|
|
69
|
+
mime_type = content_type.split(";")[0].strip() if content_type else "application/octet-stream"
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
text = request.content.decode("utf-8")
|
|
73
|
+
except UnicodeDecodeError:
|
|
74
|
+
import base64
|
|
75
|
+
|
|
76
|
+
text = base64.b64encode(request.content).decode("ascii")
|
|
77
|
+
return {
|
|
78
|
+
"mimeType": mime_type,
|
|
79
|
+
"text": text,
|
|
80
|
+
"encoding": "base64",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
post_data: dict[str, Any] = {
|
|
84
|
+
"mimeType": mime_type,
|
|
85
|
+
"text": text,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if "application/x-www-form-urlencoded" in content_type:
|
|
89
|
+
params = parse_qs(text, keep_blank_values=True)
|
|
90
|
+
post_data["params"] = [{"name": k, "value": v[0] if len(v) == 1 else v} for k, v in params.items()]
|
|
91
|
+
|
|
92
|
+
return post_data
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _format_response_content(response: httpx.Response) -> dict[str, Any]:
|
|
96
|
+
"""Format response body as HAR content."""
|
|
97
|
+
content_type = response.headers.get("content-type", "")
|
|
98
|
+
mime_type = content_type.split(";")[0].strip() if content_type else "application/octet-stream"
|
|
99
|
+
|
|
100
|
+
content: dict[str, Any] = {
|
|
101
|
+
"size": len(response.content) if response.content else 0,
|
|
102
|
+
"mimeType": mime_type,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if response.content:
|
|
106
|
+
try:
|
|
107
|
+
content["text"] = response.content.decode("utf-8")
|
|
108
|
+
except UnicodeDecodeError:
|
|
109
|
+
import base64
|
|
110
|
+
|
|
111
|
+
content["text"] = base64.b64encode(response.content).decode("ascii")
|
|
112
|
+
content["encoding"] = "base64"
|
|
113
|
+
|
|
114
|
+
return content
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _calculate_headers_size(headers: httpx.Headers) -> int:
|
|
118
|
+
"""Calculate approximate size of headers in bytes."""
|
|
119
|
+
size = 0
|
|
120
|
+
for name, value in headers.items():
|
|
121
|
+
size += len(name) + len(value) + 4
|
|
122
|
+
return size
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def request_response_to_har_entry(
|
|
126
|
+
request: httpx.Request,
|
|
127
|
+
response: httpx.Response,
|
|
128
|
+
started_datetime: datetime | None = None,
|
|
129
|
+
elapsed_ms: float = 0,
|
|
130
|
+
) -> dict[str, Any]:
|
|
131
|
+
"""Convert an httpx Request/Response pair to a HAR entry.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
request: The httpx Request object.
|
|
135
|
+
response: The httpx Response object.
|
|
136
|
+
started_datetime: When the request started (defaults to now).
|
|
137
|
+
elapsed_ms: Total elapsed time in milliseconds.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
A dictionary representing a HAR entry.
|
|
141
|
+
"""
|
|
142
|
+
if started_datetime is None:
|
|
143
|
+
started_datetime = datetime.now(UTC)
|
|
144
|
+
|
|
145
|
+
entry: dict[str, Any] = {
|
|
146
|
+
"startedDateTime": started_datetime.isoformat(),
|
|
147
|
+
"time": elapsed_ms,
|
|
148
|
+
"request": {
|
|
149
|
+
"method": request.method,
|
|
150
|
+
"url": str(request.url),
|
|
151
|
+
"httpVersion": response.http_version or "HTTP/1.1",
|
|
152
|
+
"cookies": _parse_cookie_header(request.headers.get("cookie", "")),
|
|
153
|
+
"headers": _format_headers(request.headers),
|
|
154
|
+
"queryString": _format_query_string(request.url),
|
|
155
|
+
"headersSize": _calculate_headers_size(request.headers),
|
|
156
|
+
"bodySize": len(request.content) if request.content else 0,
|
|
157
|
+
},
|
|
158
|
+
"response": {
|
|
159
|
+
"status": response.status_code,
|
|
160
|
+
"statusText": response.reason_phrase or "",
|
|
161
|
+
"httpVersion": response.http_version or "HTTP/1.1",
|
|
162
|
+
"cookies": _format_cookies(response.cookies),
|
|
163
|
+
"headers": _format_headers(response.headers),
|
|
164
|
+
"content": _format_response_content(response),
|
|
165
|
+
"redirectURL": response.headers.get("location", ""),
|
|
166
|
+
"headersSize": _calculate_headers_size(response.headers),
|
|
167
|
+
"bodySize": len(response.content) if response.content else 0,
|
|
168
|
+
},
|
|
169
|
+
"cache": {},
|
|
170
|
+
"timings": {
|
|
171
|
+
"send": -1,
|
|
172
|
+
"wait": elapsed_ms if elapsed_ms > 0 else -1,
|
|
173
|
+
"receive": -1,
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
post_data = _format_post_data(request)
|
|
178
|
+
if post_data:
|
|
179
|
+
entry["request"]["postData"] = post_data
|
|
180
|
+
|
|
181
|
+
return entry
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def create_har_log(entries: list[dict[str, Any]], comment: str | None = None) -> dict[str, Any]:
|
|
185
|
+
"""Create a complete HAR log structure.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
entries: List of HAR entry dictionaries.
|
|
189
|
+
comment: Optional comment to include in the log.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
A complete HAR log dictionary.
|
|
193
|
+
"""
|
|
194
|
+
har: dict[str, Any] = {
|
|
195
|
+
"log": {
|
|
196
|
+
"version": "1.2",
|
|
197
|
+
"creator": {
|
|
198
|
+
"name": "pytest-httpchain",
|
|
199
|
+
"version": _get_version(),
|
|
200
|
+
},
|
|
201
|
+
"entries": entries,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if comment:
|
|
206
|
+
har["log"]["comment"] = comment
|
|
207
|
+
|
|
208
|
+
return har
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def write_har_file(
|
|
212
|
+
output_dir: Path,
|
|
213
|
+
test_name: str,
|
|
214
|
+
request: httpx.Request,
|
|
215
|
+
response: httpx.Response,
|
|
216
|
+
started_datetime: datetime | None = None,
|
|
217
|
+
elapsed_ms: float = 0,
|
|
218
|
+
) -> Path:
|
|
219
|
+
"""Write a HAR file for a single test.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
output_dir: Directory to write the HAR file to.
|
|
223
|
+
test_name: Name of the test (used for filename).
|
|
224
|
+
request: The httpx Request object.
|
|
225
|
+
response: The httpx Response object.
|
|
226
|
+
started_datetime: When the request started.
|
|
227
|
+
elapsed_ms: Total elapsed time in milliseconds.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Path to the written HAR file.
|
|
231
|
+
"""
|
|
232
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
|
|
234
|
+
safe_name = test_name.replace("/", "_").replace("\\", "_").replace(":", "_")
|
|
235
|
+
filename = f"{safe_name}.har"
|
|
236
|
+
filepath = output_dir / filename
|
|
237
|
+
|
|
238
|
+
entry = request_response_to_har_entry(request, response, started_datetime, elapsed_ms)
|
|
239
|
+
har = create_har_log([entry], comment=f"Test: {test_name}")
|
|
240
|
+
|
|
241
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
242
|
+
json.dump(har, f, indent=2, ensure_ascii=False)
|
|
243
|
+
|
|
244
|
+
return filepath
|
|
@@ -12,12 +12,13 @@ from _pytest import config, nodes, python, reports, runner
|
|
|
12
12
|
from _pytest.config import argparsing
|
|
13
13
|
from pydantic import ValidationError
|
|
14
14
|
from pytest_httpchain_models.entities import Scenario
|
|
15
|
-
from simpleeval import EvalWithCompoundTypes
|
|
16
15
|
|
|
17
16
|
from pytest_httpchain.constants import ConfigOptions
|
|
18
17
|
|
|
19
18
|
from .carrier import Carrier, create_test_class
|
|
19
|
+
from .har_writer import write_har_file
|
|
20
20
|
from .report_formatter import format_request, format_response
|
|
21
|
+
from .utils import make_marker
|
|
21
22
|
|
|
22
23
|
logger = logging.getLogger(__name__)
|
|
23
24
|
|
|
@@ -59,7 +60,8 @@ class JsonModule(python.Module):
|
|
|
59
60
|
raise nodes.Collector.CollectError(full_error_msg) from None
|
|
60
61
|
|
|
61
62
|
# generate python test class
|
|
62
|
-
|
|
63
|
+
max_parallel_iterations = int(self.config.getini(ConfigOptions.MAX_PARALLEL_ITERATIONS))
|
|
64
|
+
CarrierClass = create_test_class(scenario, self.name, max_parallel_iterations=max_parallel_iterations)
|
|
63
65
|
dummy_module = types.ModuleType("generated")
|
|
64
66
|
setattr(dummy_module, self.name, CarrierClass)
|
|
65
67
|
self._getobj = lambda: dummy_module # ty: ignore[invalid-assignment]
|
|
@@ -71,14 +73,11 @@ class JsonModule(python.Module):
|
|
|
71
73
|
)
|
|
72
74
|
|
|
73
75
|
# apply class-level markers
|
|
74
|
-
evaluator = EvalWithCompoundTypes(names={"pytest": pytest})
|
|
75
76
|
for mark_str in scenario.marks:
|
|
76
77
|
try:
|
|
77
|
-
|
|
78
|
-
if marker:
|
|
79
|
-
json_class.add_marker(marker)
|
|
78
|
+
json_class.add_marker(make_marker(mark_str))
|
|
80
79
|
except Exception as e:
|
|
81
|
-
|
|
80
|
+
raise nodes.Collector.CollectError(f"Invalid marker '{mark_str}' in {self.path}: {e}") from None
|
|
82
81
|
|
|
83
82
|
yield json_class
|
|
84
83
|
|
|
@@ -102,6 +101,18 @@ def pytest_addoption(parser: argparsing.Parser) -> None:
|
|
|
102
101
|
type="string",
|
|
103
102
|
default="50000",
|
|
104
103
|
)
|
|
104
|
+
parser.addini(
|
|
105
|
+
name=ConfigOptions.MAX_PARALLEL_ITERATIONS,
|
|
106
|
+
help="Maximum number of parallel iterations allowed per stage.",
|
|
107
|
+
type="string",
|
|
108
|
+
default="10000",
|
|
109
|
+
)
|
|
110
|
+
parser.addoption(
|
|
111
|
+
"--output-dir",
|
|
112
|
+
dest="output_dir",
|
|
113
|
+
default=None,
|
|
114
|
+
help="Directory to write test output files (HAR format for HTTP communications).",
|
|
115
|
+
)
|
|
105
116
|
|
|
106
117
|
|
|
107
118
|
def pytest_configure(config: config.Config) -> None:
|
|
@@ -118,7 +129,13 @@ def pytest_configure(config: config.Config) -> None:
|
|
|
118
129
|
raise ValueError("Maximum comprehension length must be a positive integer")
|
|
119
130
|
if max_comprehension_length > 1_000_000:
|
|
120
131
|
raise ValueError("Maximum comprehension length must not exceed 1,000,000")
|
|
121
|
-
simpleeval.MAX_COMPREHENSION_LENGTH = max_comprehension_length #
|
|
132
|
+
simpleeval.MAX_COMPREHENSION_LENGTH = max_comprehension_length # ty: ignore[invalid-assignment]
|
|
133
|
+
|
|
134
|
+
max_parallel_iterations = int(config.getini(ConfigOptions.MAX_PARALLEL_ITERATIONS))
|
|
135
|
+
if max_parallel_iterations < 1:
|
|
136
|
+
raise ValueError("Maximum parallel iterations must be a positive integer")
|
|
137
|
+
if max_parallel_iterations > 1_000_000:
|
|
138
|
+
raise ValueError("Maximum parallel iterations must not exceed 1,000,000")
|
|
122
139
|
|
|
123
140
|
|
|
124
141
|
def pytest_collect_file(file_path: Path, parent: nodes.Collector) -> nodes.Collector | None:
|
|
@@ -150,3 +167,16 @@ def pytest_runtest_makereport(item: nodes.Item, call: runner.CallInfo[Any]) -> A
|
|
|
150
167
|
report.sections.append(("HTTP Response", format_response(carrier.last_response)))
|
|
151
168
|
except Exception as e:
|
|
152
169
|
report.sections.append(("HTTP Response", f"<Error formatting response: {e}>"))
|
|
170
|
+
|
|
171
|
+
output_dir = item.config.getoption("output_dir")
|
|
172
|
+
if output_dir and carrier.last_request is not None and carrier.last_response is not None:
|
|
173
|
+
try:
|
|
174
|
+
har_path = write_har_file(
|
|
175
|
+
output_dir=Path(output_dir),
|
|
176
|
+
test_name=item.nodeid,
|
|
177
|
+
request=carrier.last_request,
|
|
178
|
+
response=carrier.last_response,
|
|
179
|
+
)
|
|
180
|
+
report.sections.append(("HAR File", str(har_path)))
|
|
181
|
+
except Exception as e:
|
|
182
|
+
logger.warning(f"Failed to write HAR file for {item.nodeid}: {e}")
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pytest-httpchain
|
|
3
|
+
description: Write and edit pytest-httpchain HTTP API test scenarios in JSON format
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# pytest-httpchain test authoring
|
|
7
|
+
|
|
8
|
+
pytest-httpchain is a pytest plugin for declarative HTTP API integration testing. Test scenarios are JSON files discovered by pattern `test_<name>.http.json`.
|
|
9
|
+
|
|
10
|
+
## Scenario structure
|
|
11
|
+
|
|
12
|
+
```json
|
|
13
|
+
{
|
|
14
|
+
"description": "optional scenario description",
|
|
15
|
+
"marks": ["optional_pytest_markers"],
|
|
16
|
+
"substitutions": [],
|
|
17
|
+
"stages": []
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Stage structure
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"name": "stage name",
|
|
26
|
+
"description": "optional",
|
|
27
|
+
"fixtures": ["fixture_name"],
|
|
28
|
+
"marks": ["skip", "xfail(reason='not ready')"],
|
|
29
|
+
"always_run": false,
|
|
30
|
+
"substitutions": [],
|
|
31
|
+
"parametrize": [],
|
|
32
|
+
"parallel": null,
|
|
33
|
+
"request": { ... },
|
|
34
|
+
"response": [ ... ]
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Stages run sequentially and share a global context. Values saved in one stage are available in subsequent stages.
|
|
39
|
+
|
|
40
|
+
Stages can also be written as a dict (keys become stage names):
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"stages": {
|
|
45
|
+
"create user": { "request": { ... }, "response": [ ... ] },
|
|
46
|
+
"get user": { "request": { ... }, "response": [ ... ] }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Request
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"url": "{{ server }}/api/users",
|
|
56
|
+
"method": "POST",
|
|
57
|
+
"headers": { "Authorization": "Bearer {{ token }}" },
|
|
58
|
+
"params": { "page": 1 },
|
|
59
|
+
"body": { "json": { "name": "Alice" } },
|
|
60
|
+
"timeout": 30.0,
|
|
61
|
+
"allow_redirects": true
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Body types** (use exactly one key):
|
|
66
|
+
- `{"json": { ... }}` - JSON body
|
|
67
|
+
- `{"form": { ... }}` - URL-encoded form
|
|
68
|
+
- `{"text": "..."}` - raw text
|
|
69
|
+
- `{"xml": "<root/>"}` - XML
|
|
70
|
+
- `{"base64": "..."}` - base64-encoded binary
|
|
71
|
+
- `{"binary": "/path/to/file"}` - file upload
|
|
72
|
+
- `{"files": {"field": "/path/to/file"}}` - multipart file upload
|
|
73
|
+
- `{"graphql": {"query": "...", "variables": {}}}` - GraphQL
|
|
74
|
+
|
|
75
|
+
## Response steps
|
|
76
|
+
|
|
77
|
+
Response is a list of verify and save steps, executed in order:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
"response": [
|
|
81
|
+
{
|
|
82
|
+
"verify": {
|
|
83
|
+
"status": 200,
|
|
84
|
+
"headers": { "content-type": "application/json" },
|
|
85
|
+
"expressions": [
|
|
86
|
+
"{{ user_count > 0 }}",
|
|
87
|
+
"{{ 'error' not in body }}"
|
|
88
|
+
],
|
|
89
|
+
"body": {
|
|
90
|
+
"schema": { "type": "object", "required": ["id"] },
|
|
91
|
+
"contains": ["expected text"],
|
|
92
|
+
"not_contains": ["error"],
|
|
93
|
+
"matches": ["\\d{4}-\\d{2}-\\d{2}"],
|
|
94
|
+
"not_matches": ["forbidden"]
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"save": {
|
|
100
|
+
"jmespath": {
|
|
101
|
+
"user_id": "data.id",
|
|
102
|
+
"user_name": "data.name",
|
|
103
|
+
"total": "length(items)"
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**Save types:**
|
|
111
|
+
- `{"jmespath": {...}}` - extract values from JSON response via JMESPath
|
|
112
|
+
- `{"substitutions": [...]}` - compute values using template expressions
|
|
113
|
+
- `{"user_functions": [...]}` - call Python functions to process response
|
|
114
|
+
|
|
115
|
+
## Template expressions
|
|
116
|
+
|
|
117
|
+
Use `{{ expr }}` syntax. Expressions are evaluated with Python semantics.
|
|
118
|
+
|
|
119
|
+
**Available context:** all saved variables, fixture values, and substitution results.
|
|
120
|
+
|
|
121
|
+
**Built-in functions:** `len`, `min`, `max`, `sum`, `abs`, `round`, `sorted`, `range`, `zip`, `enumerate`, `bool`, `int`, `float`, `str`, `dict`, `list`, `tuple`, `set`, `uuid4()`, `env(var, default)`, `get(var, default)`, `exists(var)`, `rand()`, `randint(a, b)`
|
|
122
|
+
|
|
123
|
+
**JSON literals:** `true`, `false`, `null` map to Python `True`, `False`, `None`.
|
|
124
|
+
|
|
125
|
+
## Substitutions
|
|
126
|
+
|
|
127
|
+
Define variables before stages run:
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
"substitutions": [
|
|
131
|
+
{ "vars": { "base_url": "https://api.example.com", "count": "{{ 2 + 3 }}" } },
|
|
132
|
+
{ "functions": { "generate_token": "mymodule:create_jwt" } }
|
|
133
|
+
]
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Substitutions can appear at scenario level (global) or stage level (local).
|
|
137
|
+
|
|
138
|
+
## References ($include / $ref)
|
|
139
|
+
|
|
140
|
+
Split scenarios across files using `$include` (preferred) or `$ref`:
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
{
|
|
144
|
+
"request": {
|
|
145
|
+
"$include": "common.json#/requests/get_user"
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Sibling properties are deep-merged with the referenced content:
|
|
151
|
+
|
|
152
|
+
```json
|
|
153
|
+
{
|
|
154
|
+
"$include": "base_request.json",
|
|
155
|
+
"headers": { "X-Custom": "override" }
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Parametrize
|
|
160
|
+
|
|
161
|
+
Run a stage with different inputs:
|
|
162
|
+
|
|
163
|
+
```json
|
|
164
|
+
"parametrize": [
|
|
165
|
+
{
|
|
166
|
+
"individual": { "user_id": [1, 2, 3] },
|
|
167
|
+
"ids": ["user-one", "user-two", "user-three"]
|
|
168
|
+
}
|
|
169
|
+
]
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Or use combinations:
|
|
173
|
+
|
|
174
|
+
```json
|
|
175
|
+
"parametrize": [
|
|
176
|
+
{
|
|
177
|
+
"combinations": [
|
|
178
|
+
{ "method": "GET", "expected": 200 },
|
|
179
|
+
{ "method": "DELETE", "expected": 403 }
|
|
180
|
+
]
|
|
181
|
+
}
|
|
182
|
+
]
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Parallel execution
|
|
186
|
+
|
|
187
|
+
Execute requests concurrently for load testing:
|
|
188
|
+
|
|
189
|
+
```json
|
|
190
|
+
"parallel": {
|
|
191
|
+
"repeat": 100,
|
|
192
|
+
"max_concurrency": 10,
|
|
193
|
+
"calls_per_sec": 50
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Or iterate over parameter sets in parallel:
|
|
198
|
+
|
|
199
|
+
```json
|
|
200
|
+
"parallel": {
|
|
201
|
+
"foreach": [{ "individual": { "id": [1, 2, 3, 4, 5] } }],
|
|
202
|
+
"max_concurrency": 5
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Complete example: multi-stage API test
|
|
207
|
+
|
|
208
|
+
```json
|
|
209
|
+
{
|
|
210
|
+
"substitutions": [
|
|
211
|
+
{ "vars": { "base": "{{ env('API_URL', 'http://localhost:8000') }}" } }
|
|
212
|
+
],
|
|
213
|
+
"stages": [
|
|
214
|
+
{
|
|
215
|
+
"name": "create user",
|
|
216
|
+
"request": {
|
|
217
|
+
"url": "{{ base }}/users",
|
|
218
|
+
"method": "POST",
|
|
219
|
+
"body": { "json": { "name": "Alice", "email": "alice@example.com" } }
|
|
220
|
+
},
|
|
221
|
+
"response": [
|
|
222
|
+
{ "verify": { "status": 201 } },
|
|
223
|
+
{ "save": { "jmespath": { "user_id": "id" } } }
|
|
224
|
+
]
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
"name": "get user",
|
|
228
|
+
"request": {
|
|
229
|
+
"url": "{{ base }}/users/{{ user_id }}"
|
|
230
|
+
},
|
|
231
|
+
"response": [
|
|
232
|
+
{
|
|
233
|
+
"verify": {
|
|
234
|
+
"status": 200,
|
|
235
|
+
"expressions": ["{{ body.name == 'Alice' }}"]
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
]
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
"name": "delete user",
|
|
242
|
+
"request": {
|
|
243
|
+
"url": "{{ base }}/users/{{ user_id }}",
|
|
244
|
+
"method": "DELETE"
|
|
245
|
+
},
|
|
246
|
+
"response": [
|
|
247
|
+
{ "verify": { "status": 204 } }
|
|
248
|
+
]
|
|
249
|
+
}
|
|
250
|
+
]
|
|
251
|
+
}
|
|
252
|
+
```
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import ast
|
|
1
2
|
import logging
|
|
2
3
|
from collections.abc import Mapping, Sequence
|
|
3
4
|
from typing import Any
|
|
4
5
|
|
|
6
|
+
import pytest
|
|
5
7
|
from pytest_httpchain_models import FunctionsSubstitution, Substitution, UserFunctionCall, UserFunctionKwargs, UserFunctionName, VarsSubstitution
|
|
6
8
|
from pytest_httpchain_templates import walk
|
|
7
9
|
from pytest_httpchain_userfunc import call_function, wrap_function
|
|
@@ -11,6 +13,22 @@ from .exceptions import StageExecutionError
|
|
|
11
13
|
logger = logging.getLogger(__name__)
|
|
12
14
|
|
|
13
15
|
|
|
16
|
+
def make_marker(mark_str: str) -> pytest.MarkDecorator:
|
|
17
|
+
"""Create a pytest marker from a string like 'skip(reason="foo")' or 'geofencing'."""
|
|
18
|
+
tree = ast.parse(mark_str, mode="eval")
|
|
19
|
+
node = tree.body
|
|
20
|
+
|
|
21
|
+
if isinstance(node, ast.Name):
|
|
22
|
+
return getattr(pytest.mark, node.id)
|
|
23
|
+
|
|
24
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
|
|
25
|
+
args = [ast.literal_eval(a) for a in node.args]
|
|
26
|
+
kwargs = {kw.arg: ast.literal_eval(kw.value) for kw in node.keywords if kw.arg is not None}
|
|
27
|
+
return getattr(pytest.mark, node.func.id)(*args, **kwargs)
|
|
28
|
+
|
|
29
|
+
raise ValueError(f"unsupported marker expression: {mark_str}")
|
|
30
|
+
|
|
31
|
+
|
|
14
32
|
def process_substitutions(
|
|
15
33
|
substitutions: Sequence[Substitution],
|
|
16
34
|
context: Mapping[str, Any] = {},
|
|
File without changes
|
|
File without changes
|
|
File without changes
|