auto-api-test 0.1.0__py3-none-any.whl
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.
- auto_api_test/__init__.py +3 -0
- auto_api_test/assertions.py +158 -0
- auto_api_test/cli.py +142 -0
- auto_api_test/issue.py +175 -0
- auto_api_test/parser.py +118 -0
- auto_api_test/plugin.py +193 -0
- auto_api_test/reporter.py +115 -0
- auto_api_test/runner.py +164 -0
- auto_api_test-0.1.0.dist-info/METADATA +261 -0
- auto_api_test-0.1.0.dist-info/RECORD +14 -0
- auto_api_test-0.1.0.dist-info/WHEEL +5 -0
- auto_api_test-0.1.0.dist-info/entry_points.txt +5 -0
- auto_api_test-0.1.0.dist-info/licenses/LICENSE +73 -0
- auto_api_test-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Assertion engine with expression prefix support."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AssertionError_(Exception):
|
|
8
|
+
"""Custom assertion error with expected/actual detail."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, field: str, expected: Any, actual: Any, operator: str = "="):
|
|
11
|
+
self.field = field
|
|
12
|
+
self.expected = expected
|
|
13
|
+
self.actual = actual
|
|
14
|
+
self.operator = operator
|
|
15
|
+
super().__init__(
|
|
16
|
+
f"Assertion failed on '{field}': expected {operator} {expected!r}, got {actual!r}"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _numeric_eq(a: Any, b: Any) -> bool:
|
|
21
|
+
"""Try numeric coercion for string vs number comparison."""
|
|
22
|
+
try:
|
|
23
|
+
return float(a) == float(b)
|
|
24
|
+
except (ValueError, TypeError):
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _resolve_field(data: Any, field_path: str) -> Any:
|
|
29
|
+
"""Resolve a dotted field path against data. Supports .len for length."""
|
|
30
|
+
parts = field_path.split(".")
|
|
31
|
+
current = data
|
|
32
|
+
for part in parts:
|
|
33
|
+
if part == "len":
|
|
34
|
+
if isinstance(current, (list, str, dict)):
|
|
35
|
+
current = len(current)
|
|
36
|
+
else:
|
|
37
|
+
raise AssertionError_(field_path, "list/str/dict", type(current).__name__, ".len")
|
|
38
|
+
elif isinstance(current, dict):
|
|
39
|
+
if part not in current:
|
|
40
|
+
raise AssertionError_(field_path, "key exists", f"key '{part}' missing")
|
|
41
|
+
current = current[part]
|
|
42
|
+
elif isinstance(current, list) and part.isdigit():
|
|
43
|
+
idx = int(part)
|
|
44
|
+
if idx >= len(current):
|
|
45
|
+
raise AssertionError_(field_path, f"index {idx} exists", f"list length {len(current)}")
|
|
46
|
+
current = current[idx]
|
|
47
|
+
else:
|
|
48
|
+
raise AssertionError_(field_path, "resolvable path", f"cannot resolve '{part}' in {type(current).__name__}")
|
|
49
|
+
return current
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _check_numeric_expr(actual: Any, expr: str, field: str) -> None:
|
|
53
|
+
"""Check numeric comparison expressions like >0, >=1, <100."""
|
|
54
|
+
m = re.match(r"^(>=|<=|>|<)\s*(-?\d+(?:\.\d+)?)$", expr)
|
|
55
|
+
if not m:
|
|
56
|
+
raise AssertionError_(field, "valid numeric expression", expr, "expression")
|
|
57
|
+
op, val_str = m.group(1), m.group(2)
|
|
58
|
+
expected = float(val_str)
|
|
59
|
+
actual_num = float(actual)
|
|
60
|
+
|
|
61
|
+
ops = {
|
|
62
|
+
">": actual_num > expected,
|
|
63
|
+
">=": actual_num >= expected,
|
|
64
|
+
"<": actual_num < expected,
|
|
65
|
+
"<=": actual_num <= expected,
|
|
66
|
+
}
|
|
67
|
+
if not ops[op]:
|
|
68
|
+
raise AssertionError_(field, f"{op}{expected}", actual_num, op)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _check_regex(actual: str, pattern: str, field: str) -> None:
|
|
72
|
+
"""Check regex match."""
|
|
73
|
+
if not re.search(pattern, str(actual)):
|
|
74
|
+
raise AssertionError_(field, f"~={pattern}", actual, "regex")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _check_contains(actual: str, substring: str, field: str) -> None:
|
|
78
|
+
"""Check string contains."""
|
|
79
|
+
if substring not in str(actual):
|
|
80
|
+
raise AssertionError_(field, f"contains:{substring}", actual, "contains")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def assert_value(field: str, expected: Any, actual: Any) -> None:
|
|
84
|
+
"""Assert a single field value against expected.
|
|
85
|
+
|
|
86
|
+
Expression prefixes:
|
|
87
|
+
>0, <100, >=1, <=999 - numeric comparison
|
|
88
|
+
~=pattern - regex match
|
|
89
|
+
contains:sub - string contains
|
|
90
|
+
not_null - value is not None
|
|
91
|
+
is_null - value is None
|
|
92
|
+
is_true - boolean truthy
|
|
93
|
+
is_false - boolean falsy
|
|
94
|
+
(other) - exact equality
|
|
95
|
+
"""
|
|
96
|
+
if isinstance(expected, str):
|
|
97
|
+
if expected.startswith((">", "<")) and not expected.startswith("~="):
|
|
98
|
+
_check_numeric_expr(actual, expected, field)
|
|
99
|
+
elif expected.startswith("~="):
|
|
100
|
+
_check_regex(actual, expected[2:], field)
|
|
101
|
+
elif expected.startswith("contains:"):
|
|
102
|
+
_check_contains(actual, expected[len("contains:"):], field)
|
|
103
|
+
elif expected == "not_null":
|
|
104
|
+
if actual is None:
|
|
105
|
+
raise AssertionError_(field, "not None", None, "not_null")
|
|
106
|
+
elif expected == "is_null":
|
|
107
|
+
if actual is not None:
|
|
108
|
+
raise AssertionError_(field, None, actual, "is_null")
|
|
109
|
+
elif expected == "is_true":
|
|
110
|
+
if not actual:
|
|
111
|
+
raise AssertionError_(field, True, actual, "is_true")
|
|
112
|
+
elif expected == "is_false":
|
|
113
|
+
if actual:
|
|
114
|
+
raise AssertionError_(field, False, actual, "is_false")
|
|
115
|
+
else:
|
|
116
|
+
if actual != expected:
|
|
117
|
+
if _numeric_eq(expected, actual):
|
|
118
|
+
return
|
|
119
|
+
raise AssertionError_(field, expected, actual)
|
|
120
|
+
else:
|
|
121
|
+
if actual != expected:
|
|
122
|
+
if _numeric_eq(expected, actual):
|
|
123
|
+
return
|
|
124
|
+
raise AssertionError_(field, expected, actual)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def assert_json(json_expect: dict, response_json: Any) -> list[str]:
|
|
128
|
+
"""Assert all fields in json_expect against response_json.
|
|
129
|
+
|
|
130
|
+
Returns a list of failure messages (empty if all pass).
|
|
131
|
+
"""
|
|
132
|
+
failures = []
|
|
133
|
+
for field, expected in json_expect.items():
|
|
134
|
+
try:
|
|
135
|
+
actual = _resolve_field(response_json, field)
|
|
136
|
+
assert_value(field, expected, actual)
|
|
137
|
+
except AssertionError_ as e:
|
|
138
|
+
failures.append(str(e))
|
|
139
|
+
return failures
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def assert_status_code(expected: int, actual: int) -> str | None:
|
|
143
|
+
"""Assert status code. Returns failure message or None."""
|
|
144
|
+
if expected != actual:
|
|
145
|
+
return f"Status code: expected {expected}, got {actual}"
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def assert_headers(expected: dict, actual: dict) -> list[str]:
|
|
150
|
+
"""Assert response headers. Returns list of failure messages."""
|
|
151
|
+
failures = []
|
|
152
|
+
for key, expected_val in expected.items():
|
|
153
|
+
actual_val = actual.get(key)
|
|
154
|
+
if actual_val is None:
|
|
155
|
+
failures.append(f"Header '{key}': expected '{expected_val}', not present")
|
|
156
|
+
elif expected_val not in actual_val:
|
|
157
|
+
failures.append(f"Header '{key}': expected contains '{expected_val}', got '{actual_val}'")
|
|
158
|
+
return failures
|
auto_api_test/cli.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""CLI entry point for atr command."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(argv=None):
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
prog="atr",
|
|
15
|
+
description="auto-api-test: YAML DSL API testing framework",
|
|
16
|
+
)
|
|
17
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
18
|
+
|
|
19
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
20
|
+
|
|
21
|
+
# run command
|
|
22
|
+
run_parser = subparsers.add_parser("run", help="Run API tests from YAML files")
|
|
23
|
+
run_parser.add_argument(
|
|
24
|
+
"path",
|
|
25
|
+
nargs="?",
|
|
26
|
+
default=".",
|
|
27
|
+
help="YAML file or directory to run (default: current directory)",
|
|
28
|
+
)
|
|
29
|
+
run_parser.add_argument(
|
|
30
|
+
"-o", "--report",
|
|
31
|
+
default=None,
|
|
32
|
+
help="Report output path (default: report.md alongside YAML file)",
|
|
33
|
+
)
|
|
34
|
+
run_parser.add_argument(
|
|
35
|
+
"-v", "--verbose",
|
|
36
|
+
action="store_true",
|
|
37
|
+
help="Verbose output",
|
|
38
|
+
)
|
|
39
|
+
run_parser.add_argument(
|
|
40
|
+
"--env-file",
|
|
41
|
+
default=None,
|
|
42
|
+
help="Load environment variables from .env file",
|
|
43
|
+
)
|
|
44
|
+
run_parser.add_argument(
|
|
45
|
+
"--base-url",
|
|
46
|
+
default=None,
|
|
47
|
+
help="Override base_url for all tests",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# init command
|
|
51
|
+
init_parser = subparsers.add_parser("init", help="Create example YAML test file")
|
|
52
|
+
init_parser.add_argument(
|
|
53
|
+
"filename",
|
|
54
|
+
nargs="?",
|
|
55
|
+
default="test_api.yaml",
|
|
56
|
+
help="Output filename (default: test_api.yaml)",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
args = parser.parse_args(argv)
|
|
60
|
+
|
|
61
|
+
if args.command == "run":
|
|
62
|
+
return _run_tests(args)
|
|
63
|
+
elif args.command == "init":
|
|
64
|
+
return _init_project(args.filename)
|
|
65
|
+
else:
|
|
66
|
+
parser.print_help()
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _run_tests(args):
|
|
71
|
+
"""Execute tests via pytest.main()."""
|
|
72
|
+
path = Path(args.path)
|
|
73
|
+
if not path.exists():
|
|
74
|
+
print(f"Error: path '{args.path}' does not exist", file=sys.stderr)
|
|
75
|
+
return 1
|
|
76
|
+
|
|
77
|
+
pytest_args = [str(path)]
|
|
78
|
+
|
|
79
|
+
if args.report:
|
|
80
|
+
pytest_args.extend(["--report", args.report])
|
|
81
|
+
if args.env_file:
|
|
82
|
+
pytest_args.extend(["--env-file", args.env_file])
|
|
83
|
+
if args.base_url:
|
|
84
|
+
pytest_args.extend(["--base-url", args.base_url])
|
|
85
|
+
if args.verbose:
|
|
86
|
+
pytest_args.append("-v")
|
|
87
|
+
|
|
88
|
+
exit_code = pytest.main(pytest_args)
|
|
89
|
+
return exit_code
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _init_project(filename):
|
|
93
|
+
"""Create an example YAML test file."""
|
|
94
|
+
example = """\
|
|
95
|
+
config:
|
|
96
|
+
base_url: "https://jsonplaceholder.typicode.com"
|
|
97
|
+
timeout: 30
|
|
98
|
+
|
|
99
|
+
tests:
|
|
100
|
+
- name: "Get all posts"
|
|
101
|
+
request:
|
|
102
|
+
method: GET
|
|
103
|
+
url: /posts
|
|
104
|
+
expect:
|
|
105
|
+
status_code: 200
|
|
106
|
+
json:
|
|
107
|
+
0.id: "not_null"
|
|
108
|
+
|
|
109
|
+
- name: "Get first post"
|
|
110
|
+
request:
|
|
111
|
+
method: GET
|
|
112
|
+
url: /posts/1
|
|
113
|
+
expect:
|
|
114
|
+
status_code: 200
|
|
115
|
+
json:
|
|
116
|
+
userId: "not_null"
|
|
117
|
+
title: "not_null"
|
|
118
|
+
|
|
119
|
+
- name: "Create a post"
|
|
120
|
+
request:
|
|
121
|
+
method: POST
|
|
122
|
+
url: /posts
|
|
123
|
+
json:
|
|
124
|
+
title: "test post"
|
|
125
|
+
body: "test body"
|
|
126
|
+
userId: 1
|
|
127
|
+
expect:
|
|
128
|
+
status_code: 201
|
|
129
|
+
json:
|
|
130
|
+
id: "not_null"
|
|
131
|
+
"""
|
|
132
|
+
path = Path(filename)
|
|
133
|
+
if path.exists():
|
|
134
|
+
print(f"Error: file '{filename}' already exists", file=sys.stderr)
|
|
135
|
+
return 1
|
|
136
|
+
path.write_text(example, encoding="utf-8")
|
|
137
|
+
print(f"Created example test file: {filename}")
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
sys.exit(main())
|
auto_api_test/issue.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Issue backends for auto-creating bug tickets from test failures."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class IssueBackend(ABC):
|
|
10
|
+
"""Abstract base for git platform issue creation."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, repo: str, token: str, base_url: str = ""):
|
|
13
|
+
self.repo = repo # "owner/repo" format
|
|
14
|
+
self.token = token
|
|
15
|
+
self.base_url = base_url.rstrip("/")
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def create_issue(self, title: str, body: str, labels: list[str] | None = None) -> dict:
|
|
19
|
+
"""Create an issue. Returns API response dict with at least 'url' key."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GitHubBackend(IssueBackend):
|
|
23
|
+
"""GitHub Issues API."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, repo: str, token: str, base_url: str = "https://api.github.com"):
|
|
26
|
+
super().__init__(repo, token, base_url)
|
|
27
|
+
|
|
28
|
+
def create_issue(self, title: str, body: str, labels: list[str] | None = None) -> dict:
|
|
29
|
+
url = f"{self.base_url}/repos/{self.repo}/issues"
|
|
30
|
+
payload = {"title": title, "body": body}
|
|
31
|
+
if labels:
|
|
32
|
+
payload["labels"] = labels
|
|
33
|
+
resp = requests.post(
|
|
34
|
+
url,
|
|
35
|
+
json=payload,
|
|
36
|
+
headers={
|
|
37
|
+
"Authorization": f"Bearer {self.token}",
|
|
38
|
+
"Accept": "application/vnd.github+json",
|
|
39
|
+
},
|
|
40
|
+
timeout=30,
|
|
41
|
+
)
|
|
42
|
+
resp.raise_for_status()
|
|
43
|
+
data = resp.json()
|
|
44
|
+
return {"url": data.get("html_url", ""), "number": data.get("number")}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class GitLabBackend(IssueBackend):
|
|
48
|
+
"""GitLab Issues API."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, repo: str, token: str, base_url: str = "https://gitlab.com"):
|
|
51
|
+
super().__init__(repo, token, base_url)
|
|
52
|
+
|
|
53
|
+
def create_issue(self, title: str, body: str, labels: list[str] | None = None) -> dict:
|
|
54
|
+
project_id = self.repo.replace("/", "%2F")
|
|
55
|
+
url = f"{self.base_url}/api/v4/projects/{project_id}/issues"
|
|
56
|
+
payload = {"title": title, "description": body}
|
|
57
|
+
if labels:
|
|
58
|
+
payload["labels"] = ",".join(labels)
|
|
59
|
+
resp = requests.post(
|
|
60
|
+
url,
|
|
61
|
+
json=payload,
|
|
62
|
+
headers={"PRIVATE-TOKEN": self.token},
|
|
63
|
+
timeout=30,
|
|
64
|
+
)
|
|
65
|
+
resp.raise_for_status()
|
|
66
|
+
data = resp.json()
|
|
67
|
+
return {"url": data.get("web_url", ""), "number": data.get("iid")}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class GiteaBackend(IssueBackend):
|
|
71
|
+
"""Gitea Issues API."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, repo: str, token: str, base_url: str = "http://localhost:3000"):
|
|
74
|
+
super().__init__(repo, token, base_url)
|
|
75
|
+
|
|
76
|
+
def create_issue(self, title: str, body: str, labels: list[str] | None = None) -> dict:
|
|
77
|
+
owner, name = self.repo.split("/", 1)
|
|
78
|
+
url = f"{self.base_url}/api/v1/repos/{owner}/{name}/issues"
|
|
79
|
+
payload = {"title": title, "body": body}
|
|
80
|
+
if labels:
|
|
81
|
+
payload["labels"] = labels
|
|
82
|
+
resp = requests.post(
|
|
83
|
+
url,
|
|
84
|
+
json=payload,
|
|
85
|
+
headers={"Authorization": f"token {self.token}"},
|
|
86
|
+
timeout=30,
|
|
87
|
+
)
|
|
88
|
+
resp.raise_for_status()
|
|
89
|
+
data = resp.json()
|
|
90
|
+
return {"url": data.get("html_url", ""), "number": data.get("number")}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class GiteeBackend(IssueBackend):
|
|
94
|
+
"""Gitee Issues API."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, repo: str, token: str, base_url: str = "https://gitee.com"):
|
|
97
|
+
super().__init__(repo, token, base_url)
|
|
98
|
+
|
|
99
|
+
def create_issue(self, title: str, body: str, labels: list[str] | None = None) -> dict:
|
|
100
|
+
owner, repo_name = self.repo.split("/", 1)
|
|
101
|
+
url = f"{self.base_url}/api/v5/repos/{owner}/{repo_name}/issues"
|
|
102
|
+
payload = {"title": title, "body": body, "access_token": self.token}
|
|
103
|
+
if labels:
|
|
104
|
+
payload["labels"] = ",".join(labels)
|
|
105
|
+
resp = requests.post(url, json=payload, timeout=30)
|
|
106
|
+
resp.raise_for_status()
|
|
107
|
+
data = resp.json()
|
|
108
|
+
return {"url": data.get("html_url", ""), "number": data.get("number")}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
_BACKENDS: dict[str, type[IssueBackend]] = {
|
|
112
|
+
"github": GitHubBackend,
|
|
113
|
+
"gitlab": GitLabBackend,
|
|
114
|
+
"gitea": GiteaBackend,
|
|
115
|
+
"gitee": GiteeBackend,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def create_backend(platform: str, repo: str, token: str, base_url: str = "") -> IssueBackend:
|
|
120
|
+
"""Factory: create an IssueBackend by platform name."""
|
|
121
|
+
cls = _BACKENDS.get(platform.lower())
|
|
122
|
+
if cls is None:
|
|
123
|
+
raise ValueError(f"Unsupported platform: {platform!r}. Choose from: {', '.join(_BACKENDS)}")
|
|
124
|
+
kwargs = {"repo": repo, "token": token}
|
|
125
|
+
if base_url:
|
|
126
|
+
kwargs["base_url"] = base_url
|
|
127
|
+
return cls(**kwargs)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def create_issues_from_failures(
|
|
131
|
+
failures: list[dict],
|
|
132
|
+
platform: str,
|
|
133
|
+
repo: str,
|
|
134
|
+
base_url: str = "",
|
|
135
|
+
labels: list[str] | None = None,
|
|
136
|
+
) -> list[dict]:
|
|
137
|
+
"""Create one issue per failure. Returns list of created issue info."""
|
|
138
|
+
token = os.environ.get("git_token", "")
|
|
139
|
+
if not token:
|
|
140
|
+
raise ValueError("Environment variable 'git_token' is not set")
|
|
141
|
+
|
|
142
|
+
backend = create_backend(platform, repo, token, base_url)
|
|
143
|
+
created = []
|
|
144
|
+
|
|
145
|
+
for fail in failures:
|
|
146
|
+
title = f"[API Test {fail['type']}] {fail['name']}"
|
|
147
|
+
lines = [
|
|
148
|
+
f"## {fail['name']}",
|
|
149
|
+
"",
|
|
150
|
+
f"- **Method:** {fail['method']}",
|
|
151
|
+
f"- **URL:** `{fail['url']}`",
|
|
152
|
+
f"- **Status:** {fail.get('status_code', '-')}",
|
|
153
|
+
f"- **Duration:** {fail['duration_ms']} ms",
|
|
154
|
+
"",
|
|
155
|
+
"### Detail",
|
|
156
|
+
"",
|
|
157
|
+
fail["detail"],
|
|
158
|
+
]
|
|
159
|
+
if fail.get("response_body"):
|
|
160
|
+
lines.extend([
|
|
161
|
+
"",
|
|
162
|
+
"<details><summary>Response Body</summary>",
|
|
163
|
+
"",
|
|
164
|
+
"```json",
|
|
165
|
+
fail["response_body"],
|
|
166
|
+
"```",
|
|
167
|
+
"",
|
|
168
|
+
"</details>",
|
|
169
|
+
])
|
|
170
|
+
body = "\n".join(lines)
|
|
171
|
+
|
|
172
|
+
result = backend.create_issue(title, body, labels)
|
|
173
|
+
created.append(result)
|
|
174
|
+
|
|
175
|
+
return created
|
auto_api_test/parser.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""YAML DSL parser with variable substitution."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ValidationError(Exception):
|
|
12
|
+
"""Raised when YAML DSL structure is invalid."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, message: str, path: str = "", line: int = 0):
|
|
15
|
+
self.path = path
|
|
16
|
+
self.line = line
|
|
17
|
+
detail = f"[{path}:{line}] " if path else ""
|
|
18
|
+
super().__init__(f"{detail}{message}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_VAR_PATTERN = re.compile(r"\$\{(\w+)\}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_vars(obj: Any, variables: dict[str, str]) -> Any:
|
|
25
|
+
"""Recursively substitute ${VAR} placeholders in strings."""
|
|
26
|
+
if isinstance(obj, str):
|
|
27
|
+
def _replace(m: re.Match) -> str:
|
|
28
|
+
key = m.group(1)
|
|
29
|
+
val = variables.get(key)
|
|
30
|
+
if val is None:
|
|
31
|
+
val = os.environ.get(key)
|
|
32
|
+
if val is None:
|
|
33
|
+
return m.group(0)
|
|
34
|
+
return str(val)
|
|
35
|
+
|
|
36
|
+
return _VAR_PATTERN.sub(_replace, obj)
|
|
37
|
+
if isinstance(obj, dict):
|
|
38
|
+
return {k: resolve_vars(v, variables) for k, v in obj.items()}
|
|
39
|
+
if isinstance(obj, list):
|
|
40
|
+
return [resolve_vars(item, variables) for item in obj]
|
|
41
|
+
return obj
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _validate_config(config: dict | None, path: str) -> dict:
|
|
45
|
+
if config is None:
|
|
46
|
+
return {}
|
|
47
|
+
if not isinstance(config, dict):
|
|
48
|
+
raise ValidationError("config must be a mapping", path, 0)
|
|
49
|
+
return config
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _validate_test(test: Any, path: str, index: int) -> dict:
|
|
53
|
+
if not isinstance(test, dict):
|
|
54
|
+
raise ValidationError(f"tests[{index}] must be a mapping", path)
|
|
55
|
+
if "name" not in test:
|
|
56
|
+
raise ValidationError(f"tests[{index}] missing 'name'", path)
|
|
57
|
+
if "request" not in test:
|
|
58
|
+
raise ValidationError(f"tests[{index}] missing 'request'", path)
|
|
59
|
+
req = test["request"]
|
|
60
|
+
if not isinstance(req, dict):
|
|
61
|
+
raise ValidationError(f"tests[{index}].request must be a mapping", path)
|
|
62
|
+
if "method" not in req:
|
|
63
|
+
raise ValidationError(f"tests[{index}].request missing 'method'", path)
|
|
64
|
+
if "url" not in req:
|
|
65
|
+
raise ValidationError(f"tests[{index}].request missing 'url'", path)
|
|
66
|
+
return test
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load_yaml(path: str | Path) -> dict:
|
|
70
|
+
"""Load and validate a YAML DSL file. Returns parsed dict with variables resolved."""
|
|
71
|
+
path = Path(path)
|
|
72
|
+
if not path.exists():
|
|
73
|
+
raise ValidationError(f"File not found: {path}")
|
|
74
|
+
|
|
75
|
+
with open(path, encoding="utf-8") as f:
|
|
76
|
+
try:
|
|
77
|
+
data = yaml.safe_load(f)
|
|
78
|
+
except yaml.YAMLError as e:
|
|
79
|
+
raise ValidationError(f"YAML parse error: {e}", str(path))
|
|
80
|
+
|
|
81
|
+
if not isinstance(data, dict):
|
|
82
|
+
raise ValidationError("Root must be a mapping", str(path))
|
|
83
|
+
|
|
84
|
+
config = _validate_config(data.get("config"), str(path))
|
|
85
|
+
tests = data.get("tests")
|
|
86
|
+
if not isinstance(tests, list) or len(tests) == 0:
|
|
87
|
+
raise ValidationError("'tests' must be a non-empty list", str(path))
|
|
88
|
+
|
|
89
|
+
for i, test in enumerate(tests):
|
|
90
|
+
_validate_test(test, str(path), i)
|
|
91
|
+
|
|
92
|
+
# Collect extract variables defined in config (if any)
|
|
93
|
+
variables = {}
|
|
94
|
+
env_file = config.get("env_file")
|
|
95
|
+
if env_file:
|
|
96
|
+
from dotenv import load_dotenv
|
|
97
|
+
load_dotenv(env_file)
|
|
98
|
+
|
|
99
|
+
# First pass: collect all extract definitions for variable resolution context
|
|
100
|
+
for test in tests:
|
|
101
|
+
extract = test.get("extract", {})
|
|
102
|
+
if isinstance(extract, dict):
|
|
103
|
+
variables.update({k: "" for k in extract})
|
|
104
|
+
|
|
105
|
+
# Resolve config-level variables
|
|
106
|
+
config = resolve_vars(config, variables)
|
|
107
|
+
|
|
108
|
+
return {"config": config, "tests": tests, "variables": variables}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_config(data: dict) -> dict:
|
|
112
|
+
"""Extract config section."""
|
|
113
|
+
return data.get("config", {})
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_tests(data: dict) -> list[dict]:
|
|
117
|
+
"""Extract tests list."""
|
|
118
|
+
return data.get("tests", [])
|