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.
@@ -0,0 +1,193 @@
1
+ """pytest plugin: collects YAML test files and registers them as test items."""
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from .parser import load_yaml, get_config, get_tests
8
+ from .runner import Runner, TestResult
9
+ from .reporter import generate_report, collect_failures
10
+ from .issue import create_issues_from_failures
11
+
12
+ # Module-level caches
13
+ _yaml_cache: dict[str, dict] = {}
14
+ _file_configs: dict[str, dict] = {}
15
+
16
+
17
+ def pytest_collect_file(parent, file_path):
18
+ """Collect .yaml and .yml files as test modules."""
19
+ if file_path.suffix in (".yaml", ".yml"):
20
+ return YamlTestFile.from_parent(parent, path=file_path)
21
+
22
+
23
+ class YamlTestFile(pytest.File):
24
+ """A YAML file containing API test cases."""
25
+
26
+ def collect(self):
27
+ try:
28
+ data = load_yaml(self.path)
29
+ _yaml_cache[str(self.path)] = data
30
+ except Exception as e:
31
+ item = YamlTestItem.from_parent(self, name="parse_error")
32
+ item._error = str(e)
33
+ item._test_data = None
34
+ item._config = {}
35
+ item._index = 0
36
+ yield item
37
+ return
38
+
39
+ config = get_config(data)
40
+ _file_configs[str(self.path)] = config
41
+ tests = get_tests(data)
42
+ for i, test in enumerate(tests):
43
+ item = YamlTestItem.from_parent(self, name=test["name"])
44
+ item._error = None
45
+ item._test_data = test
46
+ item._config = config
47
+ item._index = i
48
+ yield item
49
+
50
+
51
+ class YamlTestItem(pytest.Item):
52
+ """A single YAML API test case."""
53
+
54
+ def __init__(self, name, parent):
55
+ super().__init__(name, parent)
56
+ self._error: str | None = None
57
+ self._test_data: dict | None = None
58
+ self._config: dict = {}
59
+ self._index: int = 0
60
+ self._result: TestResult | None = None
61
+
62
+ def runtest(self):
63
+ if self._error:
64
+ raise YamlTestError(self._error)
65
+
66
+ runner = self._get_or_create_runner()
67
+ result = runner.run_tests([self._test_data])[0]
68
+ self._result = result
69
+
70
+ if not hasattr(self.session.config, "_atr_results"):
71
+ self.session.config._atr_results = []
72
+ self.session.config._atr_results.append(result)
73
+
74
+ if result.is_error:
75
+ raise YamlTestError(result.error)
76
+ if not result.passed:
77
+ raise YamlTestAssertionError(result.failures)
78
+
79
+ def _get_or_create_runner(self):
80
+ """Get or create a Runner shared across tests in the same file."""
81
+ cache_key = f"_yaml_runner_{self.fspath}"
82
+ runner = getattr(self.session, cache_key, None)
83
+ if runner is None:
84
+ config = dict(self._config)
85
+ opt = getattr(self.session.config, "option", None)
86
+ if opt and getattr(opt, "base_url", None):
87
+ config["base_url"] = opt.base_url
88
+ runner = Runner(config)
89
+ setattr(self.session, cache_key, runner)
90
+ return runner
91
+
92
+ def repr_failure(self, excinfo):
93
+ if isinstance(excinfo.value, YamlTestError):
94
+ return f"ERROR: {excinfo.value}"
95
+ if isinstance(excinfo.value, YamlTestAssertionError):
96
+ lines = ["Assertion failures:"]
97
+ for f in excinfo.value.failures:
98
+ lines.append(f" - {f}")
99
+ return "\n".join(lines)
100
+ return super().repr_failure(excinfo)
101
+
102
+ def reportinfo(self):
103
+ return self.fspath, None, self.name
104
+
105
+
106
+ class YamlTestError(Exception):
107
+ """Raised when a test encounters an error (network, parse, etc.)."""
108
+
109
+
110
+ class YamlTestAssertionError(Exception):
111
+ """Raised when test assertions fail."""
112
+
113
+ def __init__(self, failures: list[str]):
114
+ self.failures = failures
115
+ super().__init__("\n".join(failures))
116
+
117
+
118
+ def pytest_addoption(parser):
119
+ """Add custom CLI options for YAML API testing."""
120
+ parser.addoption("--report", default=None, help="Markdown report output path")
121
+ parser.addoption("--base-url", default=None, help="Override base_url for all tests")
122
+ parser.addoption("--env-file", default=None, help="Load environment variables from file")
123
+
124
+
125
+ def pytest_configure(config):
126
+ """Load env-file if specified."""
127
+ env_file = config.getoption("env_file", None)
128
+ if env_file:
129
+ from dotenv import load_dotenv
130
+ load_dotenv(env_file)
131
+
132
+
133
+ def pytest_sessionfinish(session, exitstatus):
134
+ """Generate report and optionally create issues after all tests complete."""
135
+ results = getattr(session.config, "_atr_results", [])
136
+ if not results:
137
+ return
138
+
139
+ # Determine report path: CLI override > YAML file parent dir / report.md
140
+ report_path = session.config.getoption("report", None)
141
+ if not report_path:
142
+ for item in session.items:
143
+ if isinstance(item, YamlTestItem):
144
+ report_path = str(item.path.parent / "report.md")
145
+ break
146
+ if not report_path:
147
+ return
148
+
149
+ report_content = generate_report(results, report_path)
150
+
151
+ # Auto-create issues if configured in any YAML file
152
+ _maybe_create_issues(session, results)
153
+
154
+
155
+ def _maybe_create_issues(session, results: list[TestResult]):
156
+ """Create git platform issues for failed tests if configured."""
157
+ failures = collect_failures(results)
158
+ if not failures:
159
+ return
160
+
161
+ for item in session.items:
162
+ if not isinstance(item, YamlTestItem):
163
+ continue
164
+
165
+ config = _file_configs.get(str(item.path), {})
166
+ issue_cfg = config.get("issue", {})
167
+ if not issue_cfg.get("enabled"):
168
+ continue
169
+
170
+ platform = issue_cfg.get("platform")
171
+ repo = issue_cfg.get("repo")
172
+ if not platform or not repo:
173
+ continue
174
+
175
+ base_url = issue_cfg.get("base_url", "")
176
+ labels = issue_cfg.get("labels")
177
+
178
+ # Only create issues for failures from this file
179
+ file_failures = [
180
+ f for f in failures
181
+ if any(r.name == f["name"] and not r.passed for r in results if r.name == f["name"])
182
+ ]
183
+ if not file_failures:
184
+ continue
185
+
186
+ try:
187
+ created = create_issues_from_failures(
188
+ file_failures, platform, repo, base_url, labels
189
+ )
190
+ for issue in created:
191
+ print(f" Created issue: {issue.get('url', issue)}")
192
+ except Exception as e:
193
+ print(f" Warning: failed to create issues: {e}")
@@ -0,0 +1,115 @@
1
+ """Markdown test report generator."""
2
+
3
+ import datetime
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from .runner import TestResult
9
+
10
+
11
+ def generate_report(results: list["TestResult"], output_path: str | Path) -> str:
12
+ """Generate a Markdown test report from test results.
13
+
14
+ Args:
15
+ results: List of test results.
16
+ output_path: Absolute path for the report file.
17
+ """
18
+ total = len(results)
19
+ passed = sum(1 for r in results if r.passed)
20
+ failed = total - passed
21
+ errors = sum(1 for r in results if r.is_error)
22
+ pass_rate = f"{passed / total * 100:.1f}%" if total else "0%"
23
+ total_duration = sum(r.duration_ms for r in results)
24
+
25
+ now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
26
+
27
+ lines = [
28
+ "# API Test Report",
29
+ "",
30
+ f"**Generated:** {now}",
31
+ "",
32
+ "## Summary",
33
+ "",
34
+ "| Metric | Value |",
35
+ "|--------|-------|",
36
+ f"| Total | {total} |",
37
+ f"| Passed | {passed} |",
38
+ f"| Failed | {failed - errors} |",
39
+ f"| Errors | {errors} |",
40
+ f"| Pass Rate | {pass_rate} |",
41
+ f"| Total Duration | {total_duration:.2f} ms |",
42
+ "",
43
+ "## Test Results",
44
+ "",
45
+ "| # | Name | Method | URL | Status | Duration | Result |",
46
+ "|---|------|--------|-----|--------|----------|--------|",
47
+ ]
48
+
49
+ for i, r in enumerate(results, 1):
50
+ status = str(r.status_code) if r.status_code else "-"
51
+ result_str = "✅ PASS" if r.passed else ("❌ ERROR" if r.is_error else "❌ FAIL")
52
+ url_short = r.request_url
53
+ if len(url_short) > 60:
54
+ url_short = url_short[:57] + "..."
55
+ lines.append(
56
+ f"| {i} | {r.name} | {r.request_method} | {url_short} | {status} | {r.duration_ms:.2f} ms | {result_str} |"
57
+ )
58
+
59
+ # Failure details
60
+ failed_results = [r for r in results if not r.passed]
61
+ if failed_results:
62
+ lines.extend(["", "## Failure Details", ""])
63
+ for r in failed_results:
64
+ lines.append(f"### {r.name}")
65
+ lines.append("")
66
+ if r.is_error:
67
+ lines.append(f"**Error:** `{r.error}`")
68
+ else:
69
+ for f in r.failures:
70
+ lines.append(f"- {f}")
71
+ if r.response_body:
72
+ lines.extend([
73
+ "",
74
+ "<details><summary>Response Body</summary>",
75
+ "",
76
+ "```json",
77
+ r.response_body[:1000],
78
+ "```",
79
+ "",
80
+ "</details>",
81
+ ])
82
+ lines.append("")
83
+
84
+ report = "\n".join(lines)
85
+
86
+ output_path = Path(output_path)
87
+ output_path.parent.mkdir(parents=True, exist_ok=True)
88
+ output_path.write_text(report, encoding="utf-8")
89
+
90
+ return report
91
+
92
+
93
+ def collect_failures(results: list["TestResult"]) -> list[dict]:
94
+ """Extract failed/error tests as structured dicts for issue creation."""
95
+ failures = []
96
+ for r in results:
97
+ if r.passed:
98
+ continue
99
+ entry = {
100
+ "name": r.name,
101
+ "method": r.request_method,
102
+ "url": r.request_url,
103
+ "status_code": r.status_code,
104
+ "duration_ms": r.duration_ms,
105
+ }
106
+ if r.is_error:
107
+ entry["type"] = "ERROR"
108
+ entry["detail"] = r.error
109
+ else:
110
+ entry["type"] = "FAIL"
111
+ entry["detail"] = "\n".join(f"- {f}" for f in r.failures)
112
+ if r.response_body:
113
+ entry["response_body"] = r.response_body[:500]
114
+ failures.append(entry)
115
+ return failures
@@ -0,0 +1,164 @@
1
+ """HTTP request runner with variable context management."""
2
+
3
+ import json as json_module
4
+ import re
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ import requests
10
+
11
+ from .assertions import assert_headers, assert_json, assert_status_code
12
+ from .parser import resolve_vars
13
+
14
+
15
+ @dataclass
16
+ class TestResult:
17
+ """Result of a single test case execution."""
18
+ name: str
19
+ passed: bool
20
+ status_code: int = 0
21
+ duration_ms: float = 0.0
22
+ request_url: str = ""
23
+ request_method: str = ""
24
+ response_body: str = ""
25
+ failures: list[str] = field(default_factory=list)
26
+ error: str = ""
27
+ is_error: bool = False
28
+ response_json: Any = None
29
+
30
+
31
+ class Runner:
32
+ """Executes API test cases sequentially with variable context."""
33
+
34
+ def __init__(self, config: dict):
35
+ self.base_url = config.get("base_url", "").rstrip("/")
36
+ self.timeout = config.get("timeout", 30)
37
+ self.default_headers = config.get("headers", {})
38
+ self.session = requests.Session()
39
+ self.session.headers.update(self.default_headers)
40
+ self.variables: dict[str, str] = {}
41
+
42
+ def run_tests(self, tests: list[dict]) -> list[TestResult]:
43
+ """Run all test cases in order, maintaining variable context."""
44
+ results = []
45
+ for test in tests:
46
+ result = self._run_single(test)
47
+ results.append(result)
48
+ if not result.is_error:
49
+ self._extract_vars(test, result)
50
+ return results
51
+
52
+ def _run_single(self, test: dict) -> TestResult:
53
+ """Execute a single test case."""
54
+ name = test["name"]
55
+ req_spec = test.get("request", {})
56
+ expect = test.get("expect", {})
57
+
58
+ req_spec = resolve_vars(req_spec, self.variables)
59
+ expect = resolve_vars(expect, self.variables)
60
+
61
+ method = req_spec.get("method", "GET").upper()
62
+ url = req_spec.get("url", "")
63
+ if self.base_url and not url.startswith(("http://", "https://")):
64
+ url = f"{self.base_url}{url}"
65
+
66
+ headers = req_spec.get("headers", {})
67
+ params = req_spec.get("params", {})
68
+ json_body = req_spec.get("json")
69
+ body = req_spec.get("body")
70
+
71
+ start = time.monotonic()
72
+ try:
73
+ resp = self.session.request(
74
+ method=method,
75
+ url=url,
76
+ headers=headers,
77
+ params=params,
78
+ json=json_body,
79
+ data=body,
80
+ timeout=self.timeout,
81
+ )
82
+ except requests.RequestException as e:
83
+ duration = (time.monotonic() - start) * 1000
84
+ return TestResult(
85
+ name=name,
86
+ passed=False,
87
+ request_url=url,
88
+ request_method=method,
89
+ duration_ms=round(duration, 2),
90
+ error=str(e),
91
+ is_error=True,
92
+ )
93
+
94
+ duration = (time.monotonic() - start) * 1000
95
+ failures = []
96
+
97
+ if "status_code" in expect:
98
+ msg = assert_status_code(expect["status_code"], resp.status_code)
99
+ if msg:
100
+ failures.append(msg)
101
+
102
+ resp_json = None
103
+ if "json" in expect:
104
+ try:
105
+ resp_json = resp.json()
106
+ failures.extend(assert_json(expect["json"], resp_json))
107
+ except ValueError:
108
+ failures.append("Response body is not valid JSON")
109
+
110
+ if "headers" in expect:
111
+ failures.extend(assert_headers(expect["headers"], dict(resp.headers)))
112
+
113
+ try:
114
+ resp_body = resp.text[:2000]
115
+ except Exception:
116
+ resp_body = ""
117
+
118
+ return TestResult(
119
+ name=name,
120
+ passed=len(failures) == 0,
121
+ status_code=resp.status_code,
122
+ duration_ms=round(duration, 2),
123
+ request_url=url,
124
+ request_method=method,
125
+ response_body=resp_body,
126
+ failures=failures,
127
+ response_json=resp_json,
128
+ )
129
+
130
+ def _resolve_extract_path(self, data: Any, path: str) -> Any:
131
+ """Resolve a dotted path to extract a value from JSON data."""
132
+ parts = path.split(".")
133
+ current = data
134
+ for part in parts:
135
+ if isinstance(current, dict) and part in current:
136
+ current = current[part]
137
+ elif isinstance(current, list) and part.isdigit():
138
+ idx = int(part)
139
+ if idx < len(current):
140
+ current = current[idx]
141
+ else:
142
+ return None
143
+ else:
144
+ return None
145
+ return current
146
+
147
+ def _extract_vars(self, test: dict, result: TestResult) -> None:
148
+ """Extract variables from response for subsequent tests."""
149
+ extract = test.get("extract", {})
150
+ if not extract:
151
+ return
152
+
153
+ resp_json = result.response_json
154
+ if resp_json is None:
155
+ try:
156
+ resp_json = json_module.loads(result.response_body)
157
+ except (json_module.JSONDecodeError, TypeError):
158
+ return
159
+
160
+ for var_name, json_path in extract.items():
161
+ json_path = resolve_vars(json_path, self.variables)
162
+ value = self._resolve_extract_path(resp_json, json_path)
163
+ if value is not None:
164
+ self.variables[var_name] = str(value)