pytest-httpchain 0.7.0__tar.gz → 0.8.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,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-httpchain
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: pytest plugin for HTTP testing using JSON files
5
- Keywords: testing,pytest,requests
5
+ Keywords: testing,pytest,httpx,http,api,integration-testing
6
6
  Author: Alexander Eresov
7
7
  Author-email: Alexander Eresov <aeresov@gmail.com>
8
8
  License-Expression: MIT
@@ -197,11 +197,21 @@ pip install 'git+https://github.com/aeresov/pytest-httpchain@main'
197
197
 
198
198
  - Test file discovery is based on this name pattern: `test_<name>.<suffix>.json`.
199
199
  The `suffix` is configurable as pytest ini option, default value is **http**.
200
- - `$ref` instructions can point to other files; absolute and relative paths are supported.
200
+ - `$ref` instructions can point to other files using relative paths; absolute paths are rejected for security.
201
201
  You can limit the depth of relative path traversal using `ref_parent_traversal_depth` ini option, default value is **3**.
202
202
  - Template expressions support list/dict comprehensions. You can limit the maximum comprehension length using `max_comprehension_length` ini option, default value is **50000**.
203
203
  - Parallel stage iterations (repeat/foreach) have a safety limit configurable via `max_parallel_iterations` ini option, default value is **10000**.
204
204
 
205
+ ### HAR export
206
+
207
+ Pass `--output-dir DIR` on the pytest command line to write an [HAR](http://www.softwareishard.com/blog/har-12-spec/) file (and a "HAR File" report section) capturing each test's HTTP traffic:
208
+
209
+ ```bash
210
+ pytest --output-dir ./har-output
211
+ ```
212
+
213
+ HAR files contain full requests/responses **including credential headers and saved tokens** — nothing is redacted, so scrub them before sharing. See the [HAR export docs](https://aeresov.github.io/pytest-httpchain/getting-started/#har-export).
214
+
205
215
  ## AI agent support
206
216
 
207
217
  `pytest-httpchain` ships a scenario validator to help AI coding agents (and humans) author and check test scenarios.
@@ -241,7 +251,7 @@ A JSON Schema is published for as-you-type validation and autocomplete. Referenc
241
251
  The hosted schema tracks the latest release; to pin the schema matching your installed version (e.g. for CI), emit it locally:
242
252
 
243
253
  ```bash
244
- uvx pytest-httpchain schema --output scenario.schema.json
254
+ uvx pytest-httpchain schema > scenario.schema.json
245
255
  ```
246
256
 
247
257
  ### Inspecting scenarios
@@ -164,11 +164,21 @@ pip install 'git+https://github.com/aeresov/pytest-httpchain@main'
164
164
 
165
165
  - Test file discovery is based on this name pattern: `test_<name>.<suffix>.json`.
166
166
  The `suffix` is configurable as pytest ini option, default value is **http**.
167
- - `$ref` instructions can point to other files; absolute and relative paths are supported.
167
+ - `$ref` instructions can point to other files using relative paths; absolute paths are rejected for security.
168
168
  You can limit the depth of relative path traversal using `ref_parent_traversal_depth` ini option, default value is **3**.
169
169
  - Template expressions support list/dict comprehensions. You can limit the maximum comprehension length using `max_comprehension_length` ini option, default value is **50000**.
170
170
  - Parallel stage iterations (repeat/foreach) have a safety limit configurable via `max_parallel_iterations` ini option, default value is **10000**.
171
171
 
172
+ ### HAR export
173
+
174
+ Pass `--output-dir DIR` on the pytest command line to write an [HAR](http://www.softwareishard.com/blog/har-12-spec/) file (and a "HAR File" report section) capturing each test's HTTP traffic:
175
+
176
+ ```bash
177
+ pytest --output-dir ./har-output
178
+ ```
179
+
180
+ HAR files contain full requests/responses **including credential headers and saved tokens** — nothing is redacted, so scrub them before sharing. See the [HAR export docs](https://aeresov.github.io/pytest-httpchain/getting-started/#har-export).
181
+
172
182
  ## AI agent support
173
183
 
174
184
  `pytest-httpchain` ships a scenario validator to help AI coding agents (and humans) author and check test scenarios.
@@ -208,7 +218,7 @@ A JSON Schema is published for as-you-type validation and autocomplete. Referenc
208
218
  The hosted schema tracks the latest release; to pin the schema matching your installed version (e.g. for CI), emit it locally:
209
219
 
210
220
  ```bash
211
- uvx pytest-httpchain schema --output scenario.schema.json
221
+ uvx pytest-httpchain schema > scenario.schema.json
212
222
  ```
213
223
 
214
224
  ### Inspecting scenarios
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pytest-httpchain"
3
- version = "0.7.0"
3
+ version = "0.8.0"
4
4
  description = "pytest plugin for HTTP testing using JSON files"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -21,7 +21,7 @@ dependencies = [
21
21
  "simpleeval>=1.0.3",
22
22
  "typer>=0.16.0",
23
23
  ]
24
- keywords = ["testing", "pytest", "requests"]
24
+ keywords = ["testing", "pytest", "httpx", "http", "api", "integration-testing"]
25
25
  license = "MIT"
26
26
  license-files = ["LICENSE"]
27
27
  classifiers = [
@@ -155,9 +155,10 @@ class Carrier:
155
155
 
156
156
  total = len(iteration_substitutions)
157
157
  if total == 0:
158
- # Unreachable via validated models (foreach values have min_length=1,
159
- # repeat is PositiveInt), but guard so a future gap fails loudly
160
- # instead of passing a stage that never sent a request.
158
+ # The models reject the static empty cases (foreach/combinations
159
+ # have min_length=1, repeat is PositiveInt), but a template- or
160
+ # $ref-sourced parallel config can still resolve to empty at
161
+ # runtime, so guard rather than silently send zero requests.
161
162
  raise StageExecutionError("Parallel configuration produced zero iterations; foreach/repeat must yield at least one item")
162
163
  if total > cls.max_parallel_iterations:
163
164
  raise StageExecutionError(
@@ -543,6 +544,19 @@ class Carrier:
543
544
  cls.client = None
544
545
 
545
546
 
547
+ def _normalize_cert(cert: Any) -> str | tuple[str, ...]:
548
+ """Stringify SSL client-cert paths for httpx.
549
+
550
+ The model stores ``cert`` as ``pathlib.Path`` (single) or a tuple of Paths.
551
+ httpx builds the SSL context via ``load_cert_chain(*cert)`` for a non-tuple
552
+ cert, so a bare ``Path`` is unpacked and raises ``TypeError``. Passing string
553
+ paths avoids that for both the single-path and (cert, key) tuple forms.
554
+ """
555
+ if isinstance(cert, list | tuple):
556
+ return tuple(str(p) for p in cert)
557
+ return str(cert)
558
+
559
+
546
560
  def create_test_class(scenario: Scenario, class_name: str, max_parallel_iterations: int = 10_000) -> type[Carrier]:
547
561
  """Create a dynamic test class from a scenario definition."""
548
562
  scenario_context = process_substitutions(scenario.substitutions)
@@ -553,7 +567,7 @@ def create_test_class(scenario: Scenario, class_name: str, max_parallel_iteratio
553
567
  "http2": True,
554
568
  }
555
569
  if scenario.ssl.cert is not None:
556
- client_kwargs["cert"] = resolved_ssl.cert
570
+ client_kwargs["cert"] = _normalize_cert(resolved_ssl.cert)
557
571
  if scenario.auth:
558
572
  resolved_auth = walk(scenario.auth, scenario_context)
559
573
  auth_result = call_user_function(resolved_auth)
@@ -28,23 +28,6 @@ class GraphDirection(enum.StrEnum):
28
28
  RefParentTraversalDepth = Annotated[int, typer.Option(help="Maximum $ref parent directory traversal depth.")]
29
29
 
30
30
 
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
31
  @app.callback()
49
32
  def main() -> None:
50
33
  """pytest-httpchain command-line tools."""
@@ -99,24 +82,23 @@ def validate(
99
82
 
100
83
 
101
84
  @app.command()
102
- def schema(
103
- output: Annotated[Path | None, typer.Option("--output", "-o", help="Write schema to PATH instead of stdout.")] = None,
104
- ) -> None:
105
- """Emit the JSON Schema for scenario files (editor autocomplete/validation)."""
85
+ def schema() -> None:
86
+ """Emit the JSON Schema for scenario files (editor autocomplete/validation).
87
+
88
+ Writes to stdout; redirect to a file (``pytest-httpchain schema > scenario.schema.json``).
89
+ """
106
90
  from pytest_httpchain.schema import build_schema
107
91
 
108
- text = json.dumps(build_schema(), indent=2, default=str)
109
- _emit(text, output, "schema")
92
+ typer.echo(json.dumps(build_schema(), indent=2, default=str))
110
93
 
111
94
 
112
95
  @app.command()
113
96
  def resolve(
114
97
  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
98
  ref_parent_traversal_depth: RefParentTraversalDepth = 3,
117
99
  root_path: Annotated[Path | None, typer.Option("--root-path", help="Directory that constrains $ref resolution (default: nearest tests/ ancestor).")] = None,
118
100
  ) -> None:
119
- """Resolve $ref/$include/$merge and print the merged scenario JSON."""
101
+ """Resolve $ref/$include/$merge and print the merged scenario JSON to stdout."""
120
102
  from pytest_httpchain_jsonref import ReferenceResolverError, load_json
121
103
 
122
104
  from pytest_httpchain.validation import resolve_root_path
@@ -131,8 +113,7 @@ def resolve(
131
113
  typer.echo(f"error: {e}", err=True)
132
114
  raise typer.Exit(1) from e
133
115
 
134
- text = json.dumps(data, indent=2, default=str)
135
- _emit(text, output, "resolved scenario")
116
+ typer.echo(json.dumps(data, indent=2, default=str))
136
117
 
137
118
 
138
119
  def _load_for_inspection(path: Path, depth: int, root_path: Path | None = None) -> tuple["Scenario", dict]:
@@ -165,26 +165,33 @@ def pytest_addoption(parser: pytest.Parser) -> None:
165
165
 
166
166
 
167
167
  def pytest_configure(config: pytest.Config) -> None:
168
- # Numeric options are registered with type="int", so getini returns an int and a
169
- # non-int value in the ini is reported by pytest as a clean usage error. Range
170
- # checks below raise pytest.UsageError (not bare ValueError, which pytest renders
171
- # as an INTERNALERROR with traceback).
168
+ # Numeric options are registered with type="int", but pytest performs the
169
+ # int() conversion with a bare int(value) that raises ValueError for a
170
+ # non-integer ini value — which pytest renders as an INTERNALERROR traceback.
171
+ # Wrap the read so a garbage value becomes a clean usage error; the range
172
+ # checks below likewise raise pytest.UsageError.
173
+ def _getint(name: str) -> int:
174
+ try:
175
+ return config.getini(name)
176
+ except ValueError as e:
177
+ raise pytest.UsageError(f"{name} must be an integer: {e}") from None
178
+
172
179
  suffix = str(config.getini(ConfigOptions.SUFFIX))
173
180
  if not re.match(r"^[a-zA-Z0-9_-]{1,32}$", suffix):
174
181
  raise pytest.UsageError("suffix must contain only alphanumeric characters, underscores, hyphens, and be ≤32 chars")
175
182
 
176
- ref_parent_traversal_depth = config.getini(ConfigOptions.REF_PARENT_TRAVERSAL_DEPTH)
183
+ ref_parent_traversal_depth = _getint(ConfigOptions.REF_PARENT_TRAVERSAL_DEPTH)
177
184
  if ref_parent_traversal_depth < 0:
178
185
  raise pytest.UsageError("ref_parent_traversal_depth must be non-negative")
179
186
 
180
- max_comprehension_length = config.getini(ConfigOptions.MAX_COMPREHENSION_LENGTH)
187
+ max_comprehension_length = _getint(ConfigOptions.MAX_COMPREHENSION_LENGTH)
181
188
  if max_comprehension_length < 1:
182
189
  raise pytest.UsageError("max_comprehension_length must be a positive integer")
183
190
  if max_comprehension_length > 1_000_000:
184
191
  raise pytest.UsageError("max_comprehension_length must not exceed 1,000,000")
185
192
  simpleeval.MAX_COMPREHENSION_LENGTH = max_comprehension_length # ty: ignore[invalid-assignment]
186
193
 
187
- max_parallel_iterations = config.getini(ConfigOptions.MAX_PARALLEL_ITERATIONS)
194
+ max_parallel_iterations = _getint(ConfigOptions.MAX_PARALLEL_ITERATIONS)
188
195
  if max_parallel_iterations < 1:
189
196
  raise pytest.UsageError("max_parallel_iterations must be a positive integer")
190
197
  if max_parallel_iterations > 1_000_000:
@@ -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)
@@ -64,7 +67,7 @@ from pytest_httpchain_models import (
64
67
  Verify,
65
68
  VerifyStep,
66
69
  )
67
- from pytest_httpchain_templates import TEMPLATE_BUILTINS, TEMPLATE_PATTERN
70
+ from pytest_httpchain_templates import TEMPLATE_BUILTINS, TEMPLATE_PATTERN, is_complete_template
68
71
 
69
72
  Severity = Literal["error", "warning"]
70
73
 
@@ -90,6 +93,8 @@ class DiagnosticCode:
90
93
  PARSE_ERROR = "HTTPCHAIN015"
91
94
  FIXTURE_IN_SCENARIO_TEMPLATE = "HTTPCHAIN016"
92
95
  SCENARIO_UNDEFINED_VAR = "HTTPCHAIN017"
96
+ NONTEMPLATE_EXPRESSION = "HTTPCHAIN018"
97
+ INVALID_MARKER = "HTTPCHAIN019"
93
98
  # Deep (opt-in) checks: imports, signatures, referenced files.
94
99
  REFERENCED_FILE_NOT_FOUND = "HTTPCHAIN020"
95
100
  SCHEMA_FILE_INVALID = "HTTPCHAIN021"
@@ -453,6 +458,21 @@ def _verify_diagnostics(scenario: Scenario) -> list[Diagnostic]:
453
458
  )
454
459
  )
455
460
 
461
+ # A verify expression is meant to be a complete ``{{ }}`` template that
462
+ # evaluates to a truthy/falsy value. A plain string (e.g. a forgotten
463
+ # ``{{ }}``) is non-empty and therefore always truthy at runtime, so the
464
+ # assertion silently passes — it tests nothing.
465
+ for expr in verify.expressions:
466
+ if isinstance(expr, str) and not is_complete_template(expr):
467
+ diagnostics.append(
468
+ _diag(
469
+ DiagnosticCode.NONTEMPLATE_EXPRESSION,
470
+ "warning",
471
+ f"Stage '{stage.name}': verify expression {expr!r} is not a template ({{{{ }}}}); it is always truthy and asserts nothing",
472
+ location=location,
473
+ )
474
+ )
475
+
456
476
  # Overlap is compared on the raw (unrendered) strings: identical
457
477
  # entries — including identical templates — are caught. A contradiction
458
478
  # that only emerges after rendering (e.g. a template that resolves to a
@@ -483,6 +503,47 @@ def _verify_diagnostics(scenario: Scenario) -> list[Diagnostic]:
483
503
  return diagnostics
484
504
 
485
505
 
506
+ def _marker_diagnostics(scenario: Scenario) -> list[Diagnostic]:
507
+ """Validate scenario- and stage-level pytest marker expressions.
508
+
509
+ Markers are parsed by ``make_marker`` only at collection time, so a malformed
510
+ marker (``skip(``) or an unsupported form (``foo.bar``) would pass ``validate``
511
+ yet crash collection. Run the same parser here so the validator catches it as
512
+ an error, keeping ``validate`` a faithful pre-flight check of what collection
513
+ will accept.
514
+ """
515
+ import warnings
516
+
517
+ from pytest_httpchain.utils import make_marker
518
+
519
+ diagnostics: list[Diagnostic] = []
520
+
521
+ def _check(marks: list[str], location: str) -> None:
522
+ for mark in marks:
523
+ try:
524
+ # We only care whether the marker parses; constructing it would
525
+ # otherwise emit PytestUnknownMarkWarning for custom (unregistered)
526
+ # marks, which is noise during validation.
527
+ with warnings.catch_warnings():
528
+ warnings.simplefilter("ignore")
529
+ make_marker(mark)
530
+ except (ValueError, SyntaxError) as e:
531
+ diagnostics.append(
532
+ _diag(
533
+ DiagnosticCode.INVALID_MARKER,
534
+ "error",
535
+ f"Invalid marker {mark!r}: {e}",
536
+ location=location,
537
+ )
538
+ )
539
+
540
+ _check(scenario.marks, "marks")
541
+ for i, stage in enumerate(scenario.stages):
542
+ _check(stage.marks, f"stages[{i}].marks")
543
+
544
+ return diagnostics
545
+
546
+
486
547
  # --------------------------------------------------------------------------- #
487
548
  # Deep (opt-in) validation: imports, signatures, referenced files.
488
549
  # These touch the filesystem and import user code, so they NEVER run at
@@ -829,6 +890,8 @@ def check_scenario(scenario: Scenario, test_data: dict[str, Any]) -> tuple[list[
829
890
 
830
891
  diagnostics.extend(_verify_diagnostics(scenario))
831
892
 
893
+ diagnostics.extend(_marker_diagnostics(scenario))
894
+
832
895
  scenario_info = ScenarioInfo(
833
896
  num_stages=len(scenario.stages),
834
897
  stage_names=stage_names,
@@ -919,7 +982,14 @@ def validate_scenario(
919
982
  root_path=root_path,
920
983
  )
921
984
  except ReferenceResolverError as e:
922
- diagnostics.append(_diag(DiagnosticCode.REF_ERROR, "error", f"JSON reference resolution error: {e}"))
985
+ # The resolver wraps a plain JSON syntax error as a ReferenceResolverError
986
+ # (chaining the JSONDecodeError as __cause__). Report those under the
987
+ # accurate "Invalid JSON syntax" code rather than the $ref-flavored one,
988
+ # which would mislead when no reference is involved.
989
+ if isinstance(e.__cause__, json.JSONDecodeError):
990
+ diagnostics.append(_diag(DiagnosticCode.INVALID_JSON, "error", f"Invalid JSON syntax: {e.__cause__}"))
991
+ else:
992
+ diagnostics.append(_diag(DiagnosticCode.REF_ERROR, "error", f"JSON reference resolution error: {e}"))
923
993
  return _result(diagnostics)
924
994
  except json.JSONDecodeError as e:
925
995
  diagnostics.append(_diag(DiagnosticCode.INVALID_JSON, "error", f"Invalid JSON syntax: {e}"))