pytest-httpchain 0.2.4__tar.gz → 0.3.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.2.4
3
+ Version: 0.3.0
4
4
  Summary: pytest plugin for HTTP testing using JSON files
5
5
  Keywords: testing,pytest,requests
6
6
  Author: Alexander Eresov
@@ -18,8 +18,8 @@ Requires-Dist: httpx[http2]>=0.27.0
18
18
  Requires-Dist: pydantic>=2.11.7
19
19
  Requires-Dist: pyrate-limiter>=3.0.0
20
20
  Requires-Dist: pytest-httpchain-jsonref
21
- Requires-Dist: pytest-httpchain-mcp
22
21
  Requires-Dist: pytest-httpchain-models
22
+ Requires-Dist: pytest-httpchain-templates
23
23
  Requires-Dist: pytest-order>=1.3.0
24
24
  Requires-Dist: typer>=0.16.0
25
25
  Requires-Python: >=3.13, <4.0
@@ -196,37 +196,39 @@ pip install 'git+https://github.com/aeresov/pytest-httpchain@main'
196
196
  - Template expressions support list/dict comprehensions. You can limit the maximum comprehension length using `max_comprehension_length` ini option, default value is **50000**.
197
197
  - Parallel stage iterations (repeat/foreach) have a safety limit configurable via `max_parallel_iterations` ini option, default value is **10000**.
198
198
 
199
- ## MCP Server
199
+ ## AI agent support
200
200
 
201
- `pytest-httpchain` includes an MCP (Model Context Protocol) server to aid AI code assistants.
201
+ `pytest-httpchain` ships tooling to help AI coding agents (and humans) author and check test scenarios.
202
202
 
203
- ### Setup
203
+ ### Claude Code skill
204
204
 
205
- Install the MCP server config and Claude Code skill into your project:
205
+ Install the authoring skill into your project (or `--global` for personal scope):
206
206
 
207
207
  ```bash
208
- uvx pytest-httpchain install --skill --mcp
208
+ uvx pytest-httpchain install
209
209
  ```
210
210
 
211
- Or configure manually in `.mcp.json`:
211
+ This writes `.claude/skills/pytest-httpchain/SKILL.md` with guidance for writing scenarios.
212
212
 
213
- ```json
214
- {
215
- "mcpServers": {
216
- "pytest-httpchain": {
217
- "command": "uvx",
218
- "args": ["pytest-httpchain", "mcp"]
219
- }
220
- }
221
- }
213
+ ### Scenario validation
214
+
215
+ Validate scenario files for structure and common problems — undefined variables, duplicate stage names, fixture/variable conflicts, stages with no assertions:
216
+
217
+ ```bash
218
+ uvx pytest-httpchain validate tests/test_login.http.json
222
219
  ```
223
220
 
224
- ### Features
221
+ It exits non-zero when any file is invalid, so it doubles as a CI gate. The same checks also run automatically at **pytest collection time** — semantic errors fail collection and warnings are reported — so `pytest --collect-only` validates every scenario in your suite.
222
+
223
+ ### Editor schema
225
224
 
226
- The MCP server provides:
225
+ A JSON Schema is published for as-you-type validation and autocomplete. Reference it from your test files:
227
226
 
228
- - **Scenario validation** - validate test scenario and scan for possible problems
229
- - **Claude Code skill** - authoring guidance for writing test scenarios
227
+ ```json
228
+ {
229
+ "$schema": "https://aeresov.github.io/pytest-httpchain/schema/scenario.schema.json"
230
+ }
231
+ ```
230
232
 
231
233
  ## Documentation
232
234
 
@@ -169,37 +169,39 @@ pip install 'git+https://github.com/aeresov/pytest-httpchain@main'
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
- ## MCP Server
172
+ ## AI agent support
173
173
 
174
- `pytest-httpchain` includes an MCP (Model Context Protocol) server to aid AI code assistants.
174
+ `pytest-httpchain` ships tooling to help AI coding agents (and humans) author and check test scenarios.
175
175
 
176
- ### Setup
176
+ ### Claude Code skill
177
177
 
178
- Install the MCP server config and Claude Code skill into your project:
178
+ Install the authoring skill into your project (or `--global` for personal scope):
179
179
 
180
180
  ```bash
181
- uvx pytest-httpchain install --skill --mcp
181
+ uvx pytest-httpchain install
182
182
  ```
183
183
 
184
- Or configure manually in `.mcp.json`:
184
+ This writes `.claude/skills/pytest-httpchain/SKILL.md` with guidance for writing scenarios.
185
185
 
186
- ```json
187
- {
188
- "mcpServers": {
189
- "pytest-httpchain": {
190
- "command": "uvx",
191
- "args": ["pytest-httpchain", "mcp"]
192
- }
193
- }
194
- }
186
+ ### Scenario validation
187
+
188
+ Validate scenario files for structure and common problems — undefined variables, duplicate stage names, fixture/variable conflicts, stages with no assertions:
189
+
190
+ ```bash
191
+ uvx pytest-httpchain validate tests/test_login.http.json
195
192
  ```
196
193
 
197
- ### Features
194
+ It exits non-zero when any file is invalid, so it doubles as a CI gate. The same checks also run automatically at **pytest collection time** — semantic errors fail collection and warnings are reported — so `pytest --collect-only` validates every scenario in your suite.
195
+
196
+ ### Editor schema
198
197
 
199
- The MCP server provides:
198
+ A JSON Schema is published for as-you-type validation and autocomplete. Reference it from your test files:
200
199
 
201
- - **Scenario validation** - validate test scenario and scan for possible problems
202
- - **Claude Code skill** - authoring guidance for writing test scenarios
200
+ ```json
201
+ {
202
+ "$schema": "https://aeresov.github.io/pytest-httpchain/schema/scenario.schema.json"
203
+ }
204
+ ```
203
205
 
204
206
  ## Documentation
205
207
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pytest-httpchain"
3
- version = "0.2.4"
3
+ version = "0.3.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"
@@ -10,8 +10,8 @@ dependencies = [
10
10
  "pydantic>=2.11.7",
11
11
  "pyrate-limiter>=3.0.0",
12
12
  "pytest-httpchain-jsonref",
13
- "pytest-httpchain-mcp",
14
13
  "pytest-httpchain-models",
14
+ "pytest-httpchain-templates",
15
15
  "pytest-order>=1.3.0",
16
16
  "typer>=0.16.0",
17
17
  ]
@@ -54,7 +54,6 @@ members = ["packages/*"]
54
54
  pytest-httpchain-core = { workspace = true }
55
55
  pytest-httpchain-jsonref = { workspace = true }
56
56
  pytest-httpchain-templates = { workspace = true }
57
- pytest-httpchain-mcp = { workspace = true }
58
57
  pytest-httpchain-models = { workspace = true }
59
58
  pytest-httpchain-userfunc = { workspace = true }
60
59
 
@@ -103,7 +102,6 @@ pythonpath = [
103
102
  "packages/pytest-httpchain-core/src",
104
103
  "packages/pytest-httpchain-jsonref/src",
105
104
  "packages/pytest-httpchain-templates/src",
106
- "packages/pytest-httpchain-mcp/src",
107
105
  "packages/pytest-httpchain-models/src",
108
106
  "packages/pytest-httpchain-userfunc/src",
109
107
  ]
@@ -124,7 +122,6 @@ source = [
124
122
  "packages/pytest-httpchain-core/src",
125
123
  "packages/pytest-httpchain-jsonref/src",
126
124
  "packages/pytest-httpchain-templates/src",
127
- "packages/pytest-httpchain-mcp/src",
128
125
  "packages/pytest-httpchain-models/src",
129
126
  "packages/pytest-httpchain-userfunc/src",
130
127
  ]
@@ -180,7 +180,7 @@ class Carrier:
180
180
  cls.last_request = exc.request
181
181
  if exc.response is not None:
182
182
  cls.last_response = exc.response
183
- raise StageExecutionError(f"Parallel execution failed at iteration {idx}: {exc}")
183
+ raise StageExecutionError(f"Parallel execution failed at iteration {idx}: {exc}") from exc
184
184
 
185
185
  except (
186
186
  TemplatesError,
@@ -199,7 +199,7 @@ class Carrier:
199
199
  "method": request_model.method,
200
200
  "url": str(request_model.url),
201
201
  "headers": request_model.headers,
202
- "params": request_model.params,
202
+ "params": request_model.params or None,
203
203
  "timeout": request_model.timeout,
204
204
  "follow_redirects": request_model.allow_redirects,
205
205
  }
@@ -209,7 +209,7 @@ class Carrier:
209
209
  auth_result = call_user_function(request_model.auth)
210
210
  request_kwargs["auth"] = auth_result
211
211
  except UserFunctionError as e:
212
- raise RequestError(f"Failed to configure authentication: {str(e)}") from None
212
+ raise RequestError(f"Failed to configure authentication: {e}") from None
213
213
 
214
214
  match request_model.body:
215
215
  case None:
@@ -233,19 +233,16 @@ class Carrier:
233
233
 
234
234
  case BinaryBody(binary=file_path):
235
235
  try:
236
- with open(file_path, "rb") as f:
237
- binary_data = f.read()
238
- request_kwargs["content"] = binary_data
236
+ request_kwargs["content"] = Path(file_path).read_bytes()
239
237
  except FileNotFoundError:
240
238
  raise RequestError(f"Binary file not found: {file_path}") from None
241
239
 
242
240
  case FilesBody(files=file_paths):
243
241
  files_list = []
244
242
  for field_name, file_path in file_paths.items():
243
+ path = Path(file_path)
245
244
  try:
246
- with open(file_path, "rb") as f:
247
- file_content = f.read()
248
- files_list.append((field_name, (Path(file_path).name, file_content)))
245
+ files_list.append((field_name, (path.name, path.read_bytes())))
249
246
  except FileNotFoundError:
250
247
  raise RequestError(f"File not found for upload: {file_path}") from None
251
248
  request_kwargs["files"] = files_list
@@ -257,13 +254,13 @@ class Carrier:
257
254
  try:
258
255
  return cls.client.request(**request_kwargs)
259
256
  except httpx.TimeoutException as e:
260
- raise RequestError(f"HTTP request timed out: {str(e)}") from None
257
+ raise RequestError(f"HTTP request timed out: {e}") from None
261
258
  except httpx.ConnectError as e:
262
- raise RequestError(f"HTTP connection error: {str(e)}") from None
259
+ raise RequestError(f"HTTP connection error: {e}") from None
263
260
  except httpx.HTTPError as e:
264
- raise RequestError(f"HTTP request failed: {str(e)}") from None
261
+ raise RequestError(f"HTTP request failed: {e}") from None
265
262
  except Exception as e:
266
- raise RequestError(f"Unexpected error: {str(e)}") from None
263
+ raise RequestError(f"Unexpected error: {e}") from None
267
264
 
268
265
  @staticmethod
269
266
  def _process_save_step(save_model: Save, response: httpx.Response, context: ChainMap[str, Any]) -> dict[str, Any]:
@@ -274,21 +271,21 @@ class Carrier:
274
271
  try:
275
272
  response_json = response.json()
276
273
  except (json.JSONDecodeError, UnicodeDecodeError) as e:
277
- raise SaveError(f"Cannot extract variables, response is not valid JSON: {str(e)}") from None
274
+ raise SaveError(f"Cannot extract variables, response is not valid JSON: {e}") from None
278
275
 
279
276
  for var_name, jmespath_expr in save_model.jmespath.items():
280
277
  try:
281
278
  saved_value = jmespath.search(jmespath_expr, response_json)
282
279
  step_saved[var_name] = saved_value
283
280
  except jmespath.exceptions.JMESPathError as e:
284
- raise SaveError(f"Error saving variable {var_name}: {str(e)}") from None
281
+ raise SaveError(f"Error saving variable {var_name}: {e}") from None
285
282
 
286
283
  case SubstitutionsSave():
287
284
  try:
288
285
  substitution_result = process_substitutions(save_model.substitutions, context)
289
286
  step_saved.update(substitution_result)
290
287
  except TemplatesError as e:
291
- raise SaveError(f"Error processing substitutions: {str(e)}") from None
288
+ raise SaveError(f"Error processing substitutions: {e}") from None
292
289
 
293
290
  case UserFunctionsSave():
294
291
  for func_item in save_model.user_functions:
@@ -298,12 +295,11 @@ class Carrier:
298
295
  if not isinstance(func_result, dict):
299
296
  raise SaveError(f"Save function must return dict, got {type(func_result).__name__}")
300
297
 
301
- result_dict = cast(dict[str, Any], func_result)
302
- step_saved.update(result_dict)
298
+ step_saved.update(func_result)
303
299
  except SaveError:
304
300
  raise
305
301
  except UserFunctionError as e:
306
- raise SaveError(f"Error calling user function '{func_item}': {str(e)}") from None
302
+ raise SaveError(f"Error calling user function '{func_item}': {e}") from None
307
303
 
308
304
  return step_saved
309
305
 
@@ -333,7 +329,7 @@ class Carrier:
333
329
  except VerificationError:
334
330
  raise
335
331
  except UserFunctionError as e:
336
- raise VerificationError(f"Error calling user function '{func_item}': {str(e)}") from None
332
+ raise VerificationError(f"Error calling user function '{func_item}': {e}") from None
337
333
 
338
334
  if verify_model.body.schema:
339
335
  schema = verify_model.body.schema
@@ -343,21 +339,21 @@ class Carrier:
343
339
  schema = json.loads(schema_path.read_text())
344
340
  check_json_schema(schema)
345
341
  except (OSError, json.JSONDecodeError) as e:
346
- raise VerificationError(f"Error reading body schema file '{schema_path}': {str(e)}") from None
342
+ raise VerificationError(f"Error reading body schema file '{schema_path}': {e}") from None
347
343
  except jsonschema.SchemaError as e:
348
344
  raise VerificationError(f"Invalid JSON Schema in file '{schema_path}': {e}") from None
349
345
 
350
346
  try:
351
347
  response_json = response.json()
352
348
  except (json.JSONDecodeError, UnicodeDecodeError) as e:
353
- raise VerificationError(f"Cannot validate schema, response is not valid JSON: {str(e)}") from None
349
+ raise VerificationError(f"Cannot validate schema, response is not valid JSON: {e}") from None
354
350
 
355
351
  try:
356
352
  jsonschema.validate(instance=response_json, schema=schema)
357
353
  except jsonschema.ValidationError as e:
358
- raise VerificationError(f"Body schema validation failed: {str(e)}") from None
354
+ raise VerificationError(f"Body schema validation failed: {e}") from None
359
355
  except jsonschema.SchemaError as e:
360
- raise VerificationError(f"Invalid body validation schema: {str(e)}") from None
356
+ raise VerificationError(f"Invalid body validation schema: {e}") from None
361
357
 
362
358
  for substring in verify_model.body.contains:
363
359
  if substring not in response.text:
@@ -438,7 +434,7 @@ class Carrier:
438
434
  try:
439
435
  ctx.__exit__(None, None, None)
440
436
  except Exception as e:
441
- logger.error(f"Error while cleaning up context manager fixture: {str(e)}")
437
+ logger.error(f"Error while cleaning up context manager fixture: {e}")
442
438
 
443
439
  if cls.client is not None:
444
440
  cls.client.close()
@@ -0,0 +1,59 @@
1
+ from pathlib import Path
2
+ from typing import Annotated
3
+
4
+ import typer
5
+
6
+ app = typer.Typer()
7
+
8
+ SKILL_FILE = Path(__file__).parent / "skill.md"
9
+
10
+
11
+ @app.command()
12
+ def validate(
13
+ paths: Annotated[list[Path], typer.Argument(help="Scenario JSON file(s) to validate.")],
14
+ ref_parent_traversal_depth: Annotated[int, typer.Option(help="Maximum $ref parent directory traversal depth.")] = 3,
15
+ ) -> None:
16
+ """Validate pytest-httpchain scenario file(s).
17
+
18
+ Prints errors and warnings per file and exits non-zero if any file is invalid.
19
+ """
20
+ from pytest_httpchain.validation import validate_scenario
21
+
22
+ all_valid = True
23
+ for path in paths:
24
+ result = validate_scenario(path, ref_parent_traversal_depth=ref_parent_traversal_depth)
25
+ if result.valid:
26
+ status = "OK with warnings" if result.warnings else "OK"
27
+ else:
28
+ status = "INVALID"
29
+ all_valid = False
30
+ typer.echo(f"{path}: {status}")
31
+ for error in result.errors:
32
+ typer.echo(f" error: {error}")
33
+ for warning in result.warnings:
34
+ typer.echo(f" warning: {warning}")
35
+
36
+ raise typer.Exit(0 if all_valid else 1)
37
+
38
+
39
+ @app.command()
40
+ def install(
41
+ global_: Annotated[bool, typer.Option("--global", "-g", help="Install to ~/.claude (personal scope) instead of project")] = False,
42
+ project_dir: Annotated[Path, typer.Option(help="Project directory (ignored with --global)")] = Path("."),
43
+ ) -> None:
44
+ """Install the Claude Code skill for authoring test scenarios."""
45
+ if global_:
46
+ _install_skill(Path.home() / ".claude" / "skills" / "pytest-httpchain")
47
+ else:
48
+ _install_skill(project_dir.resolve() / ".claude" / "skills" / "pytest-httpchain")
49
+
50
+
51
+ def _install_skill(skill_dir: Path) -> None:
52
+ skill_dir.mkdir(parents=True, exist_ok=True)
53
+ dest = skill_dir / "SKILL.md"
54
+ dest.write_text(SKILL_FILE.read_text())
55
+ typer.echo(f"Installed skill to {dest}")
56
+
57
+
58
+ if __name__ == "__main__":
59
+ app()
@@ -238,7 +238,6 @@ def write_har_file(
238
238
  entry = request_response_to_har_entry(request, response, started_datetime, elapsed_ms)
239
239
  har = create_har_log([entry], comment=f"Test: {test_name}")
240
240
 
241
- with open(filepath, "w", encoding="utf-8") as f:
242
- json.dump(har, f, indent=2, ensure_ascii=False)
241
+ filepath.write_text(json.dumps(har, indent=2, ensure_ascii=False), encoding="utf-8")
243
242
 
244
243
  return filepath
@@ -1,6 +1,7 @@
1
1
  import logging
2
2
  import re
3
3
  import types
4
+ import warnings
4
5
  from collections.abc import Iterable
5
6
  from pathlib import Path
6
7
  from typing import Any
@@ -19,10 +20,15 @@ from .carrier import Carrier, create_test_class
19
20
  from .har_writer import write_har_file
20
21
  from .report_formatter import format_request, format_response
21
22
  from .utils import make_marker
23
+ from .validation import check_scenario
22
24
 
23
25
  logger = logging.getLogger(__name__)
24
26
 
25
27
 
28
+ class ScenarioValidationWarning(pytest.PytestWarning):
29
+ """A collected scenario has a non-fatal validation issue (e.g. an undefined variable)."""
30
+
31
+
26
32
  class JsonModule(python.Module):
27
33
  """JSON test module that collects and executes HTTP chain tests.
28
34
 
@@ -42,9 +48,9 @@ class JsonModule(python.Module):
42
48
  root_path=root_path,
43
49
  )
44
50
  except pytest_httpchain_jsonref.ReferenceResolverError as e:
45
- raise nodes.Collector.CollectError(f"Cannot load JSON file {self.path}: {str(e)}") from None
51
+ raise nodes.Collector.CollectError(f"Cannot load JSON file {self.path}: {e}") from None
46
52
  except Exception as e:
47
- raise nodes.Collector.CollectError(f"Failed to parse JSON file {self.path}: {str(e)}") from None
53
+ raise nodes.Collector.CollectError(f"Failed to parse JSON file {self.path}: {e}") from None
48
54
 
49
55
  # validate general scenario structure
50
56
  try:
@@ -59,6 +65,15 @@ class JsonModule(python.Module):
59
65
  full_error_msg = f"Cannot parse test scenario in {self.path}:\n" + "\n".join(error_details)
60
66
  raise nodes.Collector.CollectError(full_error_msg) from None
61
67
 
68
+ # semantic validation: cross-cutting checks the schema cannot express
69
+ # (duplicate stage names, fixture/variable conflicts, undefined variables, ...)
70
+ semantic_errors, semantic_warnings, _ = check_scenario(scenario, test_data)
71
+ for warning in semantic_warnings:
72
+ warnings.warn(ScenarioValidationWarning(f"{self.path}: {warning}"), stacklevel=2)
73
+ if semantic_errors:
74
+ detail = "\n".join(f" - {e}" for e in semantic_errors)
75
+ raise nodes.Collector.CollectError(f"Invalid test scenario in {self.path}:\n{detail}")
76
+
62
77
  # generate python test class
63
78
  max_parallel_iterations = int(self.config.getini(ConfigOptions.MAX_PARALLEL_ITERATIONS))
64
79
  CarrierClass = create_test_class(scenario, self.name, max_parallel_iterations=max_parallel_iterations)
@@ -82,10 +82,6 @@ Response is a list of verify and save steps, executed in order:
82
82
  "verify": {
83
83
  "status": 200,
84
84
  "headers": { "content-type": "application/json" },
85
- "expressions": [
86
- "{{ user_count > 0 }}",
87
- "{{ 'error' not in body }}"
88
- ],
89
85
  "body": {
90
86
  "schema": { "type": "object", "required": ["id"] },
91
87
  "contains": ["expected text"],
@@ -103,10 +99,22 @@ Response is a list of verify and save steps, executed in order:
103
99
  "total": "length(items)"
104
100
  }
105
101
  }
102
+ },
103
+ {
104
+ "verify": {
105
+ "expressions": [
106
+ "{{ total > 0 }}",
107
+ "{{ user_name != '' }}"
108
+ ]
109
+ }
106
110
  }
107
111
  ]
108
112
  ```
109
113
 
114
+ **Important:** `verify.expressions` are `{{ }}` templates evaluated against the **context** (saved variables, fixtures, substitutions). The HTTP response is **not** ambient in templates — there is no `response`/`status_code`/`body`/`json` variable. To assert on response data, either:
115
+ - use `verify.status`, `verify.headers`, `verify.body` (these check the response directly), or
116
+ - `save` the value first (e.g. via `jmespath`) and reference the saved variable in a later `expressions` step (as shown above).
117
+
110
118
  **Save types:**
111
119
  - `{"jmespath": {...}}` - extract values from JSON response via JMESPath
112
120
  - `{"substitutions": [...]}` - compute values using template expressions
@@ -229,12 +237,9 @@ Or iterate over parameter sets in parallel:
229
237
  "url": "{{ base }}/users/{{ user_id }}"
230
238
  },
231
239
  "response": [
232
- {
233
- "verify": {
234
- "status": 200,
235
- "expressions": ["{{ body.name == 'Alice' }}"]
236
- }
237
- }
240
+ { "verify": { "status": 200 } },
241
+ { "save": { "jmespath": { "name": "name" } } },
242
+ { "verify": { "expressions": ["{{ name == 'Alice' }}"] } }
238
243
  ]
239
244
  },
240
245
  {
@@ -31,11 +31,11 @@ def make_marker(mark_str: str) -> pytest.MarkDecorator:
31
31
 
32
32
  def process_substitutions(
33
33
  substitutions: Sequence[Substitution],
34
- context: Mapping[str, Any] = {},
34
+ context: Mapping[str, Any] | None = None,
35
35
  ) -> dict[str, Any]:
36
- result = {}
36
+ result: dict[str, Any] = {}
37
37
  for step in substitutions:
38
- current_context = {**context, **result}
38
+ current_context = {**(context or {}), **result}
39
39
  match step:
40
40
  case FunctionsSubstitution():
41
41
  for alias, func_def in step.functions.items():
@@ -0,0 +1,272 @@
1
+ """Static validation for pytest-httpchain scenario files.
2
+
3
+ This is the single source of truth for scenario validation, consumed by the
4
+ `pytest-httpchain validate` CLI command (and available for collection-time and
5
+ editor integrations). It performs structural validation via the Pydantic
6
+ ``Scenario`` model plus cross-cutting semantic checks that a JSON Schema cannot
7
+ express (duplicate stage names, undefined-variable/data-flow, fixture conflicts).
8
+ """
9
+
10
+ import ast
11
+ import json
12
+ import re
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import pytest_httpchain_jsonref.loader
17
+ from pydantic import BaseModel, ValidationError
18
+ from pytest_httpchain_jsonref.exceptions import ReferenceResolverError
19
+ from pytest_httpchain_models.entities import Scenario
20
+ from pytest_httpchain_templates.expressions import TEMPLATE_PATTERN
21
+ from pytest_httpchain_templates.substitution import JSON_LITERALS, SAFE_FUNCTIONS
22
+
23
+ # Names provided by the template engine that don't need user definition.
24
+ TEMPLATE_BUILTINS = (
25
+ set(SAFE_FUNCTIONS)
26
+ | set(JSON_LITERALS)
27
+ | {"exists", "get"} # context helpers added at eval time
28
+ | {"rand", "randint", "int", "float", "str"} # simpleeval defaults
29
+ )
30
+
31
+
32
+ def _extract_names_from_expr(expr: str) -> set[str]:
33
+ """Extract identifier names from a Python expression using AST parsing."""
34
+ try:
35
+ tree = ast.parse(expr.strip(), mode="eval")
36
+ return {node.id for node in ast.walk(tree) if isinstance(node, ast.Name)}
37
+ except SyntaxError:
38
+ return set(re.findall(r"[a-zA-Z_][a-zA-Z0-9_]*", expr))
39
+
40
+
41
+ def extract_template_variables(obj: Any, variables: set[str] | None = None) -> set[str]:
42
+ """Recursively extract variable names from {{ expr }} template expressions."""
43
+ if variables is None:
44
+ variables = set()
45
+
46
+ if isinstance(obj, str):
47
+ for match in re.finditer(TEMPLATE_PATTERN, obj):
48
+ names = _extract_names_from_expr(match.group("expr"))
49
+ variables.update(names - TEMPLATE_BUILTINS)
50
+ elif isinstance(obj, dict):
51
+ for value in obj.values():
52
+ extract_template_variables(value, variables)
53
+ elif isinstance(obj, list):
54
+ for item in obj:
55
+ extract_template_variables(item, variables)
56
+
57
+ return variables
58
+
59
+
60
+ def extract_saved_variables(scenario: Scenario) -> set[str]:
61
+ """Extract variable names saved in response steps."""
62
+ saved_vars: set[str] = set()
63
+
64
+ for stage in scenario.stages:
65
+ for response_step in stage.response:
66
+ if not hasattr(response_step, "save"):
67
+ continue
68
+ save = response_step.save
69
+ if hasattr(save, "jmespath") and isinstance(save.jmespath, dict):
70
+ saved_vars.update(save.jmespath.keys())
71
+ if hasattr(save, "substitutions"):
72
+ for sub in save.substitutions:
73
+ if hasattr(sub, "vars") and isinstance(sub.vars, dict):
74
+ saved_vars.update(sub.vars.keys())
75
+ if hasattr(sub, "functions") and isinstance(sub.functions, dict):
76
+ saved_vars.update(sub.functions.keys())
77
+
78
+ return saved_vars
79
+
80
+
81
+ def _parameter_names(params: Any) -> set[str]:
82
+ """Names injected by a list of parametrize/foreach Parameter entries.
83
+
84
+ Covers both ``individual`` (one name -> list of values) and ``combinations``
85
+ (list of dicts whose keys are the names). Template-string forms (deferred to
86
+ runtime) contribute no statically-known names.
87
+ """
88
+ names: set[str] = set()
89
+ for param in params or []:
90
+ individual = getattr(param, "individual", None)
91
+ if isinstance(individual, dict):
92
+ names.update(k for k in individual if isinstance(k, str))
93
+ combinations = getattr(param, "combinations", None)
94
+ if isinstance(combinations, list):
95
+ for combo in combinations:
96
+ if isinstance(combo, dict):
97
+ names.update(k for k in combo if isinstance(k, str))
98
+ return names
99
+
100
+
101
+ def extract_defined_variables(scenario: Scenario, test_data: dict[str, Any]) -> set[str]:
102
+ """Extract variable names made available before/within templates.
103
+
104
+ Sources: ``vars`` and ``functions`` substitutions (scenario- and stage-level),
105
+ plus parameter names injected by ``parametrize`` and ``parallel.foreach`` — all
106
+ of which the engine wires into the evaluation context at runtime.
107
+ """
108
+ defined_vars: set[str] = set()
109
+
110
+ # Defensive: a top-level "vars" key is not a model field but is tolerated.
111
+ if "vars" in test_data and isinstance(test_data["vars"], dict):
112
+ defined_vars.update(k for k in test_data["vars"] if isinstance(k, str))
113
+
114
+ def add_substitution_names(subs: Any) -> None:
115
+ for sub in subs:
116
+ vars_ = getattr(sub, "vars", None)
117
+ if isinstance(vars_, dict):
118
+ defined_vars.update(k for k in vars_ if isinstance(k, str))
119
+ functions = getattr(sub, "functions", None)
120
+ if isinstance(functions, dict):
121
+ defined_vars.update(k for k in functions if isinstance(k, str))
122
+
123
+ add_substitution_names(scenario.substitutions)
124
+
125
+ for stage in scenario.stages:
126
+ add_substitution_names(stage.substitutions)
127
+ defined_vars |= _parameter_names(stage.parametrize)
128
+ if stage.parallel is not None:
129
+ defined_vars |= _parameter_names(getattr(stage.parallel, "foreach", None))
130
+
131
+ return defined_vars
132
+
133
+
134
+ class ScenarioInfo(BaseModel):
135
+ """Detailed information about the scenario structure."""
136
+
137
+ num_stages: int = 0
138
+ stage_names: list[str] = []
139
+ vars_referenced: list[str] = []
140
+ vars_saved: list[str] = []
141
+ vars_defined: list[str] = []
142
+ fixtures: list[str] = []
143
+
144
+
145
+ class ValidateResult(BaseModel):
146
+ """Result of scenario validation."""
147
+
148
+ valid: bool
149
+ errors: list[str] = []
150
+ warnings: list[str] = []
151
+ scenario_info: ScenarioInfo | None = None
152
+
153
+
154
+ def check_scenario(scenario: Scenario, test_data: dict[str, Any]) -> tuple[list[str], list[str], ScenarioInfo]:
155
+ """Semantic checks on an already-loaded, schema-valid scenario.
156
+
157
+ Operates on the parsed ``test_data`` dict together with the validated ``Scenario``
158
+ so it can be shared by :func:`validate_scenario` (file-based) and by the pytest
159
+ collector. Returns ``(errors, warnings, scenario_info)``.
160
+ """
161
+ errors: list[str] = []
162
+ warnings: list[str] = []
163
+
164
+ stage_names = [stage.name for stage in scenario.stages]
165
+ seen_names: set[str] = set()
166
+ duplicate_names: set[str] = set()
167
+ for name in stage_names:
168
+ if name in seen_names:
169
+ duplicate_names.add(name)
170
+ seen_names.add(name)
171
+ if duplicate_names:
172
+ errors.append(f"Duplicate stage names found: {sorted(duplicate_names)}")
173
+
174
+ fixtures: list[str] = []
175
+ if "fixtures" in test_data and isinstance(test_data["fixtures"], list):
176
+ fixtures = list(test_data["fixtures"])
177
+ for stage in scenario.stages:
178
+ fixtures.extend(stage.fixtures)
179
+ fixtures = list(set(fixtures))
180
+
181
+ vars_defined = extract_defined_variables(scenario, test_data)
182
+ vars_saved = extract_saved_variables(scenario)
183
+ vars_referenced = extract_template_variables(test_data)
184
+
185
+ fixture_set = set(fixtures)
186
+ var_conflicts = fixture_set & vars_defined
187
+ if var_conflicts:
188
+ errors.append(f"Conflicting fixtures and vars with same names: {sorted(var_conflicts)}")
189
+
190
+ # NOTE: response data (response/status_code/body/json/text/headers/cookies) is
191
+ # NOT ambient in {{ }} templates — it reaches save/verify handlers directly and
192
+ # only enters the template context via an earlier `save` step. So there are no
193
+ # response "builtins" to whitelist here; template functions are already excluded
194
+ # from vars_referenced via TEMPLATE_BUILTINS.
195
+ all_available_vars = vars_defined | vars_saved | fixture_set
196
+
197
+ undefined_vars = vars_referenced - all_available_vars
198
+ if undefined_vars:
199
+ warnings.append(f"Potentially undefined variables referenced: {sorted(undefined_vars)}")
200
+
201
+ for stage in scenario.stages:
202
+ if not any(hasattr(step, "verify") for step in stage.response):
203
+ warnings.append(f"Stage '{stage.name}' has no response validation (no verify step)")
204
+
205
+ scenario_info = ScenarioInfo(
206
+ num_stages=len(scenario.stages),
207
+ stage_names=stage_names,
208
+ vars_referenced=sorted(vars_referenced),
209
+ vars_saved=sorted(vars_saved),
210
+ vars_defined=sorted(vars_defined),
211
+ fixtures=sorted(fixtures),
212
+ )
213
+
214
+ return errors, warnings, scenario_info
215
+
216
+
217
+ def validate_scenario(
218
+ path: Path,
219
+ ref_parent_traversal_depth: int = 3,
220
+ root_path: Path | None = None,
221
+ ) -> ValidateResult:
222
+ """Validate a pytest-httpchain test scenario file.
223
+
224
+ Performs file/JSON/$ref/schema validation plus semantic checks (duplicate
225
+ stage names, undefined variables, fixture/variable conflicts, missing verify).
226
+ """
227
+ errors: list[str] = []
228
+ warnings: list[str] = []
229
+
230
+ if not path.exists():
231
+ return ValidateResult(valid=False, errors=[f"File not found: {path}"])
232
+
233
+ if not path.is_file():
234
+ return ValidateResult(valid=False, errors=[f"Path is not a file: {path}"])
235
+
236
+ if path.suffix.lower() not in (".json",):
237
+ warnings.append(f"File has extension '{path.suffix}' but expected '.json'. Consider renaming to use .json extension.")
238
+
239
+ if root_path is None:
240
+ potential_root = path.parent
241
+ while potential_root.parent != potential_root:
242
+ if potential_root.name == "tests":
243
+ root_path = potential_root
244
+ break
245
+ potential_root = potential_root.parent
246
+ else:
247
+ root_path = path.parent
248
+
249
+ try:
250
+ test_data = pytest_httpchain_jsonref.loader.load_json(
251
+ path,
252
+ max_parent_traversal_depth=ref_parent_traversal_depth,
253
+ root_path=root_path,
254
+ )
255
+ except ReferenceResolverError as e:
256
+ return ValidateResult(valid=False, errors=[f"JSON reference resolution error: {e}"], warnings=warnings)
257
+ except json.JSONDecodeError as e:
258
+ return ValidateResult(valid=False, errors=[f"Invalid JSON syntax: {e}"], warnings=warnings)
259
+ except Exception as e:
260
+ return ValidateResult(valid=False, errors=[f"Failed to parse JSON file: {e}"], warnings=warnings)
261
+
262
+ try:
263
+ scenario = Scenario.model_validate(test_data)
264
+ except ValidationError as e:
265
+ error_details = [f"{' -> '.join(str(x) for x in err['loc'])}: {err['msg']}" for err in e.errors()]
266
+ return ValidateResult(valid=False, errors=["Schema validation failed:"] + error_details, warnings=warnings)
267
+
268
+ semantic_errors, semantic_warnings, scenario_info = check_scenario(scenario, test_data)
269
+ errors.extend(semantic_errors)
270
+ warnings.extend(semantic_warnings)
271
+
272
+ return ValidateResult(valid=not errors, errors=errors, warnings=warnings, scenario_info=scenario_info)
@@ -1,67 +0,0 @@
1
- import json
2
- from pathlib import Path
3
- from typing import Annotated
4
-
5
- import typer
6
-
7
- app = typer.Typer()
8
-
9
- SKILL_FILE = Path(__file__).parent / "skill.md"
10
-
11
- MCP_SERVER_CONFIG = {
12
- "command": "uv",
13
- "args": ["run", "pytest-httpchain", "mcp"],
14
- }
15
-
16
-
17
- @app.command()
18
- def mcp() -> None:
19
- """Run the MCP server."""
20
- from pytest_httpchain_mcp.server import mcp as server
21
-
22
- server.run()
23
-
24
-
25
- @app.command()
26
- def install(
27
- skill: bool = typer.Option(..., "--skill/--no-skill", "-s/-S", help="Install Claude Code skill"),
28
- mcp_config: bool = typer.Option(..., "--mcp/--no-mcp", "-m/-M", help="Install MCP server config"),
29
- global_: bool = typer.Option(False, "--global", "-g", help="Install to ~/.claude (personal scope) instead of project"),
30
- project_dir: Annotated[Path, typer.Option(help="Project directory (ignored with --global)")] = Path("."),
31
- ) -> None:
32
- """Install MCP server config and/or Claude Code skill."""
33
- if global_:
34
- if skill:
35
- _install_skill(Path.home() / ".claude" / "skills" / "pytest-httpchain")
36
- if mcp_config:
37
- typer.echo("To add the MCP server globally, run:")
38
- typer.echo(" claude mcp add --scope user pytest-httpchain -- uv run pytest-httpchain mcp")
39
- else:
40
- project_dir = project_dir.resolve()
41
- if skill:
42
- _install_skill(project_dir / ".claude" / "skills" / "pytest-httpchain")
43
- if mcp_config:
44
- _install_mcp_config(project_dir / ".mcp.json")
45
-
46
-
47
- def _install_skill(skill_dir: Path) -> None:
48
- skill_dir.mkdir(parents=True, exist_ok=True)
49
- dest = skill_dir / "SKILL.md"
50
- dest.write_text(SKILL_FILE.read_text())
51
- typer.echo(f"Installed skill to {dest}")
52
-
53
-
54
- def _install_mcp_config(mcp_file: Path) -> None:
55
- if mcp_file.exists():
56
- config = json.loads(mcp_file.read_text())
57
- else:
58
- config = {}
59
-
60
- config.setdefault("mcpServers", {})
61
- config["mcpServers"]["pytest-httpchain"] = MCP_SERVER_CONFIG
62
- mcp_file.write_text(json.dumps(config, indent=2) + "\n")
63
- typer.echo(f"Installed MCP server config to {mcp_file}")
64
-
65
-
66
- if __name__ == "__main__":
67
- app()