pytest-httpchain 0.7.0__tar.gz → 0.8.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-httpchain
3
- Version: 0.7.0
3
+ Version: 0.8.1
4
4
  Summary: pytest plugin for HTTP testing using JSON files
5
- Keywords: testing,pytest,requests
5
+ Keywords: testing,pytest,httpx,http,api,integration-testing
6
6
  Author: Alexander Eresov
7
7
  Author-email: Alexander Eresov <aeresov@gmail.com>
8
8
  License-Expression: MIT
@@ -197,11 +197,21 @@ pip install 'git+https://github.com/aeresov/pytest-httpchain@main'
197
197
 
198
198
  - Test file discovery is based on this name pattern: `test_<name>.<suffix>.json`.
199
199
  The `suffix` is configurable as pytest ini option, default value is **http**.
200
- - `$ref` instructions can point to other files; absolute and relative paths are supported.
200
+ - `$ref` instructions can point to other files using relative paths; absolute paths are rejected for security.
201
201
  You can limit the depth of relative path traversal using `ref_parent_traversal_depth` ini option, default value is **3**.
202
202
  - Template expressions support list/dict comprehensions. You can limit the maximum comprehension length using `max_comprehension_length` ini option, default value is **50000**.
203
203
  - Parallel stage iterations (repeat/foreach) have a safety limit configurable via `max_parallel_iterations` ini option, default value is **10000**.
204
204
 
205
+ ### HAR export
206
+
207
+ Pass `--output-dir DIR` on the pytest command line to write an [HAR](http://www.softwareishard.com/blog/har-12-spec/) file (and a "HAR File" report section) capturing each test's HTTP traffic:
208
+
209
+ ```bash
210
+ pytest --output-dir ./har-output
211
+ ```
212
+
213
+ HAR files contain full requests/responses **including credential headers and saved tokens** — nothing is redacted, so scrub them before sharing. See the [HAR export docs](https://aeresov.github.io/pytest-httpchain/getting-started/#har-export).
214
+
205
215
  ## AI agent support
206
216
 
207
217
  `pytest-httpchain` ships a scenario validator to help AI coding agents (and humans) author and check test scenarios.
@@ -241,7 +251,7 @@ A JSON Schema is published for as-you-type validation and autocomplete. Referenc
241
251
  The hosted schema tracks the latest release; to pin the schema matching your installed version (e.g. for CI), emit it locally:
242
252
 
243
253
  ```bash
244
- uvx pytest-httpchain schema --output scenario.schema.json
254
+ uvx pytest-httpchain schema > scenario.schema.json
245
255
  ```
246
256
 
247
257
  ### Inspecting scenarios
@@ -164,11 +164,21 @@ pip install 'git+https://github.com/aeresov/pytest-httpchain@main'
164
164
 
165
165
  - Test file discovery is based on this name pattern: `test_<name>.<suffix>.json`.
166
166
  The `suffix` is configurable as pytest ini option, default value is **http**.
167
- - `$ref` instructions can point to other files; absolute and relative paths are supported.
167
+ - `$ref` instructions can point to other files using relative paths; absolute paths are rejected for security.
168
168
  You can limit the depth of relative path traversal using `ref_parent_traversal_depth` ini option, default value is **3**.
169
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
170
  - Parallel stage iterations (repeat/foreach) have a safety limit configurable via `max_parallel_iterations` ini option, default value is **10000**.
171
171
 
172
+ ### HAR export
173
+
174
+ Pass `--output-dir DIR` on the pytest command line to write an [HAR](http://www.softwareishard.com/blog/har-12-spec/) file (and a "HAR File" report section) capturing each test's HTTP traffic:
175
+
176
+ ```bash
177
+ pytest --output-dir ./har-output
178
+ ```
179
+
180
+ HAR files contain full requests/responses **including credential headers and saved tokens** — nothing is redacted, so scrub them before sharing. See the [HAR export docs](https://aeresov.github.io/pytest-httpchain/getting-started/#har-export).
181
+
172
182
  ## AI agent support
173
183
 
174
184
  `pytest-httpchain` ships a scenario validator to help AI coding agents (and humans) author and check test scenarios.
@@ -208,7 +218,7 @@ A JSON Schema is published for as-you-type validation and autocomplete. Referenc
208
218
  The hosted schema tracks the latest release; to pin the schema matching your installed version (e.g. for CI), emit it locally:
209
219
 
210
220
  ```bash
211
- uvx pytest-httpchain schema --output scenario.schema.json
221
+ uvx pytest-httpchain schema > scenario.schema.json
212
222
  ```
213
223
 
214
224
  ### Inspecting scenarios
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pytest-httpchain"
3
- version = "0.7.0"
3
+ version = "0.8.1"
4
4
  description = "pytest plugin for HTTP testing using JSON files"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -21,7 +21,7 @@ dependencies = [
21
21
  "simpleeval>=1.0.3",
22
22
  "typer>=0.16.0",
23
23
  ]
24
- keywords = ["testing", "pytest", "requests"]
24
+ keywords = ["testing", "pytest", "httpx", "http", "api", "integration-testing"]
25
25
  license = "MIT"
26
26
  license-files = ["LICENSE"]
27
27
  classifiers = [
@@ -94,6 +94,26 @@ ignore = []
94
94
  [tool.ruff.lint.isort]
95
95
  known-first-party = ["tests", "utils"]
96
96
 
97
+ [tool.ty.environment]
98
+ python-version = "3.13"
99
+
100
+ [tool.ty.terminal]
101
+ error-on-warning = true
102
+
103
+ # Test suites (especially the models package) deliberately construct models with
104
+ # invalid types to assert ValidationError, and access attributes on unions/mocks
105
+ # the checker can't narrow. Relax those specific rules for test code only; src/
106
+ # stays strict and uses pinpoint `# ty: ignore[...]` comments instead.
107
+ [[tool.ty.overrides]]
108
+ include = ["tests/**", "packages/*/tests/**"]
109
+
110
+ [tool.ty.overrides.rules]
111
+ invalid-argument-type = "ignore"
112
+ unknown-argument = "ignore"
113
+ unresolved-attribute = "ignore"
114
+ not-subscriptable = "ignore"
115
+ missing-argument = "ignore"
116
+
97
117
  [tool.pytest]
98
118
  minversion = "9.0"
99
119
  log_cli = true
@@ -1,7 +1,7 @@
1
1
  """Test execution engine for pytest-httpchain scenarios.
2
2
 
3
- ``create_test_class`` turns a validated :class:`Scenario` into a dynamic pytest
4
- test class (a subclass of :class:`Carrier`), one ``test_NN - <stage name>``
3
+ ``create_test_class`` turns a validated `Scenario` into a dynamic pytest
4
+ test class (a subclass of `Carrier`), one ``test_NN - <stage name>``
5
5
  method per stage, ordered by the ``order(i)`` marker so the stages run as a
6
6
  chain. Each scenario gets its own subclass; the per-scenario mutable state
7
7
  (``client``, ``global_context``, ``aborted``, ``last_request``/``last_response``,
@@ -10,7 +10,7 @@ subclass dict, so the stage methods — which are classmethods operating on
10
10
  ``cls`` — share one running context across the chain while different scenarios
11
11
  stay isolated from each other.
12
12
 
13
- Per stage, :meth:`Carrier.execute_stage` gates on the abort/``always_run`` flow,
13
+ Per stage, `Carrier.execute_stage` gates on the abort/``always_run`` flow,
14
14
  layers fixtures and substitutions over the global context, expands any
15
15
  ``parallel`` config into iterations (sequential or thread-pooled, optionally
16
16
  rate limited), executes the HTTP request via httpx, runs the response
@@ -98,6 +98,13 @@ class Carrier:
98
98
  It manages the shared state, context, and execution flow for all stages in a test scenario.
99
99
  """
100
100
 
101
+ # These ClassVars are placeholders: create_test_class() overrides every one of
102
+ # them in each per-scenario subclass dict (see the bottom of this module), so
103
+ # the values here are never the ones used at runtime — do NOT rely on the
104
+ # ChainMap()/None/[] defaults, and do NOT move this state to instance
105
+ # attributes: the stage methods are classmethods that share one running context
106
+ # across the chain via `cls`, while distinct scenarios stay isolated because
107
+ # each gets its own subclass.
101
108
  client: ClassVar[httpx.Client | None] = None
102
109
  aborted: ClassVar[bool] = False
103
110
  last_request: ClassVar[httpx.Request | None] = None
@@ -155,9 +162,10 @@ class Carrier:
155
162
 
156
163
  total = len(iteration_substitutions)
157
164
  if total == 0:
158
- # Unreachable via validated models (foreach values have min_length=1,
159
- # repeat is PositiveInt), but guard so a future gap fails loudly
160
- # instead of passing a stage that never sent a request.
165
+ # The models reject the static empty cases (foreach/combinations
166
+ # have min_length=1, repeat is PositiveInt), but a template- or
167
+ # $ref-sourced parallel config can still resolve to empty at
168
+ # runtime, so guard rather than silently send zero requests.
161
169
  raise StageExecutionError("Parallel configuration produced zero iterations; foreach/repeat must yield at least one item")
162
170
  if total > cls.max_parallel_iterations:
163
171
  raise StageExecutionError(
@@ -221,8 +229,11 @@ class Carrier:
221
229
  def _build_iteration_substitutions(parallel_config: Any) -> list[dict[str, Any]]:
222
230
  """Expand the (already template-resolved) parallel config into per-iteration
223
231
  substitution dicts: non-parallel -> a single empty dict; ``repeat`` -> N
224
- empties; ``foreach`` -> the cross-product of its parameter steps (the same
225
- algorithm pytest uses for the ``parametrize`` marker)."""
232
+ empties; ``foreach`` -> the cross-product of its parameter steps.
233
+
234
+ Like pytest's stacked ``parametrize`` marker this is a cross-product, but the
235
+ resulting iteration ORDER is the *reverse* of pytest's — do not assume the
236
+ two orders match."""
226
237
  iteration_substitutions: list[dict[str, Any]] = [{}]
227
238
  match parallel_config:
228
239
  case None:
@@ -231,13 +242,17 @@ class Carrier:
231
242
  iteration_substitutions = [{} for _ in range(repeat_count)]
232
243
  case ParallelForeachConfig(foreach=foreach_steps):
233
244
  for step in foreach_steps:
245
+ # Comprehension clause order is load-bearing: the new values are the
246
+ # OUTER loop and the accumulated dicts the INNER loop, which is what
247
+ # produces the (reverse-of-pytest) ordering noted above. Swapping the
248
+ # two `for` clauses silently changes the iteration order.
234
249
  match step:
235
250
  case IndividualParameter(individual=individual):
236
251
  param_name = next(iter(individual.keys()))
237
252
  values = individual[param_name]
238
253
  iteration_substitutions = [{**existing, param_name: val} for val in values for existing in iteration_substitutions]
239
254
  case CombinationsParameter(combinations=combinations):
240
- combos: list[dict[str, Any] | SimpleNamespace] = [vars(item) if isinstance(item, SimpleNamespace) else item for item in combinations]
255
+ combos: list[dict[str, Any]] = [vars(item) if isinstance(item, SimpleNamespace) else item for item in combinations]
241
256
  iteration_substitutions = [{**existing, **combo} for combo in combos for existing in iteration_substitutions]
242
257
  return iteration_substitutions
243
258
 
@@ -352,7 +367,7 @@ class Carrier:
352
367
  @classmethod
353
368
  def _execute_http_request(cls, request_kwargs: dict[str, Any]) -> httpx.Response:
354
369
  try:
355
- return cls.client.request(**request_kwargs)
370
+ return cls.client.request(**request_kwargs) # ty: ignore[unresolved-attribute]
356
371
  except httpx.TimeoutException as e:
357
372
  raise RequestError(f"HTTP request timed out: {e}") from e
358
373
  except httpx.ConnectError as e:
@@ -395,7 +410,7 @@ class Carrier:
395
410
  if not isinstance(func_result, dict):
396
411
  raise SaveError(f"Save function must return dict, got {type(func_result).__name__}")
397
412
 
398
- step_saved.update(func_result)
413
+ step_saved.update(func_result) # ty: ignore[no-matching-overload]
399
414
  except SaveError:
400
415
  raise
401
416
  except UserFunctionError as e:
@@ -517,6 +532,15 @@ class Carrier:
517
532
 
518
533
  @classmethod
519
534
  def _wrap_factory_fixture(cls, fixture: Callable) -> Callable:
535
+ """Wrap a callable fixture so context-manager results are entered and tracked.
536
+
537
+ Not a pure pass-through: each call invokes ``fixture`` and, if the result is
538
+ a context manager, calls ``__enter__()`` and registers it on
539
+ ``cls.active_context_managers`` for LIFO teardown in ``teardown_class``.
540
+ Consequence: calling the wrapped value twice opens two resources (two
541
+ ``__enter__`` calls, two teardowns) — invoke it once per needed instance.
542
+ """
543
+
520
544
  def wrapped(*args, **kwargs):
521
545
  result = fixture(*args, **kwargs)
522
546
 
@@ -543,6 +567,19 @@ class Carrier:
543
567
  cls.client = None
544
568
 
545
569
 
570
+ def _normalize_cert(cert: Any) -> str | tuple[str, ...]:
571
+ """Stringify SSL client-cert paths for httpx.
572
+
573
+ The model stores ``cert`` as ``pathlib.Path`` (single) or a tuple of Paths.
574
+ httpx builds the SSL context via ``load_cert_chain(*cert)`` for a non-tuple
575
+ cert, so a bare ``Path`` is unpacked and raises ``TypeError``. Passing string
576
+ paths avoids that for both the single-path and (cert, key) tuple forms.
577
+ """
578
+ if isinstance(cert, list | tuple):
579
+ return tuple(str(p) for p in cert)
580
+ return str(cert)
581
+
582
+
546
583
  def create_test_class(scenario: Scenario, class_name: str, max_parallel_iterations: int = 10_000) -> type[Carrier]:
547
584
  """Create a dynamic test class from a scenario definition."""
548
585
  scenario_context = process_substitutions(scenario.substitutions)
@@ -553,7 +590,7 @@ def create_test_class(scenario: Scenario, class_name: str, max_parallel_iteratio
553
590
  "http2": True,
554
591
  }
555
592
  if scenario.ssl.cert is not None:
556
- client_kwargs["cert"] = resolved_ssl.cert
593
+ client_kwargs["cert"] = _normalize_cert(resolved_ssl.cert)
557
594
  if scenario.auth:
558
595
  resolved_auth = walk(scenario.auth, scenario_context)
559
596
  auth_result = call_user_function(resolved_auth)
@@ -580,7 +617,9 @@ def create_test_class(scenario: Scenario, class_name: str, max_parallel_iteratio
580
617
  padding_width = len(str(total_stages - 1)) if total_stages > 0 else 1
581
618
 
582
619
  for i, stage in enumerate(scenario.stages):
583
-
620
+ # Factory captures `stage` by value (as stage_template) per iteration. Do NOT
621
+ # inline this into a closure over the loop variable `stage`: Python closes over
622
+ # the variable, not its value, so every stage method would run the LAST stage.
584
623
  def make_stage_method(stage_template: Stage) -> Callable:
585
624
  def call_execute_stage(self, **kwargs):
586
625
  type(self).execute_stage(stage_template, kwargs)
@@ -622,7 +661,7 @@ def create_test_class(scenario: Scenario, class_name: str, max_parallel_iteratio
622
661
  stage_method = parametrize_marker(stage_method)
623
662
 
624
663
  all_fixtures = ["self"] + list(dict.fromkeys(all_param_names + stage.fixtures + scenario.fixtures))
625
- stage_method.__signature__ = inspect.Signature([inspect.Parameter(name, inspect.Parameter.POSITIONAL_OR_KEYWORD) for name in all_fixtures]) # type: ignore[assignment]
664
+ stage_method.__signature__ = inspect.Signature([inspect.Parameter(name, inspect.Parameter.POSITIONAL_OR_KEYWORD) for name in all_fixtures]) # ty: ignore[unresolved-attribute]
626
665
 
627
666
  all_marks = [f"order({i})"] + stage.marks
628
667
  for mark_str in all_marks:
@@ -1,14 +1,16 @@
1
1
  import enum
2
2
  import json
3
3
  from pathlib import Path
4
- from typing import TYPE_CHECKING, Annotated
4
+ from typing import Annotated
5
5
 
6
6
  import typer
7
+ from pydantic import ValidationError
8
+ from pytest_httpchain_jsonref import ReferenceResolverError, load_json
9
+ from pytest_httpchain_models import Scenario
7
10
 
8
- if TYPE_CHECKING:
9
- from pytest_httpchain_models import Scenario
10
-
11
- from pytest_httpchain.dataflow import DataFlow
11
+ from pytest_httpchain.dataflow import DataFlow, analyze_dataflow
12
+ from pytest_httpchain.schema import build_schema
13
+ from pytest_httpchain.validation import ValidateResult, load_scenario, resolve_root_path, validate_scenario
12
14
 
13
15
  app = typer.Typer()
14
16
 
@@ -28,23 +30,6 @@ class GraphDirection(enum.StrEnum):
28
30
  RefParentTraversalDepth = Annotated[int, typer.Option(help="Maximum $ref parent directory traversal depth.")]
29
31
 
30
32
 
31
- def _emit(text: str, output: Path | None, label: str) -> None:
32
- """Write text to a file (with a confirmation) or echo it to stdout.
33
-
34
- A failed write reports a clean ``error: ...`` and exits non-zero, matching
35
- the load-error handling elsewhere in this module instead of a raw traceback.
36
- """
37
- if output is None:
38
- typer.echo(text)
39
- return
40
- try:
41
- output.write_text(text + "\n")
42
- except OSError as e:
43
- typer.echo(f"error: cannot write {output}: {e}", err=True)
44
- raise typer.Exit(1) from e
45
- typer.echo(f"Wrote {label} to {output}")
46
-
47
-
48
33
  @app.callback()
49
34
  def main() -> None:
50
35
  """pytest-httpchain command-line tools."""
@@ -66,8 +51,6 @@ def validate(
66
51
  per file and exits non-zero if any file is invalid (or, with --strict, has any
67
52
  warnings).
68
53
  """
69
- from pytest_httpchain.validation import ValidateResult, validate_scenario
70
-
71
54
  results = [
72
55
  (path, validate_scenario(path, ref_parent_traversal_depth=ref_parent_traversal_depth, root_path=root_path, deep=deep, syspaths=list(syspath or []))) for path in paths
73
56
  ]
@@ -99,28 +82,21 @@ def validate(
99
82
 
100
83
 
101
84
  @app.command()
102
- def schema(
103
- output: Annotated[Path | None, typer.Option("--output", "-o", help="Write schema to PATH instead of stdout.")] = None,
104
- ) -> None:
105
- """Emit the JSON Schema for scenario files (editor autocomplete/validation)."""
106
- from pytest_httpchain.schema import build_schema
85
+ def schema() -> None:
86
+ """Emit the JSON Schema for scenario files (editor autocomplete/validation).
107
87
 
108
- text = json.dumps(build_schema(), indent=2, default=str)
109
- _emit(text, output, "schema")
88
+ Writes to stdout; redirect to a file (``pytest-httpchain schema > scenario.schema.json``).
89
+ """
90
+ typer.echo(json.dumps(build_schema(), indent=2, default=str))
110
91
 
111
92
 
112
93
  @app.command()
113
94
  def resolve(
114
95
  scenario: Annotated[Path, typer.Argument(help="Scenario JSON file to resolve.")],
115
- output: Annotated[Path | None, typer.Option("--output", "-o", help="Write resolved JSON to PATH instead of stdout.")] = None,
116
96
  ref_parent_traversal_depth: RefParentTraversalDepth = 3,
117
97
  root_path: Annotated[Path | None, typer.Option("--root-path", help="Directory that constrains $ref resolution (default: nearest tests/ ancestor).")] = None,
118
98
  ) -> None:
119
- """Resolve $ref/$include/$merge and print the merged scenario JSON."""
120
- from pytest_httpchain_jsonref import ReferenceResolverError, load_json
121
-
122
- from pytest_httpchain.validation import resolve_root_path
123
-
99
+ """Resolve $ref/$include/$merge and print the merged scenario JSON to stdout."""
124
100
  try:
125
101
  data = load_json(
126
102
  scenario,
@@ -131,17 +107,11 @@ def resolve(
131
107
  typer.echo(f"error: {e}", err=True)
132
108
  raise typer.Exit(1) from e
133
109
 
134
- text = json.dumps(data, indent=2, default=str)
135
- _emit(text, output, "resolved scenario")
110
+ typer.echo(json.dumps(data, indent=2, default=str))
136
111
 
137
112
 
138
- def _load_for_inspection(path: Path, depth: int, root_path: Path | None = None) -> tuple["Scenario", dict]:
113
+ def _load_for_inspection(path: Path, depth: int, root_path: Path | None = None) -> tuple[Scenario, dict]:
139
114
  """Load + validate a scenario for show/graph, mapping failures to Exit(1)."""
140
- from pydantic import ValidationError
141
- from pytest_httpchain_jsonref import ReferenceResolverError
142
-
143
- from pytest_httpchain.validation import load_scenario
144
-
145
115
  try:
146
116
  return load_scenario(path, root_path=root_path, ref_parent_traversal_depth=depth)
147
117
  except (ReferenceResolverError, json.JSONDecodeError, OSError) as e:
@@ -152,7 +122,7 @@ def _load_for_inspection(path: Path, depth: int, root_path: Path | None = None)
152
122
  raise typer.Exit(1) from None
153
123
 
154
124
 
155
- def _render_show_text(path: Path, scenario: "Scenario", flow: "DataFlow") -> list[str]:
125
+ def _render_show_text(path: Path, scenario: Scenario, flow: DataFlow) -> list[str]:
156
126
  producer_of: dict[tuple[int, str], int] = {}
157
127
  for edge in flow.edges:
158
128
  for var_name in edge.vars:
@@ -196,8 +166,6 @@ def show(
196
166
  root_path: Annotated[Path | None, typer.Option("--root-path", help="Directory that constrains $ref resolution (default: nearest tests/ ancestor).")] = None,
197
167
  ) -> None:
198
168
  """Summarize a scenario's stages and variable data-flow."""
199
- from pytest_httpchain.dataflow import analyze_dataflow
200
-
201
169
  sc, test_data = _load_for_inspection(scenario, ref_parent_traversal_depth, root_path)
202
170
  flow = analyze_dataflow(sc, test_data)
203
171
 
@@ -214,7 +182,7 @@ def _mermaid_label(text: str) -> str:
214
182
  return text.replace('"', "'").replace("\n", " ")
215
183
 
216
184
 
217
- def _to_mermaid(flow: "DataFlow", direction: str = "TD") -> str:
185
+ def _to_mermaid(flow: DataFlow, direction: str = "TD") -> str:
218
186
  lines = [f"flowchart {direction}"]
219
187
  if not flow.stages:
220
188
  lines.append(" %% (no stages)")
@@ -235,8 +203,6 @@ def graph(
235
203
  root_path: Annotated[Path | None, typer.Option("--root-path", help="Directory that constrains $ref resolution (default: nearest tests/ ancestor).")] = None,
236
204
  ) -> None:
237
205
  """Emit a Mermaid flowchart of the stage data-flow."""
238
- from pytest_httpchain.dataflow import analyze_dataflow
239
-
240
206
  sc, test_data = _load_for_inspection(scenario, ref_parent_traversal_depth, root_path)
241
207
  flow = analyze_dataflow(sc, test_data)
242
208
  typer.echo(_to_mermaid(flow, direction.value))
@@ -12,8 +12,8 @@ from pydantic import BaseModel
12
12
  from pytest_httpchain_models import Scenario
13
13
 
14
14
  from pytest_httpchain.validation import (
15
- _parameter_names,
16
15
  extract_template_variables,
16
+ parameter_names,
17
17
  raw_stages,
18
18
  saved_in_stage,
19
19
  stage_defined_names,
@@ -90,7 +90,7 @@ def analyze_dataflow(scenario: Scenario, test_data: dict[str, Any]) -> DataFlow:
90
90
  # always_run resolves before stage substitutions exist, so only fixtures
91
91
  # and parametrize parameters shadow an earlier save there.
92
92
  always_run_refs = extract_template_variables(raw.get("always_run"))
93
- always_run_local = set(stage.fixtures) | _parameter_names(stage.parametrize) | scenario_fixture_names
93
+ always_run_local = set(stage.fixtures) | parameter_names(stage.parametrize) | scenario_fixture_names
94
94
  consumes |= {name for name in always_run_refs if name in cumulative_saves and name not in always_run_local}
95
95
 
96
96
  by_producer: dict[int, list[str]] = {}
@@ -4,8 +4,10 @@ This module converts httpx Request/Response objects to HAR 1.2 format
4
4
  and writes them to files for external analysis.
5
5
  """
6
6
 
7
+ import base64
7
8
  import json
8
9
  from datetime import UTC, datetime
10
+ from importlib.metadata import version
9
11
  from pathlib import Path
10
12
  from typing import Any
11
13
  from urllib.parse import parse_qs, urlparse
@@ -16,14 +18,12 @@ import httpx
16
18
  def _get_version() -> str:
17
19
  """Get pytest-httpchain version for HAR creator info."""
18
20
  try:
19
- from importlib.metadata import version
20
-
21
21
  return version("pytest-httpchain")
22
22
  except Exception:
23
23
  return "unknown"
24
24
 
25
25
 
26
- def _format_cookies(cookies: httpx.Cookies) -> list[dict[str, Any]]:
26
+ def _format_cookies(cookies: httpx.Cookies) -> list[dict[str, str]]:
27
27
  """Convert httpx Cookies to HAR cookie format."""
28
28
  result = []
29
29
  for name, value in cookies.items():
@@ -71,8 +71,6 @@ def _format_post_data(request: httpx.Request) -> dict[str, Any] | None:
71
71
  try:
72
72
  text = request.content.decode("utf-8")
73
73
  except UnicodeDecodeError:
74
- import base64
75
-
76
74
  text = base64.b64encode(request.content).decode("ascii")
77
75
  return {
78
76
  "mimeType": mime_type,
@@ -106,8 +104,6 @@ def _format_response_content(response: httpx.Response) -> dict[str, Any]:
106
104
  try:
107
105
  content["text"] = response.content.decode("utf-8")
108
106
  except UnicodeDecodeError:
109
- import base64
110
-
111
107
  content["text"] = base64.b64encode(response.content).decode("ascii")
112
108
  content["encoding"] = "base64"
113
109
 
@@ -118,7 +114,7 @@ def _calculate_headers_size(headers: httpx.Headers) -> int:
118
114
  """Calculate approximate size of headers in bytes."""
119
115
  size = 0
120
116
  for name, value in headers.items():
121
- size += len(name) + len(value) + 4
117
+ size += len(name) + len(value) + 4 # ": " (2) + CRLF (2) per header line
122
118
  return size
123
119
 
124
120
 
@@ -194,7 +190,7 @@ def request_response_to_har_entry(
194
190
 
195
191
  post_data = _format_post_data(request)
196
192
  if post_data:
197
- entry["request"]["postData"] = post_data
193
+ entry["request"]["postData"] = post_data # ty: ignore[invalid-assignment]
198
194
 
199
195
  return entry
200
196
 
@@ -7,16 +7,16 @@ scenarios into pytest:
7
7
  options (``suffix``, ``ref_parent_traversal_depth``, ``max_comprehension_length``,
8
8
  ``max_parallel_iterations``) and the ``--output-dir`` flag.
9
9
  - ``pytest_collect_file`` matches ``test_<name>.<suffix>.json`` files and hands
10
- them to :class:`JsonModule`.
11
- - :class:`JsonModule.collect` loads the JSON (resolving ``$ref``), validates it
12
- against the :class:`Scenario` model, runs the semantic validator
13
- (warnings become :class:`ScenarioValidationWarning`, errors become
10
+ them to `JsonModule`.
11
+ - `JsonModule.collect` loads the JSON (resolving ``$ref``), validates it
12
+ against the `Scenario` model, runs the semantic validator
13
+ (warnings become `ScenarioValidationWarning`, errors become
14
14
  ``CollectError``), and builds the dynamic test class via
15
15
  ``carrier.create_test_class``.
16
16
  - ``pytest_runtest_makereport`` attaches the last HTTP request/response to the
17
17
  test report and optionally writes a HAR file.
18
18
 
19
- :class:`ScenarioValidationWarning` is defined here because the collection hook
19
+ `ScenarioValidationWarning` is defined here because the collection hook
20
20
  and the ``pytest11`` entry point reference it directly; it is re-exported from
21
21
  the package ``__init__`` as the user-facing name.
22
22
  """
@@ -165,26 +165,33 @@ def pytest_addoption(parser: pytest.Parser) -> None:
165
165
 
166
166
 
167
167
  def pytest_configure(config: pytest.Config) -> None:
168
- # Numeric options are registered with type="int", so getini returns an int and a
169
- # non-int value in the ini is reported by pytest as a clean usage error. Range
170
- # checks below raise pytest.UsageError (not bare ValueError, which pytest renders
171
- # as an INTERNALERROR with traceback).
168
+ # Numeric options are registered with type="int", but pytest performs the
169
+ # int() conversion with a bare int(value) that raises ValueError for a
170
+ # non-integer ini value — which pytest renders as an INTERNALERROR traceback.
171
+ # Wrap the read so a garbage value becomes a clean usage error; the range
172
+ # checks below likewise raise pytest.UsageError.
173
+ def _getint(name: str) -> int:
174
+ try:
175
+ return config.getini(name)
176
+ except ValueError as e:
177
+ raise pytest.UsageError(f"{name} must be an integer: {e}") from None
178
+
172
179
  suffix = str(config.getini(ConfigOptions.SUFFIX))
173
180
  if not re.match(r"^[a-zA-Z0-9_-]{1,32}$", suffix):
174
181
  raise pytest.UsageError("suffix must contain only alphanumeric characters, underscores, hyphens, and be ≤32 chars")
175
182
 
176
- ref_parent_traversal_depth = config.getini(ConfigOptions.REF_PARENT_TRAVERSAL_DEPTH)
183
+ ref_parent_traversal_depth = _getint(ConfigOptions.REF_PARENT_TRAVERSAL_DEPTH)
177
184
  if ref_parent_traversal_depth < 0:
178
185
  raise pytest.UsageError("ref_parent_traversal_depth must be non-negative")
179
186
 
180
- max_comprehension_length = config.getini(ConfigOptions.MAX_COMPREHENSION_LENGTH)
187
+ max_comprehension_length = _getint(ConfigOptions.MAX_COMPREHENSION_LENGTH)
181
188
  if max_comprehension_length < 1:
182
189
  raise pytest.UsageError("max_comprehension_length must be a positive integer")
183
190
  if max_comprehension_length > 1_000_000:
184
191
  raise pytest.UsageError("max_comprehension_length must not exceed 1,000,000")
185
192
  simpleeval.MAX_COMPREHENSION_LENGTH = max_comprehension_length # ty: ignore[invalid-assignment]
186
193
 
187
- max_parallel_iterations = config.getini(ConfigOptions.MAX_PARALLEL_ITERATIONS)
194
+ max_parallel_iterations = _getint(ConfigOptions.MAX_PARALLEL_ITERATIONS)
188
195
  if max_parallel_iterations < 1:
189
196
  raise pytest.UsageError("max_parallel_iterations must be a positive integer")
190
197
  if max_parallel_iterations > 1_000_000:
@@ -86,7 +86,7 @@ def process_substitutions(
86
86
  return result
87
87
 
88
88
 
89
- def call_user_function(func_call: UserFunctionCall, **extra_kwargs) -> object:
89
+ def call_user_function(func_call: UserFunctionCall, **extra_kwargs: Any) -> object:
90
90
  """Import and call a user function described by a ``UserFunctionCall`` model.
91
91
 
92
92
  A bare ``UserFunctionName`` is called with only ``extra_kwargs``; a
@@ -6,7 +6,7 @@ pytest plugin, and available for editor/CI integrations. It performs structural
6
6
  validation via the Pydantic ``Scenario`` model plus cross-cutting *semantic*
7
7
  checks that a JSON Schema cannot express.
8
8
 
9
- Every finding is reported as a :class:`Diagnostic` carrying a stable code
9
+ Every finding is reported as a `Diagnostic` carrying a stable code
10
10
  (``HTTPCHAINxxx``), a severity, a human-readable message and (where meaningful)
11
11
  a location, so tooling can filter/sort/route diagnostics deterministically.
12
12
 
@@ -32,6 +32,9 @@ Code Severity Meaning
32
32
  014 error Invalid JSON syntax
33
33
  015 error Failed to parse JSON file
34
34
  016 error Fixture referenced in a scenario-level template
35
+ 017 error Scenario-level template references an undefined name
36
+ 018 warning Verify expression is not a template (``{{ }}``) — asserts nothing
37
+ 019 error Invalid pytest marker expression (scenario or stage ``marks``)
35
38
  020 warning Referenced file does not exist (deep, opt-in)
36
39
  021 warning Schema file is not valid JSON / not a valid schema (deep)
37
40
  022 warning User function cannot be imported (deep)
@@ -45,8 +48,11 @@ runs at collection time. Deep findings are always warnings.
45
48
  """
46
49
 
47
50
  import ast
51
+ import inspect
48
52
  import json
49
53
  import re
54
+ import sys
55
+ import warnings
50
56
  from pathlib import Path
51
57
  from typing import Any, Literal
52
58
 
@@ -55,16 +61,24 @@ from pytest_httpchain_jsonref import ReferenceResolverError, load_json
55
61
  from pytest_httpchain_models import (
56
62
  FunctionsSubstitution,
57
63
  JMESPathSave,
64
+ Parameters,
58
65
  SaveStep,
59
66
  Scenario,
60
67
  Stage,
61
68
  SubstitutionsSave,
69
+ UserFunctionCall,
70
+ UserFunctionKwargs,
71
+ UserFunctionName,
62
72
  UserFunctionsSave,
63
73
  VarsSubstitution,
64
74
  Verify,
65
75
  VerifyStep,
76
+ check_json_schema,
66
77
  )
67
- from pytest_httpchain_templates import TEMPLATE_BUILTINS, TEMPLATE_PATTERN
78
+ from pytest_httpchain_templates import TEMPLATE_BUILTINS, TEMPLATE_PATTERN, is_complete_template
79
+ from pytest_httpchain_userfunc import UserFunctionError, import_function
80
+
81
+ from pytest_httpchain.utils import make_marker
68
82
 
69
83
  Severity = Literal["error", "warning"]
70
84
 
@@ -90,6 +104,8 @@ class DiagnosticCode:
90
104
  PARSE_ERROR = "HTTPCHAIN015"
91
105
  FIXTURE_IN_SCENARIO_TEMPLATE = "HTTPCHAIN016"
92
106
  SCENARIO_UNDEFINED_VAR = "HTTPCHAIN017"
107
+ NONTEMPLATE_EXPRESSION = "HTTPCHAIN018"
108
+ INVALID_MARKER = "HTTPCHAIN019"
93
109
  # Deep (opt-in) checks: imports, signatures, referenced files.
94
110
  REFERENCED_FILE_NOT_FOUND = "HTTPCHAIN020"
95
111
  SCHEMA_FILE_INVALID = "HTTPCHAIN021"
@@ -194,7 +210,7 @@ def extract_saved_variables(scenario: Scenario) -> set[str]:
194
210
  return saved_vars
195
211
 
196
212
 
197
- def _parameter_names(params: Any) -> set[str]:
213
+ def parameter_names(params: Parameters | None) -> set[str]:
198
214
  """Names injected by a list of parametrize/foreach Parameter entries.
199
215
 
200
216
  Covers both ``individual`` (one name -> list of values) and ``combinations``
@@ -218,9 +234,9 @@ def stage_defined_names(stage: Stage) -> set[str]:
218
234
  """Names available *within a single stage*: its substitutions, parametrize /
219
235
  foreach parameters, and its declared fixtures."""
220
236
  names = substitution_names(stage.substitutions)
221
- names |= _parameter_names(stage.parametrize)
237
+ names |= parameter_names(stage.parametrize)
222
238
  if stage.parallel is not None:
223
- names |= _parameter_names(getattr(stage.parallel, "foreach", None))
239
+ names |= parameter_names(getattr(stage.parallel, "foreach", None))
224
240
  names |= set(stage.fixtures)
225
241
  return names
226
242
 
@@ -232,7 +248,7 @@ def extract_defined_variables(scenario: Scenario) -> set[str]:
232
248
  plus parameter names injected by ``parametrize`` and ``parallel.foreach``. This
233
249
  is the *union* across the whole scenario, used for the fixture-conflict check
234
250
  and informational output; the order-aware data-flow check
235
- (:func:`_dataflow_diagnostics`) computes availability per stage instead.
251
+ (`_dataflow_diagnostics`) computes availability per stage instead.
236
252
  """
237
253
  defined_vars: set[str] = set()
238
254
 
@@ -240,9 +256,9 @@ def extract_defined_variables(scenario: Scenario) -> set[str]:
240
256
 
241
257
  for stage in scenario.stages:
242
258
  defined_vars |= substitution_names(stage.substitutions)
243
- defined_vars |= _parameter_names(stage.parametrize)
259
+ defined_vars |= parameter_names(stage.parametrize)
244
260
  if stage.parallel is not None:
245
- defined_vars |= _parameter_names(getattr(stage.parallel, "foreach", None))
261
+ defined_vars |= parameter_names(getattr(stage.parallel, "foreach", None))
246
262
 
247
263
  return defined_vars
248
264
 
@@ -300,9 +316,9 @@ def _dataflow_diagnostics(scenario: Scenario, test_data: dict[str, Any]) -> list
300
316
  processed and before any iteration runs: only fixtures, parametrize
301
317
  parameters, scenario substitutions and earlier saves are in scope.
302
318
 
303
- A reference that is unavailable is reported as :data:`DiagnosticCode.FORWARD_REF`
319
+ A reference that is unavailable is reported as `DiagnosticCode.FORWARD_REF`
304
320
  if the name is saved somewhere later (an ordering bug) or
305
- :data:`DiagnosticCode.UNDEFINED_VAR` otherwise (likely a typo).
321
+ `DiagnosticCode.UNDEFINED_VAR` otherwise (likely a typo).
306
322
  """
307
323
  diagnostics: list[Diagnostic] = []
308
324
 
@@ -322,6 +338,8 @@ def _dataflow_diagnostics(scenario: Scenario, test_data: dict[str, Any]) -> list
322
338
 
323
339
  scenario_available = scenario_scope | set(scenario.fixtures)
324
340
 
341
+ # raws[i] is the unvalidated dict for scenario.stages[i]: both come from the same
342
+ # order-preserving normalization (_normalize_stages_input), so they pair by index.
325
343
  raws = raw_stages(test_data)
326
344
  cumulative_saves: set[str] = set()
327
345
 
@@ -333,8 +351,8 @@ def _dataflow_diagnostics(scenario: Scenario, test_data: dict[str, Any]) -> list
333
351
  # parametrize parameters, scenario substitutions and earlier saves — but not
334
352
  # foreach parameters. The request/response are resolved per iteration, so
335
353
  # they additionally see the foreach parameters.
336
- foreach_params = _parameter_names(getattr(stage.parallel, "foreach", None)) if stage.parallel is not None else set()
337
- pre_iteration_available = scenario_available | set(stage.fixtures) | _parameter_names(stage.parametrize) | substitution_names(stage.substitutions) | cumulative_saves
354
+ foreach_params = parameter_names(getattr(stage.parallel, "foreach", None)) if stage.parallel is not None else set()
355
+ pre_iteration_available = scenario_available | set(stage.fixtures) | parameter_names(stage.parametrize) | substitution_names(stage.substitutions) | cumulative_saves
338
356
  request_available = pre_iteration_available | foreach_params
339
357
  response_available = request_available | saves_by_stage[i]
340
358
 
@@ -359,7 +377,7 @@ def _dataflow_diagnostics(scenario: Scenario, test_data: dict[str, Any]) -> list
359
377
  # ``always_run`` resolves at stage start (carrier.execute_stage) against
360
378
  # fixtures + parametrize parameters + global context — stage substitutions
361
379
  # and foreach parameters don't exist yet, and neither do this stage's saves.
362
- always_run_available = scenario_available | set(stage.fixtures) | _parameter_names(stage.parametrize) | cumulative_saves
380
+ always_run_available = scenario_available | set(stage.fixtures) | parameter_names(stage.parametrize) | cumulative_saves
363
381
  for name in sorted(extract_template_variables(raw.get("always_run"))):
364
382
  if name in always_run_available:
365
383
  continue
@@ -420,8 +438,8 @@ def _verify_diagnostics(scenario: Scenario) -> list[Diagnostic]:
420
438
  """Per-stage verify-step checks: missing validation, no-op verifies, and
421
439
  contradictory body ``contains``/``matches`` declarations.
422
440
 
423
- Extracted from :func:`check_scenario` so each check family has its own
424
- helper, mirroring :func:`_dataflow_diagnostics` and the deep-check helpers.
441
+ Extracted from `check_scenario` so each check family has its own
442
+ helper, mirroring `_dataflow_diagnostics` and the deep-check helpers.
425
443
  Behavior, codes and messages are unchanged.
426
444
  """
427
445
  diagnostics: list[Diagnostic] = []
@@ -453,6 +471,21 @@ def _verify_diagnostics(scenario: Scenario) -> list[Diagnostic]:
453
471
  )
454
472
  )
455
473
 
474
+ # A verify expression is meant to be a complete ``{{ }}`` template that
475
+ # evaluates to a truthy/falsy value. A plain string (e.g. a forgotten
476
+ # ``{{ }}``) is non-empty and therefore always truthy at runtime, so the
477
+ # assertion silently passes — it tests nothing.
478
+ for expr in verify.expressions:
479
+ if isinstance(expr, str) and not is_complete_template(expr):
480
+ diagnostics.append(
481
+ _diag(
482
+ DiagnosticCode.NONTEMPLATE_EXPRESSION,
483
+ "warning",
484
+ f"Stage '{stage.name}': verify expression {expr!r} is not a template ({{{{ }}}}); it is always truthy and asserts nothing",
485
+ location=location,
486
+ )
487
+ )
488
+
456
489
  # Overlap is compared on the raw (unrendered) strings: identical
457
490
  # entries — including identical templates — are caught. A contradiction
458
491
  # that only emerges after rendering (e.g. a template that resolves to a
@@ -483,6 +516,43 @@ def _verify_diagnostics(scenario: Scenario) -> list[Diagnostic]:
483
516
  return diagnostics
484
517
 
485
518
 
519
+ def _marker_diagnostics(scenario: Scenario) -> list[Diagnostic]:
520
+ """Validate scenario- and stage-level pytest marker expressions.
521
+
522
+ Markers are parsed by ``make_marker`` only at collection time, so a malformed
523
+ marker (``skip(``) or an unsupported form (``foo.bar``) would pass ``validate``
524
+ yet crash collection. Run the same parser here so the validator catches it as
525
+ an error, keeping ``validate`` a faithful pre-flight check of what collection
526
+ will accept.
527
+ """
528
+ diagnostics: list[Diagnostic] = []
529
+
530
+ def _check(marks: list[str], location: str) -> None:
531
+ for mark in marks:
532
+ try:
533
+ # We only care whether the marker parses; constructing it would
534
+ # otherwise emit PytestUnknownMarkWarning for custom (unregistered)
535
+ # marks, which is noise during validation.
536
+ with warnings.catch_warnings():
537
+ warnings.simplefilter("ignore")
538
+ make_marker(mark)
539
+ except (ValueError, SyntaxError) as e:
540
+ diagnostics.append(
541
+ _diag(
542
+ DiagnosticCode.INVALID_MARKER,
543
+ "error",
544
+ f"Invalid marker {mark!r}: {e}",
545
+ location=location,
546
+ )
547
+ )
548
+
549
+ _check(scenario.marks, "marks")
550
+ for i, stage in enumerate(scenario.stages):
551
+ _check(stage.marks, f"stages[{i}].marks")
552
+
553
+ return diagnostics
554
+
555
+
486
556
  # --------------------------------------------------------------------------- #
487
557
  # Deep (opt-in) validation: imports, signatures, referenced files.
488
558
  # These touch the filesystem and import user code, so they NEVER run at
@@ -529,8 +599,6 @@ def _check_schema_path(schema: Any, location: str) -> list[Diagnostic]:
529
599
  except (OSError, json.JSONDecodeError) as e:
530
600
  return [_diag(DiagnosticCode.SCHEMA_FILE_INVALID, "warning", f"Schema file is not valid JSON: {path}: {e}", location)]
531
601
  try:
532
- from pytest_httpchain_models import check_json_schema
533
-
534
602
  check_json_schema(data)
535
603
  except Exception as e:
536
604
  return [_diag(DiagnosticCode.SCHEMA_FILE_INVALID, "warning", f"Schema file is not a valid JSON Schema: {path}: {e}", location)]
@@ -559,18 +627,19 @@ def _file_diagnostics(scenario: Scenario) -> list[Diagnostic]:
559
627
  return diagnostics
560
628
 
561
629
 
562
- def _func_name_and_kwargs(call: Any) -> tuple[str | None, dict[str, Any] | None]:
630
+ def _func_name_and_kwargs(call: UserFunctionCall) -> tuple[str | None, dict[str, Any] | None]:
563
631
  """Extract ``(import_name, explicit_kwargs)`` from a UserFunctionCall.
564
632
 
565
633
  ``kwargs`` is None for the bare ``UserFunctionName`` form (no explicit
566
- keyword arguments)."""
567
- root = getattr(call, "root", None)
568
- if root is not None: # UserFunctionName
569
- return str(root), None
570
- name_obj = getattr(call, "name", None) # UserFunctionKwargs
571
- if name_obj is not None:
572
- return str(getattr(name_obj, "root", name_obj)), dict(getattr(call, "kwargs", {}) or {})
573
- return None, None
634
+ keyword arguments). Mirrors the structural dispatch in ``carrier``/``utils`` so
635
+ a new union variant is caught by a class-name search."""
636
+ match call:
637
+ case UserFunctionName():
638
+ return str(call.root), None
639
+ case UserFunctionKwargs():
640
+ return str(call.name.root), dict(call.kwargs or {})
641
+ case _:
642
+ return None, None
574
643
 
575
644
 
576
645
  def _signature_problems(func: Any, provided: set[str]) -> list[tuple[str, str]]:
@@ -580,8 +649,6 @@ def _signature_problems(func: Any, provided: set[str]) -> list[tuple[str, str]]:
580
649
  kwargs plus framework-injected names such as ``response``). All arguments are
581
650
  passed by keyword at the call sites, so positional-only parameters are not
582
651
  considered fillable here. Returns ``(code, message)`` tuples."""
583
- import inspect
584
-
585
652
  try:
586
653
  signature = inspect.signature(func)
587
654
  except (TypeError, ValueError):
@@ -608,8 +675,6 @@ def _signature_problems(func: Any, provided: set[str]) -> list[tuple[str, str]]:
608
675
  def _function_diagnostics(scenario: Scenario) -> list[Diagnostic]:
609
676
  """Resolve every literal user-function reference and (where the call
610
677
  arguments are statically known) check them against the signature."""
611
- from pytest_httpchain_userfunc import UserFunctionError, import_function
612
-
613
678
  diagnostics: list[Diagnostic] = []
614
679
 
615
680
  # (call, injected-arg-names, check_signature, location). Substitution
@@ -669,8 +734,6 @@ def check_scenario_deep(scenario: Scenario, syspaths: list[Path] | None = None)
669
734
  ``validate --deep`` — never at collection time. ``syspaths`` (and the current
670
735
  working directory) are temporarily prepended to ``sys.path`` so user modules
671
736
  resolve the same way they would under pytest."""
672
- import sys
673
-
674
737
  diagnostics = _file_diagnostics(scenario)
675
738
 
676
739
  saved_path = list(sys.path)
@@ -722,7 +785,7 @@ def check_scenario(scenario: Scenario, test_data: dict[str, Any]) -> tuple[list[
722
785
  """Semantic checks on an already-loaded, schema-valid scenario.
723
786
 
724
787
  Operates on the parsed ``test_data`` dict together with the validated ``Scenario``
725
- so it can be shared by :func:`validate_scenario` (file-based) and by the pytest
788
+ so it can be shared by `validate_scenario` (file-based) and by the pytest
726
789
  collector. Returns ``(diagnostics, scenario_info)``.
727
790
  """
728
791
  diagnostics: list[Diagnostic] = []
@@ -763,9 +826,9 @@ def check_scenario(scenario: Scenario, test_data: dict[str, Any]) -> tuple[list[
763
826
  var_conflicts: set[str] = set()
764
827
  for stage in scenario.stages:
765
828
  stage_fixtures = scenario_fixture_set | set(stage.fixtures)
766
- stage_params = _parameter_names(stage.parametrize)
829
+ stage_params = parameter_names(stage.parametrize)
767
830
  if stage.parallel is not None:
768
- stage_params |= _parameter_names(getattr(stage.parallel, "foreach", None))
831
+ stage_params |= parameter_names(getattr(stage.parallel, "foreach", None))
769
832
  stage_vars = scenario_sub_names | substitution_names(stage.substitutions) | stage_params
770
833
  var_conflicts |= stage_fixtures & stage_vars
771
834
  if var_conflicts:
@@ -829,6 +892,8 @@ def check_scenario(scenario: Scenario, test_data: dict[str, Any]) -> tuple[list[
829
892
 
830
893
  diagnostics.extend(_verify_diagnostics(scenario))
831
894
 
895
+ diagnostics.extend(_marker_diagnostics(scenario))
896
+
832
897
  scenario_info = ScenarioInfo(
833
898
  num_stages=len(scenario.stages),
834
899
  stage_names=stage_names,
@@ -857,12 +922,12 @@ def load_scenario(path: Path, *, root_path: Path | None = None, ref_parent_trave
857
922
 
858
923
  The single load+resolve+validate path shared by the CLI inspection commands
859
924
  (``show``/``graph``) and the data-flow loader. ``root_path`` constrains ``$ref``
860
- resolution; when omitted it defaults to :func:`resolve_root_path` (the nearest
925
+ resolution; when omitted it defaults to `resolve_root_path` (the nearest
861
926
  ``tests/`` ancestor).
862
927
 
863
928
  NOTE on the root-path divergence: pytest collection (``plugin.py``) constrains
864
929
  ``$ref`` resolution to ``config.rootpath`` (the repo root), while every CLI path
865
- defaults to :func:`resolve_root_path`. A ``$ref`` reaching above ``tests/`` but
930
+ defaults to `resolve_root_path`. A ``$ref`` reaching above ``tests/`` but
866
931
  inside the repo therefore resolves under collection yet may need an explicit
867
932
  ``--root-path`` to resolve the same way from the CLI. Raises
868
933
  ``ReferenceResolverError`` / ``json.JSONDecodeError`` / ``pydantic.ValidationError``
@@ -884,10 +949,10 @@ def validate_scenario(
884
949
  """Validate a pytest-httpchain test scenario file.
885
950
 
886
951
  Performs file/JSON/$ref/schema validation plus the semantic checks in
887
- :func:`check_scenario`, returning a :class:`ValidateResult` whose
952
+ `check_scenario`, returning a `ValidateResult` whose
888
953
  ``diagnostics`` carry stable codes.
889
954
 
890
- When ``deep`` is true, additionally runs :func:`check_scenario_deep`
955
+ When ``deep`` is true, additionally runs `check_scenario_deep`
891
956
  (referenced-file, import, and signature checks). This imports user modules,
892
957
  so it is opt-in; ``syspaths`` are added to ``sys.path`` for resolution.
893
958
  """
@@ -919,7 +984,14 @@ def validate_scenario(
919
984
  root_path=root_path,
920
985
  )
921
986
  except ReferenceResolverError as e:
922
- diagnostics.append(_diag(DiagnosticCode.REF_ERROR, "error", f"JSON reference resolution error: {e}"))
987
+ # The resolver wraps a plain JSON syntax error as a ReferenceResolverError
988
+ # (chaining the JSONDecodeError as __cause__). Report those under the
989
+ # accurate "Invalid JSON syntax" code rather than the $ref-flavored one,
990
+ # which would mislead when no reference is involved.
991
+ if isinstance(e.__cause__, json.JSONDecodeError):
992
+ diagnostics.append(_diag(DiagnosticCode.INVALID_JSON, "error", f"Invalid JSON syntax: {e.__cause__}"))
993
+ else:
994
+ diagnostics.append(_diag(DiagnosticCode.REF_ERROR, "error", f"JSON reference resolution error: {e}"))
923
995
  return _result(diagnostics)
924
996
  except json.JSONDecodeError as e:
925
997
  diagnostics.append(_diag(DiagnosticCode.INVALID_JSON, "error", f"Invalid JSON syntax: {e}"))