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.
- {pytest_httpchain-0.7.0 → pytest_httpchain-0.8.1}/PKG-INFO +14 -4
- {pytest_httpchain-0.7.0 → pytest_httpchain-0.8.1}/README.md +12 -2
- {pytest_httpchain-0.7.0 → pytest_httpchain-0.8.1}/pyproject.toml +22 -2
- {pytest_httpchain-0.7.0 → pytest_httpchain-0.8.1}/src/pytest_httpchain/carrier.py +53 -14
- {pytest_httpchain-0.7.0 → pytest_httpchain-0.8.1}/src/pytest_httpchain/cli.py +17 -51
- {pytest_httpchain-0.7.0 → pytest_httpchain-0.8.1}/src/pytest_httpchain/dataflow.py +2 -2
- {pytest_httpchain-0.7.0 → pytest_httpchain-0.8.1}/src/pytest_httpchain/har_writer.py +5 -9
- {pytest_httpchain-0.7.0 → pytest_httpchain-0.8.1}/src/pytest_httpchain/plugin.py +19 -12
- {pytest_httpchain-0.7.0 → pytest_httpchain-0.8.1}/src/pytest_httpchain/utils.py +1 -1
- {pytest_httpchain-0.7.0 → pytest_httpchain-0.8.1}/src/pytest_httpchain/validation.py +112 -40
- {pytest_httpchain-0.7.0 → pytest_httpchain-0.8.1}/LICENSE +0 -0
- {pytest_httpchain-0.7.0 → pytest_httpchain-0.8.1}/src/pytest_httpchain/__init__.py +0 -0
- {pytest_httpchain-0.7.0 → pytest_httpchain-0.8.1}/src/pytest_httpchain/constants.py +0 -0
- {pytest_httpchain-0.7.0 → pytest_httpchain-0.8.1}/src/pytest_httpchain/exceptions.py +0 -0
- {pytest_httpchain-0.7.0 → pytest_httpchain-0.8.1}/src/pytest_httpchain/report_formatter.py +0 -0
- {pytest_httpchain-0.7.0 → pytest_httpchain-0.8.1}/src/pytest_httpchain/schema.py +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-httpchain
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.1
|
|
4
4
|
Summary: pytest plugin for HTTP testing using JSON files
|
|
5
|
-
Keywords: testing,pytest,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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", "
|
|
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
|
|
4
|
-
test class (a subclass of
|
|
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,
|
|
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
|
-
#
|
|
159
|
-
# repeat is PositiveInt), but
|
|
160
|
-
#
|
|
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
|
|
225
|
-
|
|
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]
|
|
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]) #
|
|
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
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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[
|
|
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:
|
|
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:
|
|
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) |
|
|
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,
|
|
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
|
|
11
|
-
-
|
|
12
|
-
against the
|
|
13
|
-
(warnings 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
|
-
|
|
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",
|
|
169
|
-
#
|
|
170
|
-
#
|
|
171
|
-
#
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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 |=
|
|
237
|
+
names |= parameter_names(stage.parametrize)
|
|
222
238
|
if stage.parallel is not None:
|
|
223
|
-
names |=
|
|
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
|
-
(
|
|
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 |=
|
|
259
|
+
defined_vars |= parameter_names(stage.parametrize)
|
|
244
260
|
if stage.parallel is not None:
|
|
245
|
-
defined_vars |=
|
|
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
|
|
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
|
-
|
|
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 =
|
|
337
|
-
pre_iteration_available = scenario_available | set(stage.fixtures) |
|
|
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) |
|
|
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
|
|
424
|
-
helper, mirroring
|
|
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:
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
|
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 =
|
|
829
|
+
stage_params = parameter_names(stage.parametrize)
|
|
767
830
|
if stage.parallel is not None:
|
|
768
|
-
stage_params |=
|
|
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
|
|
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
|
|
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
|
-
|
|
952
|
+
`check_scenario`, returning a `ValidateResult` whose
|
|
888
953
|
``diagnostics`` carry stable codes.
|
|
889
954
|
|
|
890
|
-
When ``deep`` is true, additionally runs
|
|
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
|
-
|
|
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}"))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|