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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-httpchain
3
- Version: 0.3.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 tooling to help AI coding agents (and humans) author and check test scenarios.
201
+ `pytest-httpchain` ships a scenario validator to help AI coding agents (and humans) author and check test scenarios.
202
202
 
203
- ### Claude Code skill
203
+ ### Scenario validation
204
204
 
205
- Install the authoring skill into your project (or `--global` for personal scope):
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 install
208
+ uvx pytest-httpchain validate tests/test_login.http.json
209
209
  ```
210
210
 
211
- This writes `.claude/skills/pytest-httpchain/SKILL.md` with guidance for writing scenarios.
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
- ### Scenario validation
213
+ ```bash
214
+ uvx pytest-httpchain validate --format json tests/test_login.http.json
215
+ ```
214
216
 
215
- Validate scenario files for structure and common problems — undefined variables, duplicate stage names, fixture/variable conflicts, stages with no assertions:
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 tooling to help AI coding agents (and humans) author and check test scenarios.
174
+ `pytest-httpchain` ships a scenario validator to help AI coding agents (and humans) author and check test scenarios.
175
175
 
176
- ### Claude Code skill
176
+ ### Scenario validation
177
177
 
178
- Install the authoring skill into your project (or `--global` for personal scope):
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 install
181
+ uvx pytest-httpchain validate tests/test_login.http.json
182
182
  ```
183
183
 
184
- This writes `.claude/skills/pytest-httpchain/SKILL.md` with guidance for writing scenarios.
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
- ### Scenario validation
186
+ ```bash
187
+ uvx pytest-httpchain validate --format json tests/test_login.http.json
188
+ ```
187
189
 
188
- Validate scenario files for structure and common problems — undefined variables, duplicate stage names, fixture/variable conflicts, stages with no assertions:
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pytest-httpchain"
3
- version = "0.3.0"
3
+ version = "0.5.0"
4
4
  description = "pytest plugin for HTTP testing using JSON files"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13,<4.0"
@@ -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 variables, ...)
70
- semantic_errors, semantic_warnings, _ = check_scenario(scenario, test_data)
71
- for warning in semantic_warnings:
72
- warnings.warn(ScenarioValidationWarning(f"{self.path}: {warning}"), stacklevel=2)
73
- if semantic_errors:
74
- detail = "\n".join(f" - {e}" for e in semantic_errors)
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)