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.
- {pytest_httpchain-0.2.4 → pytest_httpchain-0.3.0}/PKG-INFO +23 -21
- {pytest_httpchain-0.2.4 → pytest_httpchain-0.3.0}/README.md +21 -19
- {pytest_httpchain-0.2.4 → pytest_httpchain-0.3.0}/pyproject.toml +2 -5
- {pytest_httpchain-0.2.4 → pytest_httpchain-0.3.0}/src/pytest_httpchain/carrier.py +21 -25
- pytest_httpchain-0.3.0/src/pytest_httpchain/cli.py +59 -0
- {pytest_httpchain-0.2.4 → pytest_httpchain-0.3.0}/src/pytest_httpchain/har_writer.py +1 -2
- {pytest_httpchain-0.2.4 → pytest_httpchain-0.3.0}/src/pytest_httpchain/plugin.py +17 -2
- {pytest_httpchain-0.2.4 → pytest_httpchain-0.3.0}/src/pytest_httpchain/skill.md +15 -10
- {pytest_httpchain-0.2.4 → pytest_httpchain-0.3.0}/src/pytest_httpchain/utils.py +3 -3
- pytest_httpchain-0.3.0/src/pytest_httpchain/validation.py +272 -0
- pytest_httpchain-0.2.4/src/pytest_httpchain/cli.py +0 -67
- {pytest_httpchain-0.2.4 → pytest_httpchain-0.3.0}/LICENSE +0 -0
- {pytest_httpchain-0.2.4 → pytest_httpchain-0.3.0}/src/pytest_httpchain/__init__.py +0 -0
- {pytest_httpchain-0.2.4 → pytest_httpchain-0.3.0}/src/pytest_httpchain/constants.py +0 -0
- {pytest_httpchain-0.2.4 → pytest_httpchain-0.3.0}/src/pytest_httpchain/exceptions.py +0 -0
- {pytest_httpchain-0.2.4 → pytest_httpchain-0.3.0}/src/pytest_httpchain/report_formatter.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-httpchain
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.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
|
-
##
|
|
199
|
+
## AI agent support
|
|
200
200
|
|
|
201
|
-
`pytest-httpchain`
|
|
201
|
+
`pytest-httpchain` ships tooling to help AI coding agents (and humans) author and check test scenarios.
|
|
202
202
|
|
|
203
|
-
###
|
|
203
|
+
### Claude Code skill
|
|
204
204
|
|
|
205
|
-
Install the
|
|
205
|
+
Install the authoring skill into your project (or `--global` for personal scope):
|
|
206
206
|
|
|
207
207
|
```bash
|
|
208
|
-
uvx pytest-httpchain install
|
|
208
|
+
uvx pytest-httpchain install
|
|
209
209
|
```
|
|
210
210
|
|
|
211
|
-
|
|
211
|
+
This writes `.claude/skills/pytest-httpchain/SKILL.md` with guidance for writing scenarios.
|
|
212
212
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
225
|
+
A JSON Schema is published for as-you-type validation and autocomplete. Reference it from your test files:
|
|
227
226
|
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
##
|
|
172
|
+
## AI agent support
|
|
173
173
|
|
|
174
|
-
`pytest-httpchain`
|
|
174
|
+
`pytest-httpchain` ships tooling to help AI coding agents (and humans) author and check test scenarios.
|
|
175
175
|
|
|
176
|
-
###
|
|
176
|
+
### Claude Code skill
|
|
177
177
|
|
|
178
|
-
Install the
|
|
178
|
+
Install the authoring skill into your project (or `--global` for personal scope):
|
|
179
179
|
|
|
180
180
|
```bash
|
|
181
|
-
uvx pytest-httpchain install
|
|
181
|
+
uvx pytest-httpchain install
|
|
182
182
|
```
|
|
183
183
|
|
|
184
|
-
|
|
184
|
+
This writes `.claude/skills/pytest-httpchain/SKILL.md` with guidance for writing scenarios.
|
|
185
185
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
198
|
+
A JSON Schema is published for as-you-type validation and autocomplete. Reference it from your test files:
|
|
200
199
|
|
|
201
|
-
|
|
202
|
-
|
|
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.
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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: {
|
|
259
|
+
raise RequestError(f"HTTP connection error: {e}") from None
|
|
263
260
|
except httpx.HTTPError as e:
|
|
264
|
-
raise RequestError(f"HTTP request failed: {
|
|
261
|
+
raise RequestError(f"HTTP request failed: {e}") from None
|
|
265
262
|
except Exception as e:
|
|
266
|
-
raise RequestError(f"Unexpected error: {
|
|
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: {
|
|
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}: {
|
|
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: {
|
|
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
|
-
|
|
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}': {
|
|
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}': {
|
|
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}': {
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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
|
-
|
|
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}: {
|
|
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}: {
|
|
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
|
-
|
|
234
|
-
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|