pytest-httpchain 0.8.0__tar.gz → 0.9.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/PKG-INFO +6 -6
- {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/pyproject.toml +63 -30
- {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/src/pytest_httpchain/__init__.py +4 -4
- {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/src/pytest_httpchain/carrier.py +40 -14
- {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/src/pytest_httpchain/cli.py +10 -25
- {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/src/pytest_httpchain/dataflow.py +3 -3
- pytest_httpchain-0.8.0/src/pytest_httpchain/exceptions.py → pytest_httpchain-0.9.0/src/pytest_httpchain/errors.py +4 -1
- {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/src/pytest_httpchain/har_writer.py +5 -9
- pytest_httpchain-0.9.0/src/pytest_httpchain/jsonref/__init__.py +24 -0
- pytest_httpchain-0.9.0/src/pytest_httpchain/jsonref/exceptions.py +5 -0
- pytest_httpchain-0.9.0/src/pytest_httpchain/jsonref/loader.py +30 -0
- pytest_httpchain-0.9.0/src/pytest_httpchain/jsonref/plumbing/__init__.py +0 -0
- pytest_httpchain-0.9.0/src/pytest_httpchain/jsonref/plumbing/circular.py +86 -0
- pytest_httpchain-0.9.0/src/pytest_httpchain/jsonref/plumbing/path.py +109 -0
- pytest_httpchain-0.9.0/src/pytest_httpchain/jsonref/plumbing/reference.py +255 -0
- pytest_httpchain-0.9.0/src/pytest_httpchain/models/__init__.py +101 -0
- pytest_httpchain-0.9.0/src/pytest_httpchain/models/entities.py +587 -0
- pytest_httpchain-0.9.0/src/pytest_httpchain/models/types.py +217 -0
- {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/src/pytest_httpchain/plugin.py +9 -16
- pytest_httpchain-0.9.0/src/pytest_httpchain/py.typed +0 -0
- {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/src/pytest_httpchain/schema.py +3 -3
- pytest_httpchain-0.9.0/src/pytest_httpchain/templates/__init__.py +26 -0
- pytest_httpchain-0.9.0/src/pytest_httpchain/templates/exceptions.py +5 -0
- pytest_httpchain-0.9.0/src/pytest_httpchain/templates/expressions.py +31 -0
- pytest_httpchain-0.9.0/src/pytest_httpchain/templates/substitution.py +209 -0
- pytest_httpchain-0.9.0/src/pytest_httpchain/userfunc.py +148 -0
- {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/src/pytest_httpchain/utils.py +6 -5
- {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/src/pytest_httpchain/validation.py +47 -45
- pytest_httpchain-0.9.0/src/pytest_httpchain/warnings.py +13 -0
- {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/LICENSE +0 -0
- {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/README.md +0 -0
- {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/src/pytest_httpchain/constants.py +0 -0
- {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/src/pytest_httpchain/report_formatter.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-httpchain
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: pytest plugin for HTTP testing using JSON files
|
|
5
5
|
Keywords: testing,pytest,httpx,http,api,integration-testing
|
|
6
6
|
Author: Alexander Eresov
|
|
@@ -14,21 +14,21 @@ Classifier: License :: OSI Approved :: MIT License
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
16
16
|
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Requires-Dist: deepmerge>=2.0
|
|
18
|
+
Requires-Dist: graphql-core>=3.0.0
|
|
17
19
|
Requires-Dist: httpx[http2]>=0.27.0
|
|
18
20
|
Requires-Dist: jmespath>=1.0.0
|
|
19
21
|
Requires-Dist: jsonschema>=4.0.0
|
|
20
22
|
Requires-Dist: pydantic>=2.11.7
|
|
21
23
|
Requires-Dist: pyrate-limiter>=4.0.0
|
|
22
24
|
Requires-Dist: pytest>=8.4
|
|
23
|
-
Requires-Dist: pytest-httpchain-core
|
|
24
|
-
Requires-Dist: pytest-httpchain-jsonref
|
|
25
|
-
Requires-Dist: pytest-httpchain-models
|
|
26
|
-
Requires-Dist: pytest-httpchain-templates
|
|
27
|
-
Requires-Dist: pytest-httpchain-userfunc
|
|
28
25
|
Requires-Dist: pytest-order>=1.3.0
|
|
29
26
|
Requires-Dist: simpleeval>=1.0.3
|
|
30
27
|
Requires-Dist: typer>=0.16.0
|
|
31
28
|
Requires-Python: >=3.13
|
|
29
|
+
Project-URL: Changelog, https://github.com/aeresov/pytest-httpchain/blob/main/CHANGELOG.md
|
|
30
|
+
Project-URL: Documentation, https://aeresov.github.io/pytest-httpchain/
|
|
31
|
+
Project-URL: Repository, https://github.com/aeresov/pytest-httpchain
|
|
32
32
|
Description-Content-Type: text/markdown
|
|
33
33
|
|
|
34
34
|
[](https://pypi.python.org/pypi/pytest-httpchain)
|
|
@@ -1,22 +1,19 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pytest-httpchain"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.9.0"
|
|
4
4
|
description = "pytest plugin for HTTP testing using JSON files"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
7
7
|
authors = [{ name = "Alexander Eresov", email = "aeresov@gmail.com" }]
|
|
8
8
|
dependencies = [
|
|
9
|
+
"deepmerge>=2.0",
|
|
10
|
+
"graphql-core>=3.0.0",
|
|
9
11
|
"httpx[http2]>=0.27.0",
|
|
10
12
|
"jmespath>=1.0.0",
|
|
11
13
|
"jsonschema>=4.0.0",
|
|
12
14
|
"pydantic>=2.11.7",
|
|
13
15
|
"pyrate-limiter>=4.0.0",
|
|
14
16
|
"pytest>=8.4",
|
|
15
|
-
"pytest-httpchain-core",
|
|
16
|
-
"pytest-httpchain-jsonref",
|
|
17
|
-
"pytest-httpchain-models",
|
|
18
|
-
"pytest-httpchain-templates",
|
|
19
|
-
"pytest-httpchain-userfunc",
|
|
20
17
|
"pytest-order>=1.3.0",
|
|
21
18
|
"simpleeval>=1.0.3",
|
|
22
19
|
"typer>=0.16.0",
|
|
@@ -34,10 +31,16 @@ classifiers = [
|
|
|
34
31
|
"Topic :: Software Development :: Testing",
|
|
35
32
|
]
|
|
36
33
|
|
|
34
|
+
[project.urls]
|
|
35
|
+
Repository = "https://github.com/aeresov/pytest-httpchain"
|
|
36
|
+
Documentation = "https://aeresov.github.io/pytest-httpchain/"
|
|
37
|
+
Changelog = "https://github.com/aeresov/pytest-httpchain/blob/main/CHANGELOG.md"
|
|
38
|
+
|
|
37
39
|
[dependency-groups]
|
|
38
40
|
dev = [
|
|
39
41
|
"flask-httpauth>=4.8.0",
|
|
40
42
|
"http-server-mock>=1.7",
|
|
43
|
+
"import-linter>=2.0",
|
|
41
44
|
"ruff>=0.12.1",
|
|
42
45
|
"pytest-cov>=6.0.0",
|
|
43
46
|
"coverage[toml]>=7.0",
|
|
@@ -51,20 +54,13 @@ docs = [
|
|
|
51
54
|
[tool.uv]
|
|
52
55
|
default-groups = ["dev", "docs"]
|
|
53
56
|
|
|
54
|
-
[tool.uv.workspace]
|
|
55
|
-
members = ["packages/*"]
|
|
56
|
-
|
|
57
|
-
[tool.uv.sources]
|
|
58
|
-
pytest-httpchain-core = { workspace = true }
|
|
59
|
-
pytest-httpchain-jsonref = { workspace = true }
|
|
60
|
-
pytest-httpchain-templates = { workspace = true }
|
|
61
|
-
pytest-httpchain-models = { workspace = true }
|
|
62
|
-
pytest-httpchain-userfunc = { workspace = true }
|
|
63
|
-
|
|
64
57
|
[build-system]
|
|
65
58
|
requires = ["uv_build>=0.7.21,<0.8.0"]
|
|
66
59
|
build-backend = "uv_build"
|
|
67
60
|
|
|
61
|
+
[tool.uv.build-backend]
|
|
62
|
+
source-exclude = ["src/pytest_httpchain/**/CLAUDE.md"]
|
|
63
|
+
|
|
68
64
|
[project.scripts]
|
|
69
65
|
pytest-httpchain = "pytest_httpchain.cli:app"
|
|
70
66
|
|
|
@@ -94,6 +90,26 @@ ignore = []
|
|
|
94
90
|
[tool.ruff.lint.isort]
|
|
95
91
|
known-first-party = ["tests", "utils"]
|
|
96
92
|
|
|
93
|
+
[tool.ty.environment]
|
|
94
|
+
python-version = "3.13"
|
|
95
|
+
|
|
96
|
+
[tool.ty.terminal]
|
|
97
|
+
error-on-warning = true
|
|
98
|
+
|
|
99
|
+
# Test suites (especially the models package) deliberately construct models with
|
|
100
|
+
# invalid types to assert ValidationError, and access attributes on unions/mocks
|
|
101
|
+
# the checker can't narrow. Relax those specific rules for test code only; src/
|
|
102
|
+
# stays strict and uses pinpoint `# ty: ignore[...]` comments instead.
|
|
103
|
+
[[tool.ty.overrides]]
|
|
104
|
+
include = ["tests/**"]
|
|
105
|
+
|
|
106
|
+
[tool.ty.overrides.rules]
|
|
107
|
+
invalid-argument-type = "ignore"
|
|
108
|
+
unknown-argument = "ignore"
|
|
109
|
+
unresolved-attribute = "ignore"
|
|
110
|
+
not-subscriptable = "ignore"
|
|
111
|
+
missing-argument = "ignore"
|
|
112
|
+
|
|
97
113
|
[tool.pytest]
|
|
98
114
|
minversion = "9.0"
|
|
99
115
|
log_cli = true
|
|
@@ -103,26 +119,43 @@ pytester_example_dir = "tests/integration/examples"
|
|
|
103
119
|
addopts = ["--log-disable=werkzeug", "--import-mode=importlib"]
|
|
104
120
|
pythonpath = [
|
|
105
121
|
".",
|
|
106
|
-
"packages/pytest-httpchain-core/src",
|
|
107
|
-
"packages/pytest-httpchain-jsonref/src",
|
|
108
|
-
"packages/pytest-httpchain-templates/src",
|
|
109
|
-
"packages/pytest-httpchain-models/src",
|
|
110
|
-
"packages/pytest-httpchain-userfunc/src",
|
|
111
122
|
]
|
|
112
|
-
testpaths = ["tests"
|
|
123
|
+
testpaths = ["tests"]
|
|
113
124
|
|
|
114
125
|
[tool.coverage.run]
|
|
115
|
-
source = [
|
|
116
|
-
"src",
|
|
117
|
-
"packages/pytest-httpchain-core/src",
|
|
118
|
-
"packages/pytest-httpchain-jsonref/src",
|
|
119
|
-
"packages/pytest-httpchain-templates/src",
|
|
120
|
-
"packages/pytest-httpchain-models/src",
|
|
121
|
-
"packages/pytest-httpchain-userfunc/src",
|
|
122
|
-
]
|
|
126
|
+
source = ["src"]
|
|
123
127
|
branch = true
|
|
124
128
|
|
|
125
129
|
[tool.coverage.report]
|
|
126
130
|
precision = 2
|
|
127
131
|
show_missing = true
|
|
128
132
|
skip_covered = false
|
|
133
|
+
|
|
134
|
+
[tool.importlinter]
|
|
135
|
+
root_package = "pytest_httpchain"
|
|
136
|
+
|
|
137
|
+
# Exhaustive vertical layering: every top-level module must appear in exactly one
|
|
138
|
+
# layer; a new module fails lint-imports until it is placed. Modules on the same
|
|
139
|
+
# layer (separated by ":") are additionally kept mutually independent.
|
|
140
|
+
[[tool.importlinter.contracts]]
|
|
141
|
+
name = "package layering"
|
|
142
|
+
type = "layers"
|
|
143
|
+
containers = ["pytest_httpchain"]
|
|
144
|
+
exhaustive = true
|
|
145
|
+
layers = [
|
|
146
|
+
"plugin : cli",
|
|
147
|
+
"carrier : dataflow : schema",
|
|
148
|
+
"validation : har_writer : report_formatter",
|
|
149
|
+
"utils",
|
|
150
|
+
"models",
|
|
151
|
+
"templates : jsonref : userfunc",
|
|
152
|
+
"errors : constants : warnings",
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
# Horizontal refinement the layers contract cannot express: models sits above the
|
|
156
|
+
# domain trio and may import templates/userfunc, but must never import jsonref.
|
|
157
|
+
[[tool.importlinter.contracts]]
|
|
158
|
+
name = "models does not import jsonref"
|
|
159
|
+
type = "forbidden"
|
|
160
|
+
source_modules = ["pytest_httpchain.models"]
|
|
161
|
+
forbidden_modules = ["pytest_httpchain.jsonref"]
|
|
@@ -22,9 +22,9 @@ Example test file (test_api.http.json):
|
|
|
22
22
|
}
|
|
23
23
|
"""
|
|
24
24
|
|
|
25
|
-
# Re-export the user-facing warning so consumers can filter it
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
from pytest_httpchain.
|
|
25
|
+
# Re-export the user-facing warning so consumers can filter it from the package
|
|
26
|
+
# root. It lives in the leaf module ``warnings`` so this import (which runs on
|
|
27
|
+
# any subpackage import) does not load the plugin/execution machinery.
|
|
28
|
+
from pytest_httpchain.warnings import ScenarioValidationWarning
|
|
29
29
|
|
|
30
30
|
__all__ = ["ScenarioValidationWarning"]
|
|
@@ -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
|
|
@@ -41,7 +41,8 @@ import jsonschema
|
|
|
41
41
|
import pytest
|
|
42
42
|
from pydantic import ValidationError
|
|
43
43
|
from pyrate_limiter import Duration, Limiter, Rate
|
|
44
|
-
|
|
44
|
+
|
|
45
|
+
from pytest_httpchain.models import (
|
|
45
46
|
Base64Body,
|
|
46
47
|
BinaryBody,
|
|
47
48
|
CombinationsParameter,
|
|
@@ -67,10 +68,10 @@ from pytest_httpchain_models import (
|
|
|
67
68
|
XmlBody,
|
|
68
69
|
check_json_schema,
|
|
69
70
|
)
|
|
70
|
-
from
|
|
71
|
-
from
|
|
71
|
+
from pytest_httpchain.templates import TemplatesError, walk
|
|
72
|
+
from pytest_httpchain.userfunc import UserFunctionError
|
|
72
73
|
|
|
73
|
-
from .
|
|
74
|
+
from .errors import RequestError, SaveError, StageExecutionError, VerificationError
|
|
74
75
|
from .utils import call_user_function, make_marker, process_substitutions
|
|
75
76
|
|
|
76
77
|
logger = logging.getLogger(__name__)
|
|
@@ -98,6 +99,13 @@ class Carrier:
|
|
|
98
99
|
It manages the shared state, context, and execution flow for all stages in a test scenario.
|
|
99
100
|
"""
|
|
100
101
|
|
|
102
|
+
# These ClassVars are placeholders: create_test_class() overrides every one of
|
|
103
|
+
# them in each per-scenario subclass dict (see the bottom of this module), so
|
|
104
|
+
# the values here are never the ones used at runtime — do NOT rely on the
|
|
105
|
+
# ChainMap()/None/[] defaults, and do NOT move this state to instance
|
|
106
|
+
# attributes: the stage methods are classmethods that share one running context
|
|
107
|
+
# across the chain via `cls`, while distinct scenarios stay isolated because
|
|
108
|
+
# each gets its own subclass.
|
|
101
109
|
client: ClassVar[httpx.Client | None] = None
|
|
102
110
|
aborted: ClassVar[bool] = False
|
|
103
111
|
last_request: ClassVar[httpx.Request | None] = None
|
|
@@ -222,8 +230,11 @@ class Carrier:
|
|
|
222
230
|
def _build_iteration_substitutions(parallel_config: Any) -> list[dict[str, Any]]:
|
|
223
231
|
"""Expand the (already template-resolved) parallel config into per-iteration
|
|
224
232
|
substitution dicts: non-parallel -> a single empty dict; ``repeat`` -> N
|
|
225
|
-
empties; ``foreach`` -> the cross-product of its parameter steps
|
|
226
|
-
|
|
233
|
+
empties; ``foreach`` -> the cross-product of its parameter steps.
|
|
234
|
+
|
|
235
|
+
Like pytest's stacked ``parametrize`` marker this is a cross-product, but the
|
|
236
|
+
resulting iteration ORDER is the *reverse* of pytest's — do not assume the
|
|
237
|
+
two orders match."""
|
|
227
238
|
iteration_substitutions: list[dict[str, Any]] = [{}]
|
|
228
239
|
match parallel_config:
|
|
229
240
|
case None:
|
|
@@ -232,13 +243,17 @@ class Carrier:
|
|
|
232
243
|
iteration_substitutions = [{} for _ in range(repeat_count)]
|
|
233
244
|
case ParallelForeachConfig(foreach=foreach_steps):
|
|
234
245
|
for step in foreach_steps:
|
|
246
|
+
# Comprehension clause order is load-bearing: the new values are the
|
|
247
|
+
# OUTER loop and the accumulated dicts the INNER loop, which is what
|
|
248
|
+
# produces the (reverse-of-pytest) ordering noted above. Swapping the
|
|
249
|
+
# two `for` clauses silently changes the iteration order.
|
|
235
250
|
match step:
|
|
236
251
|
case IndividualParameter(individual=individual):
|
|
237
252
|
param_name = next(iter(individual.keys()))
|
|
238
253
|
values = individual[param_name]
|
|
239
254
|
iteration_substitutions = [{**existing, param_name: val} for val in values for existing in iteration_substitutions]
|
|
240
255
|
case CombinationsParameter(combinations=combinations):
|
|
241
|
-
combos: list[dict[str, Any]
|
|
256
|
+
combos: list[dict[str, Any]] = [vars(item) if isinstance(item, SimpleNamespace) else item for item in combinations]
|
|
242
257
|
iteration_substitutions = [{**existing, **combo} for combo in combos for existing in iteration_substitutions]
|
|
243
258
|
return iteration_substitutions
|
|
244
259
|
|
|
@@ -353,7 +368,7 @@ class Carrier:
|
|
|
353
368
|
@classmethod
|
|
354
369
|
def _execute_http_request(cls, request_kwargs: dict[str, Any]) -> httpx.Response:
|
|
355
370
|
try:
|
|
356
|
-
return cls.client.request(**request_kwargs)
|
|
371
|
+
return cls.client.request(**request_kwargs) # ty: ignore[unresolved-attribute]
|
|
357
372
|
except httpx.TimeoutException as e:
|
|
358
373
|
raise RequestError(f"HTTP request timed out: {e}") from e
|
|
359
374
|
except httpx.ConnectError as e:
|
|
@@ -396,7 +411,7 @@ class Carrier:
|
|
|
396
411
|
if not isinstance(func_result, dict):
|
|
397
412
|
raise SaveError(f"Save function must return dict, got {type(func_result).__name__}")
|
|
398
413
|
|
|
399
|
-
step_saved.update(func_result)
|
|
414
|
+
step_saved.update(func_result) # ty: ignore[no-matching-overload]
|
|
400
415
|
except SaveError:
|
|
401
416
|
raise
|
|
402
417
|
except UserFunctionError as e:
|
|
@@ -518,6 +533,15 @@ class Carrier:
|
|
|
518
533
|
|
|
519
534
|
@classmethod
|
|
520
535
|
def _wrap_factory_fixture(cls, fixture: Callable) -> Callable:
|
|
536
|
+
"""Wrap a callable fixture so context-manager results are entered and tracked.
|
|
537
|
+
|
|
538
|
+
Not a pure pass-through: each call invokes ``fixture`` and, if the result is
|
|
539
|
+
a context manager, calls ``__enter__()`` and registers it on
|
|
540
|
+
``cls.active_context_managers`` for LIFO teardown in ``teardown_class``.
|
|
541
|
+
Consequence: calling the wrapped value twice opens two resources (two
|
|
542
|
+
``__enter__`` calls, two teardowns) — invoke it once per needed instance.
|
|
543
|
+
"""
|
|
544
|
+
|
|
521
545
|
def wrapped(*args, **kwargs):
|
|
522
546
|
result = fixture(*args, **kwargs)
|
|
523
547
|
|
|
@@ -594,7 +618,9 @@ def create_test_class(scenario: Scenario, class_name: str, max_parallel_iteratio
|
|
|
594
618
|
padding_width = len(str(total_stages - 1)) if total_stages > 0 else 1
|
|
595
619
|
|
|
596
620
|
for i, stage in enumerate(scenario.stages):
|
|
597
|
-
|
|
621
|
+
# Factory captures `stage` by value (as stage_template) per iteration. Do NOT
|
|
622
|
+
# inline this into a closure over the loop variable `stage`: Python closes over
|
|
623
|
+
# the variable, not its value, so every stage method would run the LAST stage.
|
|
598
624
|
def make_stage_method(stage_template: Stage) -> Callable:
|
|
599
625
|
def call_execute_stage(self, **kwargs):
|
|
600
626
|
type(self).execute_stage(stage_template, kwargs)
|
|
@@ -636,7 +662,7 @@ def create_test_class(scenario: Scenario, class_name: str, max_parallel_iteratio
|
|
|
636
662
|
stage_method = parametrize_marker(stage_method)
|
|
637
663
|
|
|
638
664
|
all_fixtures = ["self"] + list(dict.fromkeys(all_param_names + stage.fixtures + scenario.fixtures))
|
|
639
|
-
stage_method.__signature__ = inspect.Signature([inspect.Parameter(name, inspect.Parameter.POSITIONAL_OR_KEYWORD) for name in all_fixtures]) #
|
|
665
|
+
stage_method.__signature__ = inspect.Signature([inspect.Parameter(name, inspect.Parameter.POSITIONAL_OR_KEYWORD) for name in all_fixtures]) # ty: ignore[unresolved-attribute]
|
|
640
666
|
|
|
641
667
|
all_marks = [f"order({i})"] + stage.marks
|
|
642
668
|
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
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
from pytest_httpchain.dataflow import DataFlow, analyze_dataflow
|
|
10
|
+
from pytest_httpchain.jsonref import ReferenceResolverError, load_json
|
|
11
|
+
from pytest_httpchain.models import Scenario
|
|
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
|
|
|
@@ -49,8 +51,6 @@ def validate(
|
|
|
49
51
|
per file and exits non-zero if any file is invalid (or, with --strict, has any
|
|
50
52
|
warnings).
|
|
51
53
|
"""
|
|
52
|
-
from pytest_httpchain.validation import ValidateResult, validate_scenario
|
|
53
|
-
|
|
54
54
|
results = [
|
|
55
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
|
|
56
56
|
]
|
|
@@ -87,8 +87,6 @@ def schema() -> None:
|
|
|
87
87
|
|
|
88
88
|
Writes to stdout; redirect to a file (``pytest-httpchain schema > scenario.schema.json``).
|
|
89
89
|
"""
|
|
90
|
-
from pytest_httpchain.schema import build_schema
|
|
91
|
-
|
|
92
90
|
typer.echo(json.dumps(build_schema(), indent=2, default=str))
|
|
93
91
|
|
|
94
92
|
|
|
@@ -99,10 +97,6 @@ def resolve(
|
|
|
99
97
|
root_path: Annotated[Path | None, typer.Option("--root-path", help="Directory that constrains $ref resolution (default: nearest tests/ ancestor).")] = None,
|
|
100
98
|
) -> None:
|
|
101
99
|
"""Resolve $ref/$include/$merge and print the merged scenario JSON to stdout."""
|
|
102
|
-
from pytest_httpchain_jsonref import ReferenceResolverError, load_json
|
|
103
|
-
|
|
104
|
-
from pytest_httpchain.validation import resolve_root_path
|
|
105
|
-
|
|
106
100
|
try:
|
|
107
101
|
data = load_json(
|
|
108
102
|
scenario,
|
|
@@ -116,13 +110,8 @@ def resolve(
|
|
|
116
110
|
typer.echo(json.dumps(data, indent=2, default=str))
|
|
117
111
|
|
|
118
112
|
|
|
119
|
-
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]:
|
|
120
114
|
"""Load + validate a scenario for show/graph, mapping failures to Exit(1)."""
|
|
121
|
-
from pydantic import ValidationError
|
|
122
|
-
from pytest_httpchain_jsonref import ReferenceResolverError
|
|
123
|
-
|
|
124
|
-
from pytest_httpchain.validation import load_scenario
|
|
125
|
-
|
|
126
115
|
try:
|
|
127
116
|
return load_scenario(path, root_path=root_path, ref_parent_traversal_depth=depth)
|
|
128
117
|
except (ReferenceResolverError, json.JSONDecodeError, OSError) as e:
|
|
@@ -133,7 +122,7 @@ def _load_for_inspection(path: Path, depth: int, root_path: Path | None = None)
|
|
|
133
122
|
raise typer.Exit(1) from None
|
|
134
123
|
|
|
135
124
|
|
|
136
|
-
def _render_show_text(path: Path, scenario:
|
|
125
|
+
def _render_show_text(path: Path, scenario: Scenario, flow: DataFlow) -> list[str]:
|
|
137
126
|
producer_of: dict[tuple[int, str], int] = {}
|
|
138
127
|
for edge in flow.edges:
|
|
139
128
|
for var_name in edge.vars:
|
|
@@ -177,8 +166,6 @@ def show(
|
|
|
177
166
|
root_path: Annotated[Path | None, typer.Option("--root-path", help="Directory that constrains $ref resolution (default: nearest tests/ ancestor).")] = None,
|
|
178
167
|
) -> None:
|
|
179
168
|
"""Summarize a scenario's stages and variable data-flow."""
|
|
180
|
-
from pytest_httpchain.dataflow import analyze_dataflow
|
|
181
|
-
|
|
182
169
|
sc, test_data = _load_for_inspection(scenario, ref_parent_traversal_depth, root_path)
|
|
183
170
|
flow = analyze_dataflow(sc, test_data)
|
|
184
171
|
|
|
@@ -195,7 +182,7 @@ def _mermaid_label(text: str) -> str:
|
|
|
195
182
|
return text.replace('"', "'").replace("\n", " ")
|
|
196
183
|
|
|
197
184
|
|
|
198
|
-
def _to_mermaid(flow:
|
|
185
|
+
def _to_mermaid(flow: DataFlow, direction: str = "TD") -> str:
|
|
199
186
|
lines = [f"flowchart {direction}"]
|
|
200
187
|
if not flow.stages:
|
|
201
188
|
lines.append(" %% (no stages)")
|
|
@@ -216,8 +203,6 @@ def graph(
|
|
|
216
203
|
root_path: Annotated[Path | None, typer.Option("--root-path", help="Directory that constrains $ref resolution (default: nearest tests/ ancestor).")] = None,
|
|
217
204
|
) -> None:
|
|
218
205
|
"""Emit a Mermaid flowchart of the stage data-flow."""
|
|
219
|
-
from pytest_httpchain.dataflow import analyze_dataflow
|
|
220
|
-
|
|
221
206
|
sc, test_data = _load_for_inspection(scenario, ref_parent_traversal_depth, root_path)
|
|
222
207
|
flow = analyze_dataflow(sc, test_data)
|
|
223
208
|
typer.echo(_to_mermaid(flow, direction.value))
|
|
@@ -9,11 +9,11 @@ stages, and the producer -> consumer edges between stages.
|
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
11
|
from pydantic import BaseModel
|
|
12
|
-
from pytest_httpchain_models import Scenario
|
|
13
12
|
|
|
13
|
+
from pytest_httpchain.models import Scenario
|
|
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
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""JSON reference resolution for pytest-httpchain.
|
|
2
|
+
|
|
3
|
+
This package provides JSON loading with reference resolution and deep merging
|
|
4
|
+
support. References use the ``$include``/``$merge`` directives (preferred, they
|
|
5
|
+
avoid conflicts with VS Code's JSON Schema validation) or the legacy ``$ref``;
|
|
6
|
+
all three behave identically. References can point to other local files or to
|
|
7
|
+
JSON pointers within the same document, with security controls for parent
|
|
8
|
+
directory traversal.
|
|
9
|
+
|
|
10
|
+
Example:
|
|
11
|
+
>>> from pathlib import Path
|
|
12
|
+
>>> from pytest_httpchain.jsonref import load_json
|
|
13
|
+
>>> # base.json contains {"timeout": 30}; the scenario merges it in:
|
|
14
|
+
>>> # {"$include": "base.json", "url": "https://example.com"}
|
|
15
|
+
>>> data = load_json(Path("test_scenario.http.json"))
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from .exceptions import ReferenceResolverError
|
|
19
|
+
from .loader import load_json
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"load_json",
|
|
23
|
+
"ReferenceResolverError",
|
|
24
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""JSON file loading with reference resolution."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pytest_httpchain.jsonref.plumbing.reference import ReferenceResolver
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_json(path: Path, max_parent_traversal_depth: int = 3, root_path: Path | None = None) -> dict[str, Any]:
|
|
10
|
+
"""Load JSON from file and resolve all $include/$merge/$ref statements with circular reference protection.
|
|
11
|
+
|
|
12
|
+
All three directives ($include, $merge, $ref) work identically. $include and $merge are preferred
|
|
13
|
+
as they avoid conflicts with VS Code's JSON Schema validation (which treats $ref specially).
|
|
14
|
+
|
|
15
|
+
"$schema" keys pass through untouched — whether and where to tolerate them
|
|
16
|
+
is the consumer's decision (pytest-httpchain's models drop them during validation).
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
path: Path to the JSON file to load
|
|
20
|
+
max_parent_traversal_depth: Maximum number of parent directory traversals allowed in reference paths
|
|
21
|
+
root_path: Optional root directory for resolving references (e.g., pytest's rootdir)
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Dictionary with all $include/$ref statements resolved
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
ReferenceResolverError: If the file cannot be loaded or parsed, if merge conflicts occur, or if circular references are detected
|
|
28
|
+
"""
|
|
29
|
+
resolver = ReferenceResolver(max_parent_traversal_depth, root_path)
|
|
30
|
+
return resolver.resolve_file(path)
|
|
File without changes
|