pytest-httpchain 0.2.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-httpchain
3
- Version: 0.2.1
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: pytest-httpchain-mcp ; extra == 'mcp'
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
  [![image](https://img.shields.io/pypi/v/pytest-httpchain)](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
- ## Installation
42
-
43
- Install normally via package manager of your choice from PyPi:
41
+ ## Why pytest-httpchain?
44
42
 
45
- ```bash
46
- pip install pytest-httpchain
47
- ```
43
+ Testing HTTP APIs with plain pytest often leads to these pain points:
48
44
 
49
- or directly from Github, in case you need a particular ref:
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
- ```bash
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
- ### Pytest integration
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 written declaratively in JSON files.
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
- ### Multi-stage tests
58
+ ### `$ref` with deep merging
74
59
 
75
- Each test scenario contains 1+ stages; each stage is a single HTTP call.
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
- ### Common data context and variable substitution
62
+ ### Multi-stage execution
79
63
 
80
- `pytest-httpchain` maintains key-value data storage throughout the execution.
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
- ### User functions
66
+ ### Common data context
86
67
 
87
- `pytest-httpchain` can import and call regular python functions:
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
- - to extract data from HTTP response
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
- ### JMESPath support
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
- `pytest-httpchain` can extract values from JSON responses using JMESPath expressions directly.
76
+ ### Full pytest integration
97
77
 
98
- ### JSON schema support
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 examples see [USAGE.md](USAGE.md).
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
- ### Installation
203
+ ### Setup
213
204
 
214
- The optional dependency `mcp` installs MCP server's package and `pytest-httpchain-mcp` script.
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
- Claude Code `.mcp.json` example:
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
- "type": "stdio",
224
- "command": "uv",
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
- - [Usage Examples](USAGE.md) - Practical code examples
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
- `pytest-httpchain` was inspired by [Tavern](https://github.com/taverntesting/tavern) and [pytest-play](https://github.com/davidemoro/pytest-play).
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
- ## Installation
15
-
16
- Install normally via package manager of your choice from PyPi:
14
+ ## Why pytest-httpchain?
17
15
 
18
- ```bash
19
- pip install pytest-httpchain
20
- ```
16
+ Testing HTTP APIs with plain pytest often leads to these pain points:
21
17
 
22
- or directly from Github, in case you need a particular ref:
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
- ```bash
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
- ### Pytest integration
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 written declaratively in JSON files.
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
- ### Multi-stage tests
31
+ ### `$ref` with deep merging
47
32
 
48
- Each test scenario contains 1+ stages; each stage is a single HTTP call.
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
- ### Common data context and variable substitution
35
+ ### Multi-stage execution
52
36
 
53
- `pytest-httpchain` maintains key-value data storage throughout the execution.
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
- ### User functions
39
+ ### Common data context
59
40
 
60
- `pytest-httpchain` can import and call regular python functions:
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
- - to extract data from HTTP response
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
- ### JMESPath support
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
- `pytest-httpchain` can extract values from JSON responses using JMESPath expressions directly.
49
+ ### Full pytest integration
70
50
 
71
- ### JSON schema support
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 examples see [USAGE.md](USAGE.md).
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
- ### Installation
176
+ ### Setup
186
177
 
187
- The optional dependency `mcp` installs MCP server's package and `pytest-httpchain-mcp` script.
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
- Claude Code `.mcp.json` example:
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
- "type": "stdio",
197
- "command": "uv",
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
- - [Usage Examples](USAGE.md) - Practical code examples
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
- `pytest-httpchain` was inspired by [Tavern](https://github.com/taverntesting/tavern) and [pytest-play](https://github.com/davidemoro/pytest-play).
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.1"
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.HOUR) if calls_per_sec else None
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
- marker = evaluator.eval(f"pytest.mark.{mark_str}")
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(Exception):
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
- CarrierClass = create_test_class(scenario, self.name)
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
- marker = evaluator.eval(f"pytest.mark.{mark_str}")
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
- logger.warning(f"Failed to create marker '{mark_str}': {e}")
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 # type: ignore[misc]
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] = {},