pytest-httpchain 0.5.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-httpchain
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: pytest plugin for HTTP testing using JSON files
5
5
  Keywords: testing,pytest,requests
6
6
  Author: Alexander Eresov
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pytest-httpchain"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
4
  description = "pytest plugin for HTTP testing using JSON files"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13,<4.0"
@@ -79,12 +79,24 @@ class Carrier:
79
79
  max_parallel_iterations: ClassVar[int] = 10_000
80
80
 
81
81
  @classmethod
82
- def execute_stage(cls, stage: Stage, fixture_kwargs: dict[str, Any]) -> None:
82
+ def _resolve_always_run(cls, stage: Stage, stage_fixtures: dict[str, Any]) -> bool:
83
+ """Resolve ``always_run``, evaluating a template form against the context
84
+ available at stage start: fixtures and parametrize parameters plus the
85
+ global context (scenario substitutions and earlier saves). Stage
86
+ substitutions are not yet processed at this point. The result is coerced
87
+ with Python truthiness."""
88
+ if isinstance(stage.always_run, bool):
89
+ return stage.always_run
83
90
  try:
84
- if cls.aborted and not stage.always_run:
85
- pytest.skip(reason="Flow aborted")
91
+ return bool(walk(stage.always_run, ChainMap(stage_fixtures, cls.global_context)))
92
+ except TemplatesError as e:
93
+ raise StageExecutionError(f"Failed to evaluate always_run template: {e}") from e
86
94
 
87
- # prepare stage context
95
+ @classmethod
96
+ def execute_stage(cls, stage: Stage, fixture_kwargs: dict[str, Any]) -> None:
97
+ try:
98
+ # prepare stage context (before the abort check, so an always_run
99
+ # template sees the same fixture layer as stage templates)
88
100
  stage_fixtures: dict[str, Any] = {}
89
101
  for name, value in fixture_kwargs.items():
90
102
  if callable(value) and not inspect.isclass(value):
@@ -92,6 +104,9 @@ class Carrier:
92
104
  else:
93
105
  stage_fixtures[name] = value
94
106
 
107
+ if cls.aborted and not cls._resolve_always_run(stage, stage_fixtures):
108
+ pytest.skip(reason="Flow aborted")
109
+
95
110
  # Build base context for iterations (substitutions + fixtures + global)
96
111
  local_context = ChainMap(stage_fixtures, cls.global_context)
97
112
  stage_substitutions = process_substitutions(stage.substitutions, local_context)
@@ -519,7 +534,7 @@ def create_test_class(scenario: Scenario, class_name: str, max_parallel_iteratio
519
534
  parametrize_marker = pytest.mark.parametrize(",".join(param_names), param_values, ids=param_ids)
520
535
  stage_method = parametrize_marker(stage_method)
521
536
 
522
- all_fixtures = ["self"] + all_param_names + stage.fixtures
537
+ all_fixtures = ["self"] + list(dict.fromkeys(all_param_names + stage.fixtures + scenario.fixtures))
523
538
  stage_method.__signature__ = inspect.Signature([inspect.Parameter(name, inspect.Parameter.POSITIONAL_OR_KEYWORD) for name in all_fixtures]) # type: ignore[assignment]
524
539
 
525
540
  all_marks = [f"order({i})"] + stage.marks
@@ -14,6 +14,7 @@ from pydantic import BaseModel
14
14
  from pytest_httpchain_models import Scenario
15
15
 
16
16
  from pytest_httpchain.validation import (
17
+ _parameter_names,
17
18
  extract_template_variables,
18
19
  raw_stages,
19
20
  resolve_root_path,
@@ -58,11 +59,15 @@ def analyze_dataflow(scenario: Scenario, test_data: dict[str, Any]) -> DataFlow:
58
59
 
59
60
  A stage *consumes* a variable when its request/response/substitutions/parallel
60
61
  templates reference a name saved by an earlier stage and not redefined locally
61
- (own substitutions, parametrize/foreach parameters, or fixtures). ``parametrize``
62
- values are excluded — they resolve against scenario scope, never saved values.
62
+ (own substitutions, parametrize/foreach parameters, or stage/scenario fixtures).
63
+ ``always_run`` references count too, but only fixtures and parametrize
64
+ parameters shadow them — always_run resolves before stage substitutions exist.
65
+ ``parametrize`` values are excluded — they resolve against scenario scope,
66
+ never saved values.
63
67
  """
64
68
  raws = raw_stages(test_data)
65
69
  saves_by_stage = [saved_in_stage(stage) for stage in scenario.stages]
70
+ scenario_fixture_names = set(scenario.fixtures)
66
71
 
67
72
  first_save_stage: dict[str, int] = {}
68
73
  for i, saved in enumerate(saves_by_stage):
@@ -80,9 +85,17 @@ def analyze_dataflow(scenario: Scenario, test_data: dict[str, Any]) -> DataFlow:
80
85
  for key in ("request", "response", "substitutions", "parallel"):
81
86
  extract_template_variables(raw.get(key), refs)
82
87
 
83
- local = stage_defined_names(stage)
88
+ # Scenario fixtures count as local everywhere: at runtime they sit above
89
+ # the global context in the ChainMap, shadowing any same-named save.
90
+ local = stage_defined_names(stage) | scenario_fixture_names
84
91
  consumes = {name for name in refs if name in cumulative_saves and name not in local}
85
92
 
93
+ # always_run resolves before stage substitutions exist, so only fixtures
94
+ # and parametrize parameters shadow an earlier save there.
95
+ always_run_refs = extract_template_variables(raw.get("always_run"))
96
+ always_run_local = set(stage.fixtures) | _parameter_names(stage.parametrize) | scenario_fixture_names
97
+ consumes |= {name for name in always_run_refs if name in cumulative_saves and name not in always_run_local}
98
+
86
99
  by_producer: dict[int, list[str]] = {}
87
100
  for name in consumes:
88
101
  by_producer.setdefault(first_save_stage[name], []).append(name)
@@ -104,12 +117,9 @@ def analyze_dataflow(scenario: Scenario, test_data: dict[str, Any]) -> DataFlow:
104
117
 
105
118
  cumulative_saves |= saves_by_stage[i]
106
119
 
107
- scenario_fixtures = sorted(f for f in test_data["fixtures"] if isinstance(f, str)) if isinstance(test_data.get("fixtures"), list) else []
108
120
  scenario_var_names = set(substitution_names(scenario.substitutions))
109
- if isinstance(test_data.get("vars"), dict):
110
- scenario_var_names |= {k for k in test_data["vars"] if isinstance(k, str)}
111
121
 
112
- return DataFlow(stages=stages, edges=edges, scenario_fixtures=scenario_fixtures, scenario_vars=sorted(scenario_var_names))
122
+ return DataFlow(stages=stages, edges=edges, scenario_fixtures=sorted(scenario.fixtures), scenario_vars=sorted(scenario_var_names))
113
123
 
114
124
 
115
125
  def load_scenario(path: Path, ref_parent_traversal_depth: int = 3) -> tuple[Scenario, dict[str, Any]]:
@@ -14,6 +14,18 @@ SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema"
14
14
  SCHEMA_ID = "https://aeresov.github.io/pytest-httpchain/schema/scenario.schema.json"
15
15
 
16
16
 
17
+ def _one_of_to_any_of(node: Any) -> None:
18
+ """Recursively rename ``oneOf`` to ``anyOf`` in place."""
19
+ if isinstance(node, dict):
20
+ if "oneOf" in node:
21
+ node["anyOf"] = node.pop("oneOf")
22
+ for value in node.values():
23
+ _one_of_to_any_of(value)
24
+ elif isinstance(node, list):
25
+ for item in node:
26
+ _one_of_to_any_of(item)
27
+
28
+
17
29
  def _add_jsonref_support(schema: dict[str, Any]) -> dict[str, Any]:
18
30
  """Allow ``$include``/``$merge``/``$ref`` objects as alternatives anywhere.
19
31
 
@@ -37,10 +49,28 @@ def _add_jsonref_support(schema: dict[str, Any]) -> dict[str, Any]:
37
49
  "type": "string",
38
50
  "description": "Alias for $include. Path to external JSON file, JSON pointer (#/path), or combined (file.json#/path).",
39
51
  },
52
+ "$ref": {
53
+ "type": "string",
54
+ "description": "Legacy alias for $include. May conflict with VS Code's own $ref handling.",
55
+ },
40
56
  },
57
+ # Without at least one directive key this branch would match EVERY
58
+ # object, silencing the strict alternative in the surrounding anyOf.
59
+ "anyOf": [
60
+ {"required": ["$include"]},
61
+ {"required": ["$merge"]},
62
+ {"required": ["$ref"]},
63
+ ],
41
64
  "additionalProperties": True,
42
65
  }
43
66
 
67
+ # Pydantic emits oneOf for tagged unions. A reference object matches the
68
+ # JsonRef branch of EVERY union member (each $defs entry is wrapped below),
69
+ # which oneOf counts as "valid under more than one" and rejects. anyOf
70
+ # keeps the same accept set otherwise: members forbid each other's tag
71
+ # fields, so a non-reference object can never match two branches.
72
+ _one_of_to_any_of(schema)
73
+
44
74
  for type_name, original_def in list(schema["$defs"].items()):
45
75
  if type_name == "JsonRef":
46
76
  continue
@@ -58,6 +88,23 @@ def _add_jsonref_support(schema: dict[str, Any]) -> dict[str, Any]:
58
88
  if "description" in prop_def:
59
89
  schema["properties"][prop_name]["description"] = prop_def.get("description")
60
90
 
91
+ # The Scenario model forbids extra keys, so the root carries
92
+ # additionalProperties: false. Keys that are legitimate in a scenario
93
+ # *file* but handled before model validation must be declared explicitly:
94
+ # "$schema" (editor metadata, stripped by the loader) and the reference
95
+ # directives (resolved by the loader, supported at the document root).
96
+ schema.setdefault("properties", {})
97
+ schema["properties"]["$schema"] = {
98
+ "type": "string",
99
+ "description": "URL of this schema, for editor as-you-type validation. Stripped before the file is parsed.",
100
+ }
101
+ for directive, directive_def in schema["$defs"]["JsonRef"]["properties"].items():
102
+ schema["properties"][directive] = directive_def
103
+ schema["properties"]["$ref"] = {
104
+ "type": "string",
105
+ "description": "Legacy alias for $include. May conflict with VS Code's own $ref handling.",
106
+ }
107
+
61
108
  return schema
62
109
 
63
110
 
@@ -24,12 +24,14 @@ Code Severity Meaning
24
24
  006 warning Verify step asserts nothing (no-op)
25
25
  007 error Body ``contains``/``not_contains`` list the same substring
26
26
  008 error Body ``matches``/``not_matches`` list the same pattern
27
+ 009 warning Saved variable is shadowed by a scenario-level fixture
27
28
  010 error File not found
28
29
  011 error Path is not a file
29
30
  012 error ``$ref`` resolution failed
30
31
  013 warning File extension is not ``.json``
31
32
  014 error Invalid JSON syntax
32
33
  015 error Failed to parse JSON file
34
+ 016 error Fixture referenced in a scenario-level template
33
35
  020 warning Referenced file does not exist (deep, opt-in)
34
36
  021 warning Schema file is not valid JSON / not a valid schema (deep)
35
37
  022 warning User function cannot be imported (deep)
@@ -70,12 +72,14 @@ class DiagnosticCode:
70
72
  NOOP_VERIFY = "HTTPCHAIN006"
71
73
  CONTAINS_CONTRADICTION = "HTTPCHAIN007"
72
74
  MATCHES_CONTRADICTION = "HTTPCHAIN008"
75
+ FIXTURE_SHADOWS_SAVE = "HTTPCHAIN009"
73
76
  FILE_NOT_FOUND = "HTTPCHAIN010"
74
77
  NOT_A_FILE = "HTTPCHAIN011"
75
78
  REF_ERROR = "HTTPCHAIN012"
76
79
  WRONG_EXTENSION = "HTTPCHAIN013"
77
80
  INVALID_JSON = "HTTPCHAIN014"
78
81
  PARSE_ERROR = "HTTPCHAIN015"
82
+ FIXTURE_IN_SCENARIO_TEMPLATE = "HTTPCHAIN016"
79
83
  # Deep (opt-in) checks: imports, signatures, referenced files.
80
84
  REFERENCED_FILE_NOT_FOUND = "HTTPCHAIN020"
81
85
  SCHEMA_FILE_INVALID = "HTTPCHAIN021"
@@ -221,7 +225,7 @@ def stage_defined_names(stage: Stage) -> set[str]:
221
225
  return names
222
226
 
223
227
 
224
- def extract_defined_variables(scenario: Scenario, test_data: dict[str, Any]) -> set[str]:
228
+ def extract_defined_variables(scenario: Scenario) -> set[str]:
225
229
  """Extract variable names made available before/within templates (scenario-wide).
226
230
 
227
231
  Sources: ``vars`` and ``functions`` substitutions (scenario- and stage-level),
@@ -232,10 +236,6 @@ def extract_defined_variables(scenario: Scenario, test_data: dict[str, Any]) ->
232
236
  """
233
237
  defined_vars: set[str] = set()
234
238
 
235
- # Defensive: a top-level "vars" key is not a model field but is tolerated.
236
- if isinstance(test_data.get("vars"), dict):
237
- defined_vars.update(k for k in test_data["vars"] if isinstance(k, str))
238
-
239
239
  defined_vars |= substitution_names(scenario.substitutions)
240
240
 
241
241
  for stage in scenario.stages:
@@ -283,13 +283,16 @@ def _dataflow_diagnostics(scenario: Scenario, test_data: dict[str, Any]) -> list
283
283
  Walks stages in execution order tracking which variables are available at
284
284
  each reference site, mirroring the runtime scoping in ``carrier.py``:
285
285
 
286
- * scenario-level substitutions (and a tolerated top-level ``vars``) are
287
- available everywhere;
286
+ * scenario-level substitutions and scenario-level fixtures are available
287
+ everywhere;
288
288
  * a stage's own substitutions, parametrize/foreach parameters and fixtures
289
289
  are available to that stage's request and response;
290
290
  * a value saved in a stage's response is available to that stage's response
291
291
  and to *later* stages — but never to the same stage's request, and never
292
- to earlier stages.
292
+ to earlier stages;
293
+ * ``always_run`` is evaluated at stage start, before stage substitutions are
294
+ processed and before any iteration runs: only fixtures, parametrize
295
+ parameters, scenario substitutions and earlier saves are in scope.
293
296
 
294
297
  A reference that is unavailable is reported as :data:`DiagnosticCode.FORWARD_REF`
295
298
  if the name is saved somewhere later (an ordering bug) or
@@ -309,14 +312,9 @@ def _dataflow_diagnostics(scenario: Scenario, test_data: dict[str, Any]) -> list
309
312
  # fixtures, no stage substitutions, no parameter names, no saved values exist
310
313
  # yet. ``scenario_scope`` is that narrow set; ``scenario_available`` is the
311
314
  # everywhere-available set used for ordinary request/response references.
312
- scenario_scope: set[str] = set()
313
- if isinstance(test_data.get("vars"), dict):
314
- scenario_scope |= {k for k in test_data["vars"] if isinstance(k, str)}
315
- scenario_scope |= substitution_names(scenario.substitutions)
315
+ scenario_scope: set[str] = set(substitution_names(scenario.substitutions))
316
316
 
317
- scenario_available = set(scenario_scope)
318
- if isinstance(test_data.get("fixtures"), list):
319
- scenario_available |= {f for f in test_data["fixtures"] if isinstance(f, str)}
317
+ scenario_available = scenario_scope | set(scenario.fixtures)
320
318
 
321
319
  raws = raw_stages(test_data)
322
320
  cumulative_saves: set[str] = set()
@@ -347,6 +345,31 @@ def _dataflow_diagnostics(scenario: Scenario, test_data: dict[str, Any]) -> list
347
345
  )
348
346
  )
349
347
 
348
+ # ``always_run`` resolves at stage start (carrier.execute_stage) against
349
+ # fixtures + parametrize parameters + global context — stage substitutions
350
+ # and foreach parameters don't exist yet, and neither do this stage's saves.
351
+ always_run_available = scenario_available | set(stage.fixtures) | _parameter_names(stage.parametrize) | cumulative_saves
352
+ for name in sorted(extract_template_variables(raw.get("always_run"))):
353
+ if name in always_run_available:
354
+ continue
355
+ if name in all_saved:
356
+ j = first_save_stage[name]
357
+ if j == i:
358
+ msg = f"Stage '{stage.name}': always_run references '{name}', which is only saved in this stage's response — always_run is evaluated before the stage runs"
359
+ else:
360
+ msg = f"Stage '{stage.name}': always_run references '{name}' before it is saved (saved in stage '{scenario.stages[j].name}')"
361
+ diagnostics.append(_diag(DiagnosticCode.FORWARD_REF, "warning", msg, location=stage.name))
362
+ else:
363
+ diagnostics.append(
364
+ _diag(
365
+ DiagnosticCode.UNDEFINED_VAR,
366
+ "warning",
367
+ f"Stage '{stage.name}': always_run references '{name}' — potentially not in scope; only fixtures, "
368
+ f"parametrize parameters, scenario substitutions, and variables saved by earlier stages are available",
369
+ location=stage.name,
370
+ )
371
+ )
372
+
350
373
  undefined_here: set[str] = set()
351
374
 
352
375
  for refs, available, in_request in (
@@ -642,14 +665,12 @@ def check_scenario(scenario: Scenario, test_data: dict[str, Any]) -> tuple[list[
642
665
  )
643
666
  )
644
667
 
645
- fixtures: list[str] = []
646
- if isinstance(test_data.get("fixtures"), list):
647
- fixtures.extend(test_data["fixtures"])
668
+ fixtures: list[str] = list(scenario.fixtures)
648
669
  for stage in scenario.stages:
649
670
  fixtures.extend(stage.fixtures)
650
671
  fixtures = sorted(set(fixtures))
651
672
 
652
- vars_defined = extract_defined_variables(scenario, test_data)
673
+ vars_defined = extract_defined_variables(scenario)
653
674
  vars_saved = extract_saved_variables(scenario)
654
675
  vars_referenced = extract_template_variables(test_data)
655
676
 
@@ -663,6 +684,35 @@ def check_scenario(scenario: Scenario, test_data: dict[str, Any]) -> tuple[list[
663
684
  )
664
685
  )
665
686
 
687
+ # Scenario fixtures are injected into every stage and sit above the global
688
+ # context in the runtime ChainMap, so a save under the same name can never
689
+ # be read back — the fixture value always wins.
690
+ shadowed_saves = set(scenario.fixtures) & vars_saved
691
+ if shadowed_saves:
692
+ diagnostics.append(
693
+ _diag(
694
+ DiagnosticCode.FIXTURE_SHADOWS_SAVE,
695
+ "warning",
696
+ f"Saved variables shadowed by scenario-level fixtures: {sorted(shadowed_saves)} (fixture values win in every stage; these saves can never be read)",
697
+ )
698
+ )
699
+
700
+ # Scenario-level templates (substitutions/auth/ssl) resolve once at class
701
+ # creation (carrier.create_test_class), before any fixture exists — a fixture
702
+ # reference there is a guaranteed collection-time crash.
703
+ for key in ("substitutions", "auth", "ssl"):
704
+ scenario_level_refs = extract_template_variables(test_data.get(key))
705
+ fixture_refs = scenario_level_refs & set(fixtures)
706
+ if fixture_refs:
707
+ diagnostics.append(
708
+ _diag(
709
+ DiagnosticCode.FIXTURE_IN_SCENARIO_TEMPLATE,
710
+ "error",
711
+ f"Fixtures referenced in scenario-level '{key}' templates: {sorted(fixture_refs)} (scenario-level templates resolve at collection time, before fixtures exist)",
712
+ location=key,
713
+ )
714
+ )
715
+
666
716
  # NOTE: response data (response/status_code/body/json/text/headers/cookies) is
667
717
  # NOT ambient in {{ }} templates — it reaches save/verify handlers directly and
668
718
  # only enters the template context via an earlier `save` step. So there are no