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.
Files changed (33) hide show
  1. {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/PKG-INFO +6 -6
  2. {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/pyproject.toml +63 -30
  3. {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/src/pytest_httpchain/__init__.py +4 -4
  4. {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/src/pytest_httpchain/carrier.py +40 -14
  5. {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/src/pytest_httpchain/cli.py +10 -25
  6. {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/src/pytest_httpchain/dataflow.py +3 -3
  7. pytest_httpchain-0.8.0/src/pytest_httpchain/exceptions.py → pytest_httpchain-0.9.0/src/pytest_httpchain/errors.py +4 -1
  8. {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/src/pytest_httpchain/har_writer.py +5 -9
  9. pytest_httpchain-0.9.0/src/pytest_httpchain/jsonref/__init__.py +24 -0
  10. pytest_httpchain-0.9.0/src/pytest_httpchain/jsonref/exceptions.py +5 -0
  11. pytest_httpchain-0.9.0/src/pytest_httpchain/jsonref/loader.py +30 -0
  12. pytest_httpchain-0.9.0/src/pytest_httpchain/jsonref/plumbing/__init__.py +0 -0
  13. pytest_httpchain-0.9.0/src/pytest_httpchain/jsonref/plumbing/circular.py +86 -0
  14. pytest_httpchain-0.9.0/src/pytest_httpchain/jsonref/plumbing/path.py +109 -0
  15. pytest_httpchain-0.9.0/src/pytest_httpchain/jsonref/plumbing/reference.py +255 -0
  16. pytest_httpchain-0.9.0/src/pytest_httpchain/models/__init__.py +101 -0
  17. pytest_httpchain-0.9.0/src/pytest_httpchain/models/entities.py +587 -0
  18. pytest_httpchain-0.9.0/src/pytest_httpchain/models/types.py +217 -0
  19. {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/src/pytest_httpchain/plugin.py +9 -16
  20. pytest_httpchain-0.9.0/src/pytest_httpchain/py.typed +0 -0
  21. {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/src/pytest_httpchain/schema.py +3 -3
  22. pytest_httpchain-0.9.0/src/pytest_httpchain/templates/__init__.py +26 -0
  23. pytest_httpchain-0.9.0/src/pytest_httpchain/templates/exceptions.py +5 -0
  24. pytest_httpchain-0.9.0/src/pytest_httpchain/templates/expressions.py +31 -0
  25. pytest_httpchain-0.9.0/src/pytest_httpchain/templates/substitution.py +209 -0
  26. pytest_httpchain-0.9.0/src/pytest_httpchain/userfunc.py +148 -0
  27. {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/src/pytest_httpchain/utils.py +6 -5
  28. {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/src/pytest_httpchain/validation.py +47 -45
  29. pytest_httpchain-0.9.0/src/pytest_httpchain/warnings.py +13 -0
  30. {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/LICENSE +0 -0
  31. {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/README.md +0 -0
  32. {pytest_httpchain-0.8.0 → pytest_httpchain-0.9.0}/src/pytest_httpchain/constants.py +0 -0
  33. {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.8.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
  [![image](https://img.shields.io/pypi/v/pytest-httpchain)](https://pypi.python.org/pypi/pytest-httpchain)
@@ -1,22 +1,19 @@
1
1
  [project]
2
2
  name = "pytest-httpchain"
3
- version = "0.8.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", "packages/*/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 without reaching
26
- # into the plugin module. The definition stays in plugin.py because the pytest11
27
- # entry point and the collection hook reference it there directly.
28
- from pytest_httpchain.plugin import ScenarioValidationWarning
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 :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
@@ -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
- from pytest_httpchain_models import (
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 pytest_httpchain_templates import TemplatesError, walk
71
- from pytest_httpchain_userfunc import UserFunctionError
71
+ from pytest_httpchain.templates import TemplatesError, walk
72
+ from pytest_httpchain.userfunc import UserFunctionError
72
73
 
73
- from .exceptions import RequestError, SaveError, StageExecutionError, VerificationError
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 (the same
226
- algorithm pytest uses for the ``parametrize`` marker)."""
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] | SimpleNamespace] = [vars(item) if isinstance(item, SimpleNamespace) else item for item in combinations]
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]) # type: ignore[assignment]
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 TYPE_CHECKING, Annotated
4
+ from typing import Annotated
5
5
 
6
6
  import typer
7
+ from pydantic import ValidationError
7
8
 
8
- if TYPE_CHECKING:
9
- from pytest_httpchain_models import Scenario
10
-
11
- from pytest_httpchain.dataflow import DataFlow
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["Scenario", dict]:
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: "Scenario", flow: "DataFlow") -> list[str]:
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: "DataFlow", direction: str = "TD") -> str:
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) | _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]] = {}
@@ -1,5 +1,8 @@
1
1
  import httpx
2
- from pytest_httpchain_core import HttpChainError
2
+
3
+
4
+ class HttpChainError(Exception):
5
+ """Base exception for all pytest-httpchain errors."""
3
6
 
4
7
 
5
8
  class StageExecutionError(HttpChainError):
@@ -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
 
@@ -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,5 @@
1
+ from pytest_httpchain.errors import HttpChainError
2
+
3
+
4
+ class ReferenceResolverError(HttpChainError):
5
+ """Exception for reference resolution errors."""
@@ -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)