pytest-httpchain 0.3.0__tar.gz → 0.5.0__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.3.0 → pytest_httpchain-0.5.0}/PKG-INFO +34 -11
- {pytest_httpchain-0.3.0 → pytest_httpchain-0.5.0}/README.md +33 -10
- {pytest_httpchain-0.3.0 → pytest_httpchain-0.5.0}/pyproject.toml +1 -1
- pytest_httpchain-0.5.0/src/pytest_httpchain/cli.py +222 -0
- pytest_httpchain-0.5.0/src/pytest_httpchain/dataflow.py +127 -0
- {pytest_httpchain-0.3.0 → pytest_httpchain-0.5.0}/src/pytest_httpchain/plugin.py +9 -6
- pytest_httpchain-0.5.0/src/pytest_httpchain/schema.py +69 -0
- pytest_httpchain-0.5.0/src/pytest_httpchain/validation.py +818 -0
- pytest_httpchain-0.3.0/src/pytest_httpchain/cli.py +0 -59
- pytest_httpchain-0.3.0/src/pytest_httpchain/skill.md +0 -257
- pytest_httpchain-0.3.0/src/pytest_httpchain/validation.py +0 -272
- {pytest_httpchain-0.3.0 → pytest_httpchain-0.5.0}/LICENSE +0 -0
- {pytest_httpchain-0.3.0 → pytest_httpchain-0.5.0}/src/pytest_httpchain/__init__.py +0 -0
- {pytest_httpchain-0.3.0 → pytest_httpchain-0.5.0}/src/pytest_httpchain/carrier.py +0 -0
- {pytest_httpchain-0.3.0 → pytest_httpchain-0.5.0}/src/pytest_httpchain/constants.py +0 -0
- {pytest_httpchain-0.3.0 → pytest_httpchain-0.5.0}/src/pytest_httpchain/exceptions.py +0 -0
- {pytest_httpchain-0.3.0 → pytest_httpchain-0.5.0}/src/pytest_httpchain/har_writer.py +0 -0
- {pytest_httpchain-0.3.0 → pytest_httpchain-0.5.0}/src/pytest_httpchain/report_formatter.py +0 -0
- {pytest_httpchain-0.3.0 → pytest_httpchain-0.5.0}/src/pytest_httpchain/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-httpchain
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: pytest plugin for HTTP testing using JSON files
|
|
5
5
|
Keywords: testing,pytest,requests
|
|
6
6
|
Author: Alexander Eresov
|
|
@@ -198,28 +198,30 @@ pip install 'git+https://github.com/aeresov/pytest-httpchain@main'
|
|
|
198
198
|
|
|
199
199
|
## AI agent support
|
|
200
200
|
|
|
201
|
-
`pytest-httpchain` ships
|
|
201
|
+
`pytest-httpchain` ships a scenario validator to help AI coding agents (and humans) author and check test scenarios.
|
|
202
202
|
|
|
203
|
-
###
|
|
203
|
+
### Scenario validation
|
|
204
204
|
|
|
205
|
-
|
|
205
|
+
Validate scenario files for structure and common problems — undefined variables, variables referenced before they are saved (data-flow ordering), duplicate stage names, fixture/variable conflicts, no-op `verify` steps, and contradictory body checks:
|
|
206
206
|
|
|
207
207
|
```bash
|
|
208
|
-
uvx pytest-httpchain
|
|
208
|
+
uvx pytest-httpchain validate tests/test_login.http.json
|
|
209
209
|
```
|
|
210
210
|
|
|
211
|
-
|
|
211
|
+
Each finding carries a stable diagnostic code (`HTTPCHAINxxx`) and a severity. It exits non-zero when any file is invalid, so it doubles as a CI gate. Use `--format json` for machine-readable output (editor/CI integration):
|
|
212
212
|
|
|
213
|
-
|
|
213
|
+
```bash
|
|
214
|
+
uvx pytest-httpchain validate --format json tests/test_login.http.json
|
|
215
|
+
```
|
|
214
216
|
|
|
215
|
-
|
|
217
|
+
The same checks also run automatically at **pytest collection time** — semantic errors fail collection and warnings are reported — so `pytest --collect-only` validates every scenario in your suite.
|
|
218
|
+
|
|
219
|
+
For deeper, opt-in checks, add `--deep`: it imports your `module:func` references to confirm they resolve, checks their call signatures (including the injected `response` for save/verify functions), and verifies referenced files and schemas exist. Because it imports your code it is never run at collection time; pair it with `--strict` to fail CI on any warning, and `--syspath` to add import roots:
|
|
216
220
|
|
|
217
221
|
```bash
|
|
218
|
-
uvx pytest-httpchain validate tests/test_login.http.json
|
|
222
|
+
uvx pytest-httpchain validate --deep --strict tests/test_login.http.json
|
|
219
223
|
```
|
|
220
224
|
|
|
221
|
-
It exits non-zero when any file is invalid, so it doubles as a CI gate. The same checks also run automatically at **pytest collection time** — semantic errors fail collection and warnings are reported — so `pytest --collect-only` validates every scenario in your suite.
|
|
222
|
-
|
|
223
225
|
### Editor schema
|
|
224
226
|
|
|
225
227
|
A JSON Schema is published for as-you-type validation and autocomplete. Reference it from your test files:
|
|
@@ -230,6 +232,27 @@ A JSON Schema is published for as-you-type validation and autocomplete. Referenc
|
|
|
230
232
|
}
|
|
231
233
|
```
|
|
232
234
|
|
|
235
|
+
The hosted schema tracks the latest release; to pin the schema matching your installed version (e.g. for CI), emit it locally:
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
uvx pytest-httpchain schema --output scenario.schema.json
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Inspecting scenarios
|
|
242
|
+
|
|
243
|
+
More read-only commands help author and debug scenarios offline — no network, no test run:
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
# Print a scenario with all $ref/$include/$merge inlined and deep-merged
|
|
247
|
+
uvx pytest-httpchain resolve tests/test_login.http.json
|
|
248
|
+
|
|
249
|
+
# Summarize stages and the variable data-flow (which stage saves what, who consumes it)
|
|
250
|
+
uvx pytest-httpchain show tests/test_login.http.json
|
|
251
|
+
|
|
252
|
+
# Render the stage data-flow as a Mermaid flowchart
|
|
253
|
+
uvx pytest-httpchain graph tests/test_login.http.json
|
|
254
|
+
```
|
|
255
|
+
|
|
233
256
|
## Documentation
|
|
234
257
|
|
|
235
258
|
- [Full Documentation](https://aeresov.github.io/pytest-httpchain) - Complete usage guide
|
|
@@ -171,28 +171,30 @@ pip install 'git+https://github.com/aeresov/pytest-httpchain@main'
|
|
|
171
171
|
|
|
172
172
|
## AI agent support
|
|
173
173
|
|
|
174
|
-
`pytest-httpchain` ships
|
|
174
|
+
`pytest-httpchain` ships a scenario validator to help AI coding agents (and humans) author and check test scenarios.
|
|
175
175
|
|
|
176
|
-
###
|
|
176
|
+
### Scenario validation
|
|
177
177
|
|
|
178
|
-
|
|
178
|
+
Validate scenario files for structure and common problems — undefined variables, variables referenced before they are saved (data-flow ordering), duplicate stage names, fixture/variable conflicts, no-op `verify` steps, and contradictory body checks:
|
|
179
179
|
|
|
180
180
|
```bash
|
|
181
|
-
uvx pytest-httpchain
|
|
181
|
+
uvx pytest-httpchain validate tests/test_login.http.json
|
|
182
182
|
```
|
|
183
183
|
|
|
184
|
-
|
|
184
|
+
Each finding carries a stable diagnostic code (`HTTPCHAINxxx`) and a severity. It exits non-zero when any file is invalid, so it doubles as a CI gate. Use `--format json` for machine-readable output (editor/CI integration):
|
|
185
185
|
|
|
186
|
-
|
|
186
|
+
```bash
|
|
187
|
+
uvx pytest-httpchain validate --format json tests/test_login.http.json
|
|
188
|
+
```
|
|
187
189
|
|
|
188
|
-
|
|
190
|
+
The same checks also run automatically at **pytest collection time** — semantic errors fail collection and warnings are reported — so `pytest --collect-only` validates every scenario in your suite.
|
|
191
|
+
|
|
192
|
+
For deeper, opt-in checks, add `--deep`: it imports your `module:func` references to confirm they resolve, checks their call signatures (including the injected `response` for save/verify functions), and verifies referenced files and schemas exist. Because it imports your code it is never run at collection time; pair it with `--strict` to fail CI on any warning, and `--syspath` to add import roots:
|
|
189
193
|
|
|
190
194
|
```bash
|
|
191
|
-
uvx pytest-httpchain validate tests/test_login.http.json
|
|
195
|
+
uvx pytest-httpchain validate --deep --strict tests/test_login.http.json
|
|
192
196
|
```
|
|
193
197
|
|
|
194
|
-
It exits non-zero when any file is invalid, so it doubles as a CI gate. The same checks also run automatically at **pytest collection time** — semantic errors fail collection and warnings are reported — so `pytest --collect-only` validates every scenario in your suite.
|
|
195
|
-
|
|
196
198
|
### Editor schema
|
|
197
199
|
|
|
198
200
|
A JSON Schema is published for as-you-type validation and autocomplete. Reference it from your test files:
|
|
@@ -203,6 +205,27 @@ A JSON Schema is published for as-you-type validation and autocomplete. Referenc
|
|
|
203
205
|
}
|
|
204
206
|
```
|
|
205
207
|
|
|
208
|
+
The hosted schema tracks the latest release; to pin the schema matching your installed version (e.g. for CI), emit it locally:
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
uvx pytest-httpchain schema --output scenario.schema.json
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Inspecting scenarios
|
|
215
|
+
|
|
216
|
+
More read-only commands help author and debug scenarios offline — no network, no test run:
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
# Print a scenario with all $ref/$include/$merge inlined and deep-merged
|
|
220
|
+
uvx pytest-httpchain resolve tests/test_login.http.json
|
|
221
|
+
|
|
222
|
+
# Summarize stages and the variable data-flow (which stage saves what, who consumes it)
|
|
223
|
+
uvx pytest-httpchain show tests/test_login.http.json
|
|
224
|
+
|
|
225
|
+
# Render the stage data-flow as a Mermaid flowchart
|
|
226
|
+
uvx pytest-httpchain graph tests/test_login.http.json
|
|
227
|
+
```
|
|
228
|
+
|
|
206
229
|
## Documentation
|
|
207
230
|
|
|
208
231
|
- [Full Documentation](https://aeresov.github.io/pytest-httpchain) - Complete usage guide
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
app = typer.Typer()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OutputFormat(enum.StrEnum):
|
|
12
|
+
text = "text"
|
|
13
|
+
json = "json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GraphDirection(enum.StrEnum):
|
|
17
|
+
TD = "TD"
|
|
18
|
+
LR = "LR"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.callback()
|
|
22
|
+
def main() -> None:
|
|
23
|
+
"""pytest-httpchain command-line tools."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command()
|
|
27
|
+
def validate(
|
|
28
|
+
paths: Annotated[list[Path], typer.Argument(help="Scenario JSON file(s) to validate.")],
|
|
29
|
+
ref_parent_traversal_depth: Annotated[int, typer.Option(help="Maximum $ref parent directory traversal depth.")] = 3,
|
|
30
|
+
output_format: Annotated[OutputFormat, typer.Option("--format", help="Output format: human-readable text or machine-readable JSON.")] = OutputFormat.text,
|
|
31
|
+
deep: Annotated[bool, typer.Option("--deep", help="Run deep checks: resolve user-function imports/signatures and referenced files. Imports user modules.")] = False,
|
|
32
|
+
syspath: Annotated[list[Path] | None, typer.Option("--syspath", help="Extra directories to add to sys.path for --deep import resolution (repeatable).")] = None,
|
|
33
|
+
strict: Annotated[bool, typer.Option("--strict", help="Treat warnings as failures for the exit code.")] = False,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Validate pytest-httpchain scenario file(s).
|
|
36
|
+
|
|
37
|
+
Reports errors and warnings (each with a stable HTTPCHAINxxx diagnostic code)
|
|
38
|
+
per file and exits non-zero if any file is invalid (or, with --strict, has any
|
|
39
|
+
warnings).
|
|
40
|
+
"""
|
|
41
|
+
from pytest_httpchain.validation import validate_scenario
|
|
42
|
+
|
|
43
|
+
results = [(path, validate_scenario(path, ref_parent_traversal_depth=ref_parent_traversal_depth, deep=deep, syspaths=list(syspath or []))) for path in paths]
|
|
44
|
+
|
|
45
|
+
def passed(result) -> bool:
|
|
46
|
+
return result.valid and not (strict and result.warnings)
|
|
47
|
+
|
|
48
|
+
all_passed = all(passed(result) for _, result in results)
|
|
49
|
+
|
|
50
|
+
if output_format is OutputFormat.json:
|
|
51
|
+
payload = {
|
|
52
|
+
"valid": all_passed,
|
|
53
|
+
"files": [{"path": str(path), "result": result.model_dump()} for path, result in results],
|
|
54
|
+
}
|
|
55
|
+
typer.echo(json.dumps(payload, indent=2, default=str))
|
|
56
|
+
else:
|
|
57
|
+
for path, result in results:
|
|
58
|
+
if not result.valid:
|
|
59
|
+
status = "INVALID"
|
|
60
|
+
elif result.warnings:
|
|
61
|
+
status = "FAILED (warnings)" if strict else "OK with warnings"
|
|
62
|
+
else:
|
|
63
|
+
status = "OK"
|
|
64
|
+
typer.echo(f"{path}: {status}")
|
|
65
|
+
for diagnostic in result.diagnostics:
|
|
66
|
+
typer.echo(f" {diagnostic.severity} [{diagnostic.code}]: {diagnostic.message}")
|
|
67
|
+
|
|
68
|
+
raise typer.Exit(0 if all_passed else 1)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.command()
|
|
72
|
+
def schema(
|
|
73
|
+
output: Annotated[Path | None, typer.Option("--output", "-o", help="Write schema to PATH instead of stdout.")] = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Emit the JSON Schema for scenario files (editor autocomplete/validation)."""
|
|
76
|
+
from pytest_httpchain.schema import build_schema
|
|
77
|
+
|
|
78
|
+
text = json.dumps(build_schema(), indent=2, default=str)
|
|
79
|
+
if output is not None:
|
|
80
|
+
output.write_text(text + "\n")
|
|
81
|
+
typer.echo(f"Wrote schema to {output}")
|
|
82
|
+
else:
|
|
83
|
+
typer.echo(text)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command()
|
|
87
|
+
def resolve(
|
|
88
|
+
scenario: Annotated[Path, typer.Argument(help="Scenario JSON file to resolve.")],
|
|
89
|
+
output: Annotated[Path | None, typer.Option("--output", "-o", help="Write resolved JSON to PATH instead of stdout.")] = None,
|
|
90
|
+
ref_parent_traversal_depth: Annotated[int, typer.Option(help="Maximum $ref parent directory traversal depth.")] = 3,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Resolve $ref/$include/$merge and print the merged scenario JSON."""
|
|
93
|
+
import pytest_httpchain_jsonref.loader
|
|
94
|
+
from pytest_httpchain_jsonref.exceptions import ReferenceResolverError
|
|
95
|
+
|
|
96
|
+
from pytest_httpchain.validation import resolve_root_path
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
data = pytest_httpchain_jsonref.loader.load_json(
|
|
100
|
+
scenario,
|
|
101
|
+
max_parent_traversal_depth=ref_parent_traversal_depth,
|
|
102
|
+
root_path=resolve_root_path(scenario),
|
|
103
|
+
)
|
|
104
|
+
except (ReferenceResolverError, json.JSONDecodeError, OSError) as e:
|
|
105
|
+
typer.echo(f"error: {e}", err=True)
|
|
106
|
+
raise typer.Exit(1) from e
|
|
107
|
+
|
|
108
|
+
text = json.dumps(data, indent=2, default=str)
|
|
109
|
+
if output is not None:
|
|
110
|
+
output.write_text(text + "\n")
|
|
111
|
+
typer.echo(f"Wrote resolved scenario to {output}")
|
|
112
|
+
else:
|
|
113
|
+
typer.echo(text)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _load_for_inspection(path: Path, depth: int):
|
|
117
|
+
"""Load + validate a scenario for show/graph, mapping failures to Exit(1)."""
|
|
118
|
+
from pydantic import ValidationError
|
|
119
|
+
from pytest_httpchain_jsonref.exceptions import ReferenceResolverError
|
|
120
|
+
|
|
121
|
+
from pytest_httpchain.dataflow import load_scenario
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
return load_scenario(path, ref_parent_traversal_depth=depth)
|
|
125
|
+
except (ReferenceResolverError, json.JSONDecodeError, OSError) as e:
|
|
126
|
+
typer.echo(f"error: cannot load {path}: {e}", err=True)
|
|
127
|
+
raise typer.Exit(1) from e
|
|
128
|
+
except ValidationError:
|
|
129
|
+
typer.echo(f"error: {path} is not a valid scenario — run `pytest-httpchain validate {path}` for details", err=True)
|
|
130
|
+
raise typer.Exit(1) from None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _render_show_text(path: Path, scenario, flow) -> list[str]:
|
|
134
|
+
producer_of: dict[tuple[int, str], int] = {}
|
|
135
|
+
for edge in flow.edges:
|
|
136
|
+
for name in edge.vars:
|
|
137
|
+
producer_of[(edge.consumer, name)] = edge.producer
|
|
138
|
+
|
|
139
|
+
all_fixtures = sorted({*flow.scenario_fixtures, *(f for s in flow.stages for f in s.fixtures)})
|
|
140
|
+
lines: list[str] = [scenario.description or path.name]
|
|
141
|
+
summary = f"{len(flow.stages)} stage(s)"
|
|
142
|
+
if all_fixtures:
|
|
143
|
+
summary += f" · fixtures: {all_fixtures}"
|
|
144
|
+
if flow.scenario_vars:
|
|
145
|
+
summary += f" · vars: {flow.scenario_vars}"
|
|
146
|
+
lines.append(summary)
|
|
147
|
+
lines.append("")
|
|
148
|
+
|
|
149
|
+
for s in flow.stages:
|
|
150
|
+
name = s.name or f"(stage {s.index + 1})"
|
|
151
|
+
lines.append(f"{s.index + 1} · {name} {s.method} {s.url}")
|
|
152
|
+
if s.saves:
|
|
153
|
+
lines.append(f" saves: {', '.join(s.saves)}")
|
|
154
|
+
if s.consumes:
|
|
155
|
+
parts: list[str] = []
|
|
156
|
+
for name in s.consumes:
|
|
157
|
+
producer = producer_of.get((s.index, name))
|
|
158
|
+
if producer is None:
|
|
159
|
+
parts.append(name)
|
|
160
|
+
else:
|
|
161
|
+
producer_name = flow.stages[producer].name or f"stage {producer + 1}"
|
|
162
|
+
parts.append(f"{name} (from #{producer + 1} {producer_name})")
|
|
163
|
+
lines.append(f" consumes: {', '.join(parts)}")
|
|
164
|
+
if s.marks:
|
|
165
|
+
lines.append(f" marks: {', '.join(s.marks)}")
|
|
166
|
+
return lines
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@app.command()
|
|
170
|
+
def show(
|
|
171
|
+
scenario: Annotated[Path, typer.Argument(help="Scenario JSON file to summarize.")],
|
|
172
|
+
output_format: Annotated[OutputFormat, typer.Option("--format", help="Output format: human-readable text or machine-readable JSON.")] = OutputFormat.text,
|
|
173
|
+
ref_parent_traversal_depth: Annotated[int, typer.Option(help="Maximum $ref parent directory traversal depth.")] = 3,
|
|
174
|
+
) -> None:
|
|
175
|
+
"""Summarize a scenario's stages and variable data-flow."""
|
|
176
|
+
from pytest_httpchain.dataflow import analyze_dataflow
|
|
177
|
+
|
|
178
|
+
sc, test_data = _load_for_inspection(scenario, ref_parent_traversal_depth)
|
|
179
|
+
flow = analyze_dataflow(sc, test_data)
|
|
180
|
+
|
|
181
|
+
if output_format is OutputFormat.json:
|
|
182
|
+
payload = flow.model_dump()
|
|
183
|
+
payload["description"] = sc.description or None
|
|
184
|
+
typer.echo(json.dumps(payload, indent=2, default=str))
|
|
185
|
+
else:
|
|
186
|
+
for line in _render_show_text(scenario, sc, flow):
|
|
187
|
+
typer.echo(line)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _mermaid_label(text: str) -> str:
|
|
191
|
+
return text.replace('"', "'").replace("\n", " ")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _to_mermaid(flow, direction: str = "TD") -> str:
|
|
195
|
+
lines = [f"flowchart {direction}"]
|
|
196
|
+
if not flow.stages:
|
|
197
|
+
lines.append(" %% (no stages)")
|
|
198
|
+
return "\n".join(lines)
|
|
199
|
+
for s in flow.stages:
|
|
200
|
+
label = f"{s.index + 1} · {s.name}" if s.name else f"{s.index + 1}"
|
|
201
|
+
lines.append(f' S{s.index}["{_mermaid_label(label)}"]')
|
|
202
|
+
for edge in flow.edges:
|
|
203
|
+
lines.append(f" S{edge.producer} -->|{', '.join(edge.vars)}| S{edge.consumer}")
|
|
204
|
+
return "\n".join(lines)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@app.command()
|
|
208
|
+
def graph(
|
|
209
|
+
scenario: Annotated[Path, typer.Argument(help="Scenario JSON file to graph.")],
|
|
210
|
+
direction: Annotated[GraphDirection, typer.Option("--direction", help="Flowchart orientation.")] = GraphDirection.TD,
|
|
211
|
+
ref_parent_traversal_depth: Annotated[int, typer.Option(help="Maximum $ref parent directory traversal depth.")] = 3,
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Emit a Mermaid flowchart of the stage data-flow."""
|
|
214
|
+
from pytest_httpchain.dataflow import analyze_dataflow
|
|
215
|
+
|
|
216
|
+
sc, test_data = _load_for_inspection(scenario, ref_parent_traversal_depth)
|
|
217
|
+
flow = analyze_dataflow(sc, test_data)
|
|
218
|
+
typer.echo(_to_mermaid(flow, direction.value))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
if __name__ == "__main__":
|
|
222
|
+
app()
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Structured stage data-flow analysis for the ``show`` and ``graph`` CLI commands.
|
|
2
|
+
|
|
3
|
+
Reuses the same extraction helpers as the order-aware validator
|
|
4
|
+
(``validation._dataflow_diagnostics``) but produces a graph model instead of
|
|
5
|
+
diagnostics: which variables each stage saves, which it consumes from earlier
|
|
6
|
+
stages, and the producer -> consumer edges between stages.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import pytest_httpchain_jsonref.loader
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
from pytest_httpchain_models import Scenario
|
|
15
|
+
|
|
16
|
+
from pytest_httpchain.validation import (
|
|
17
|
+
extract_template_variables,
|
|
18
|
+
raw_stages,
|
|
19
|
+
resolve_root_path,
|
|
20
|
+
saved_in_stage,
|
|
21
|
+
stage_defined_names,
|
|
22
|
+
substitution_names,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DataFlowEdge(BaseModel):
|
|
27
|
+
"""A data dependency: ``vars`` saved by stage ``producer`` are referenced by stage ``consumer``."""
|
|
28
|
+
|
|
29
|
+
producer: int
|
|
30
|
+
consumer: int
|
|
31
|
+
vars: list[str]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class StageFlow(BaseModel):
|
|
35
|
+
"""Per-stage data-flow summary."""
|
|
36
|
+
|
|
37
|
+
index: int
|
|
38
|
+
name: str
|
|
39
|
+
method: str
|
|
40
|
+
url: str
|
|
41
|
+
fixtures: list[str]
|
|
42
|
+
marks: list[str]
|
|
43
|
+
saves: list[str]
|
|
44
|
+
consumes: list[str]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DataFlow(BaseModel):
|
|
48
|
+
"""Whole-scenario data-flow graph."""
|
|
49
|
+
|
|
50
|
+
stages: list[StageFlow]
|
|
51
|
+
edges: list[DataFlowEdge]
|
|
52
|
+
scenario_fixtures: list[str] = []
|
|
53
|
+
scenario_vars: list[str] = []
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def analyze_dataflow(scenario: Scenario, test_data: dict[str, Any]) -> DataFlow:
|
|
57
|
+
"""Build the stage data-flow graph for a validated scenario.
|
|
58
|
+
|
|
59
|
+
A stage *consumes* a variable when its request/response/substitutions/parallel
|
|
60
|
+
templates reference a name saved by an earlier stage and not redefined locally
|
|
61
|
+
(own substitutions, parametrize/foreach parameters, or fixtures). ``parametrize``
|
|
62
|
+
values are excluded — they resolve against scenario scope, never saved values.
|
|
63
|
+
"""
|
|
64
|
+
raws = raw_stages(test_data)
|
|
65
|
+
saves_by_stage = [saved_in_stage(stage) for stage in scenario.stages]
|
|
66
|
+
|
|
67
|
+
first_save_stage: dict[str, int] = {}
|
|
68
|
+
for i, saved in enumerate(saves_by_stage):
|
|
69
|
+
for name in saved:
|
|
70
|
+
first_save_stage.setdefault(name, i)
|
|
71
|
+
|
|
72
|
+
stages: list[StageFlow] = []
|
|
73
|
+
edges: list[DataFlowEdge] = []
|
|
74
|
+
cumulative_saves: set[str] = set()
|
|
75
|
+
|
|
76
|
+
for i, stage in enumerate(scenario.stages):
|
|
77
|
+
raw = raws[i] if i < len(raws) and isinstance(raws[i], dict) else {}
|
|
78
|
+
|
|
79
|
+
refs: set[str] = set()
|
|
80
|
+
for key in ("request", "response", "substitutions", "parallel"):
|
|
81
|
+
extract_template_variables(raw.get(key), refs)
|
|
82
|
+
|
|
83
|
+
local = stage_defined_names(stage)
|
|
84
|
+
consumes = {name for name in refs if name in cumulative_saves and name not in local}
|
|
85
|
+
|
|
86
|
+
by_producer: dict[int, list[str]] = {}
|
|
87
|
+
for name in consumes:
|
|
88
|
+
by_producer.setdefault(first_save_stage[name], []).append(name)
|
|
89
|
+
for producer in sorted(by_producer):
|
|
90
|
+
edges.append(DataFlowEdge(producer=producer, consumer=i, vars=sorted(by_producer[producer])))
|
|
91
|
+
|
|
92
|
+
stages.append(
|
|
93
|
+
StageFlow(
|
|
94
|
+
index=i,
|
|
95
|
+
name=stage.name,
|
|
96
|
+
method=str(stage.request.method),
|
|
97
|
+
url=str(stage.request.url),
|
|
98
|
+
fixtures=sorted(stage.fixtures),
|
|
99
|
+
marks=list(stage.marks),
|
|
100
|
+
saves=sorted(saves_by_stage[i]),
|
|
101
|
+
consumes=sorted(consumes),
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
cumulative_saves |= saves_by_stage[i]
|
|
106
|
+
|
|
107
|
+
scenario_fixtures = sorted(f for f in test_data["fixtures"] if isinstance(f, str)) if isinstance(test_data.get("fixtures"), list) else []
|
|
108
|
+
scenario_var_names = set(substitution_names(scenario.substitutions))
|
|
109
|
+
if isinstance(test_data.get("vars"), dict):
|
|
110
|
+
scenario_var_names |= {k for k in test_data["vars"] if isinstance(k, str)}
|
|
111
|
+
|
|
112
|
+
return DataFlow(stages=stages, edges=edges, scenario_fixtures=scenario_fixtures, scenario_vars=sorted(scenario_var_names))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def load_scenario(path: Path, ref_parent_traversal_depth: int = 3) -> tuple[Scenario, dict[str, Any]]:
|
|
116
|
+
"""Load + ``$ref``-resolve + validate a scenario file.
|
|
117
|
+
|
|
118
|
+
Returns ``(scenario, raw_test_data)``. Raises ``ReferenceResolverError``,
|
|
119
|
+
``json.JSONDecodeError`` or ``pydantic.ValidationError`` on failure — callers
|
|
120
|
+
map these to user-facing errors.
|
|
121
|
+
"""
|
|
122
|
+
test_data = pytest_httpchain_jsonref.loader.load_json(
|
|
123
|
+
path,
|
|
124
|
+
max_parent_traversal_depth=ref_parent_traversal_depth,
|
|
125
|
+
root_path=resolve_root_path(path),
|
|
126
|
+
)
|
|
127
|
+
return Scenario.model_validate(test_data), test_data
|
|
@@ -66,12 +66,15 @@ class JsonModule(python.Module):
|
|
|
66
66
|
raise nodes.Collector.CollectError(full_error_msg) from None
|
|
67
67
|
|
|
68
68
|
# semantic validation: cross-cutting checks the schema cannot express
|
|
69
|
-
# (duplicate stage names, fixture/variable conflicts, undefined
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
69
|
+
# (duplicate stage names, fixture/variable conflicts, undefined/forward-referenced
|
|
70
|
+
# variables, no-op verify, contradictory body checks, ...)
|
|
71
|
+
diagnostics, _ = check_scenario(scenario, test_data)
|
|
72
|
+
for diagnostic in diagnostics:
|
|
73
|
+
if diagnostic.severity == "warning":
|
|
74
|
+
warnings.warn(ScenarioValidationWarning(f"{self.path}: [{diagnostic.code}] {diagnostic.message}"), stacklevel=2)
|
|
75
|
+
error_diagnostics = [d for d in diagnostics if d.severity == "error"]
|
|
76
|
+
if error_diagnostics:
|
|
77
|
+
detail = "\n".join(f" - [{d.code}] {d.message}" for d in error_diagnostics)
|
|
75
78
|
raise nodes.Collector.CollectError(f"Invalid test scenario in {self.path}:\n{detail}")
|
|
76
79
|
|
|
77
80
|
# generate python test class
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Build the JSON Schema for pytest-httpchain scenario files.
|
|
2
|
+
|
|
3
|
+
Shared by the ``pytest-httpchain schema`` CLI command and
|
|
4
|
+
``scripts/generate_schema.py``. The schema is derived from the Pydantic
|
|
5
|
+
``Scenario`` model and augmented so editors accept pytest-httpchain's
|
|
6
|
+
``$ref``/``$include``/``$merge`` reference directives at any object level.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pytest_httpchain_models import Scenario
|
|
12
|
+
|
|
13
|
+
SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema"
|
|
14
|
+
SCHEMA_ID = "https://aeresov.github.io/pytest-httpchain/schema/scenario.schema.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _add_jsonref_support(schema: dict[str, Any]) -> dict[str, Any]:
|
|
18
|
+
"""Allow ``$include``/``$merge``/``$ref`` objects as alternatives anywhere.
|
|
19
|
+
|
|
20
|
+
pytest-httpchain-jsonref can substitute any element at runtime, so each
|
|
21
|
+
definition and root property is wrapped in an ``anyOf`` that also accepts a
|
|
22
|
+
reference object — otherwise editors flag missing required properties when a
|
|
23
|
+
reference is used.
|
|
24
|
+
"""
|
|
25
|
+
if "$defs" not in schema:
|
|
26
|
+
schema["$defs"] = {}
|
|
27
|
+
|
|
28
|
+
schema["$defs"]["JsonRef"] = {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"description": "Reference to external JSON file or JSON pointer. Use $include or $merge (preferred) or $ref. Resolved at runtime by pytest-httpchain-jsonref.",
|
|
31
|
+
"properties": {
|
|
32
|
+
"$include": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"description": "Path to external JSON file, JSON pointer (#/path), or combined (file.json#/path). Preferred over $ref to avoid VS Code conflicts.",
|
|
35
|
+
},
|
|
36
|
+
"$merge": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"description": "Alias for $include. Path to external JSON file, JSON pointer (#/path), or combined (file.json#/path).",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
"additionalProperties": True,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for type_name, original_def in list(schema["$defs"].items()):
|
|
45
|
+
if type_name == "JsonRef":
|
|
46
|
+
continue
|
|
47
|
+
schema["$defs"][type_name] = {"anyOf": [{"$ref": "#/$defs/JsonRef"}, original_def]}
|
|
48
|
+
if "title" in original_def:
|
|
49
|
+
schema["$defs"][type_name]["title"] = original_def.pop("title")
|
|
50
|
+
if "description" in original_def:
|
|
51
|
+
schema["$defs"][type_name]["description"] = original_def.pop("description")
|
|
52
|
+
|
|
53
|
+
for prop_name, prop_def in list(schema.get("properties", {}).items()):
|
|
54
|
+
schema["properties"][prop_name] = {
|
|
55
|
+
"anyOf": [{"$ref": "#/$defs/JsonRef"}, prop_def],
|
|
56
|
+
"title": prop_def.get("title", prop_name),
|
|
57
|
+
}
|
|
58
|
+
if "description" in prop_def:
|
|
59
|
+
schema["properties"][prop_name]["description"] = prop_def.get("description")
|
|
60
|
+
|
|
61
|
+
return schema
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_schema() -> dict[str, Any]:
|
|
65
|
+
"""Return the augmented JSON Schema dict for the ``Scenario`` model."""
|
|
66
|
+
schema = Scenario.model_json_schema()
|
|
67
|
+
schema["$schema"] = SCHEMA_DIALECT
|
|
68
|
+
schema["$id"] = SCHEMA_ID
|
|
69
|
+
return _add_jsonref_support(schema)
|