pytest-api-core 1.0.1__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,133 @@
1
+ """
2
+ Auto-registered pytest fixtures provided by pytest-api-core.
3
+
4
+ These fixtures are available in every test suite that has the package
5
+ installed — no ``conftest.py`` import required.
6
+
7
+ Fixtures
8
+ --------
9
+ api_config (session) — resolved config dict for the target environment
10
+ api_client (session) — configured APIClient instance
11
+ api_bearer_auth (function) — BearerAuth instance (token from cfg or API_TOKEN)
12
+ api_basic_auth (function) — BasicAuth instance (from API_USERNAME / API_PASSWORD)
13
+ api_key_auth (function) — APIKeyAuth instance
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from typing import Any, Generator
19
+
20
+ import pytest
21
+
22
+ from pytest_api_core.auth.auth_handlers import APIKeyAuth, BasicAuth, BearerAuth
23
+ from pytest_api_core.client.api_client import APIClient
24
+ from pytest_api_core.config.config_manager import ConfigManager
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Config fixture
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ @pytest.fixture(scope="session")
33
+ def api_config(request: pytest.FixtureRequest) -> dict[str, Any]:
34
+ """
35
+ Returns the fully-resolved configuration dict for the active environment.
36
+
37
+ Override via CLI: ``--api-env=staging --api-config-dir=config/env``
38
+ Override via ini: ``api_env = staging``
39
+ Override via env-var: ``API_ENV=staging``
40
+ """
41
+ env: str | None = (
42
+ request.config.getoption("--api-env", default=None)
43
+ or request.config.getini("api_env")
44
+ or None
45
+ )
46
+ base_url_override: str | None = request.config.getoption("--api-base-url", default=None)
47
+ settings_module: str | None = request.config.getini("api_settings_module") or None
48
+
49
+ manager = ConfigManager(
50
+ env=env,
51
+ cli_overrides={"base_url": base_url_override} if base_url_override else None,
52
+ settings_module=settings_module,
53
+ )
54
+ return manager.load()
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Client fixture
59
+ # ---------------------------------------------------------------------------
60
+
61
+
62
+ @pytest.fixture(scope="session")
63
+ def api_client(api_config: dict[str, Any]) -> Generator[APIClient, None, None]:
64
+ """
65
+ Session-scoped APIClient built from *api_config*.
66
+
67
+ Automatically applies a BearerAuth if ``API_TOKEN`` env-var is set
68
+ or ``api_config["_token"]`` is present.
69
+ """
70
+ auth = None
71
+ token = api_config.pop("_token", None) or os.environ.get("API_TOKEN")
72
+ if token:
73
+ auth = BearerAuth(token)
74
+
75
+ client = APIClient(
76
+ base_url=api_config["base_url"],
77
+ auth=auth,
78
+ timeout=api_config.get("timeout", 30),
79
+ verify_ssl=api_config.get("verify_ssl", True),
80
+ default_headers=api_config.get("headers"),
81
+ )
82
+ yield client
83
+ client.close()
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Auth fixtures (function-scoped so tests can customise per-test)
88
+ # ---------------------------------------------------------------------------
89
+
90
+
91
+ @pytest.fixture()
92
+ def api_bearer_auth(api_config: dict[str, Any]) -> BearerAuth:
93
+ """
94
+ BearerAuth built from ``API_TOKEN`` env-var or ``api_config["token"]``.
95
+ Raises if no token is available.
96
+ """
97
+ token = os.environ.get("API_TOKEN") or api_config.get("token")
98
+ if not token:
99
+ raise ValueError(
100
+ "api_bearer_auth requires an API token. "
101
+ "Set the API_TOKEN environment variable or add 'token' to your env config."
102
+ )
103
+ return BearerAuth(token)
104
+
105
+
106
+ @pytest.fixture()
107
+ def api_basic_auth() -> BasicAuth:
108
+ """
109
+ BasicAuth built from ``API_USERNAME`` / ``API_PASSWORD`` env-vars.
110
+ Raises if either is missing.
111
+ """
112
+ username = os.environ.get("API_USERNAME", "")
113
+ password = os.environ.get("API_PASSWORD", "")
114
+ if not username or not password:
115
+ raise ValueError(
116
+ "api_basic_auth requires API_USERNAME and API_PASSWORD environment variables."
117
+ )
118
+ return BasicAuth(username, password)
119
+
120
+
121
+ @pytest.fixture()
122
+ def api_key_auth() -> APIKeyAuth:
123
+ """
124
+ APIKeyAuth built from ``API_KEY_NAME`` / ``API_KEY_VALUE`` / ``API_KEY_LOCATION`` env-vars.
125
+ """
126
+ name = os.environ.get("API_KEY_NAME", "x-api-key")
127
+ value = os.environ.get("API_KEY_VALUE", "")
128
+ location = os.environ.get("API_KEY_LOCATION", "header")
129
+ if not value:
130
+ raise ValueError(
131
+ "api_key_auth requires API_KEY_VALUE environment variable."
132
+ )
133
+ return APIKeyAuth(name, value, location)
@@ -0,0 +1,125 @@
1
+ """
2
+ pytest plugin entry-point.
3
+ Registered via [project.entry-points."pytest11"] in pyproject.toml so pytest
4
+ auto-discovers and loads it without any conftest.py changes in consuming projects.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import datetime
9
+ import logging
10
+
11
+ import pytest
12
+
13
+ from pytest_api_core.config.env_loader import load_env_file
14
+ from pytest_api_core.fixtures.api_fixtures import (
15
+ api_config,
16
+ api_client,
17
+ api_bearer_auth,
18
+ api_basic_auth,
19
+ api_key_auth,
20
+ )
21
+ from pytest_api_core.reporters.html_reporter import HTMLReporter
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # CLI options
26
+ # ---------------------------------------------------------------------------
27
+
28
+
29
+ def pytest_addoption(parser: pytest.Parser) -> None:
30
+ group = parser.getgroup("pytest-api-core", "API framework options")
31
+ group.addoption(
32
+ "--api-env",
33
+ action="store",
34
+ default=None,
35
+ help="Target environment name (maps to config/env/<name>.yaml)",
36
+ )
37
+ group.addoption(
38
+ "--api-config-dir",
39
+ action="store",
40
+ default="config/env",
41
+ help="Directory containing environment YAML files (default: config/env)",
42
+ )
43
+ group.addoption(
44
+ "--api-html-report",
45
+ action="store",
46
+ default=None,
47
+ metavar="PATH",
48
+ help="Path for the custom HTML report (e.g. reports/report.html)",
49
+ )
50
+ group.addoption(
51
+ "--api-base-url",
52
+ action="store",
53
+ default=None,
54
+ help="Override base_url from config",
55
+ )
56
+ group.addoption(
57
+ "--api-log-level",
58
+ action="store",
59
+ default=None,
60
+ choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
61
+ help="Log level for pytest-api-core internals (default: WARNING)",
62
+ )
63
+ # Register ini options to suppress "Unknown config option" warnings
64
+ parser.addini("api_env", help="Default environment (e.g. dev, staging, prod)", default="dev")
65
+ parser.addini("api_html_report", help="Output path for the custom HTML report", default=None)
66
+ parser.addini("api_html_theme", help="Report theme: light or dark (default: dark)", default="dark")
67
+ parser.addini("api_html_title", help="Report browser tab title (default: API Test Report)", default="API Test Report")
68
+ parser.addini("api_html_header", help="Report page header text (default: API Test Report)", default="API Test Report")
69
+ parser.addini("api_log_level", help="Log level for pytest-api-core (DEBUG/INFO/WARNING/ERROR/CRITICAL)", default="WARNING")
70
+ parser.addini("api_settings_module", help="Dotted module path to settings (e.g. config.settings)", default=None)
71
+ parser.addini("api_dotenv_file", help="Path to .env file loaded at session start (default: .env)", default=".env")
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # ini options (allow pytest.ini / pyproject.toml [tool.pytest.ini_options])
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ def pytest_configure(config: pytest.Config) -> None:
80
+ config.addinivalue_line("markers", "api: mark test as an API test")
81
+
82
+ # Load .env file before anything else so os.environ is fully populated
83
+ # for settings.py class attributes and fixtures
84
+ dotenv_path = config.getini("api_dotenv_file") or ".env"
85
+ load_env_file(dotenv_path)
86
+
87
+ # Configure the framework logger level from CLI option or ini
88
+ log_level = (
89
+ config.getoption("--api-log-level", default=None)
90
+ or config.getini("api_log_level")
91
+ or "WARNING"
92
+ )
93
+ logging.getLogger("pytest_api_core").setLevel(log_level.upper())
94
+
95
+ report_path = config.getoption("--api-html-report", default=None)
96
+ if not report_path:
97
+ report_path = config.getini("api_html_report")
98
+ if report_path:
99
+ env = (
100
+ config.getoption("--api-env", default=None)
101
+ or config.getini("api_env")
102
+ or "default"
103
+ )
104
+ report_path = _resolve_report_path(report_path, env)
105
+ plugin = HTMLReporter(report_path, config)
106
+ config.pluginmanager.register(plugin, "api-html-reporter")
107
+
108
+
109
+ def _resolve_report_path(path: str, env: str) -> str:
110
+ """Expand {timestamp} and {env} placeholders in the report path."""
111
+ timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
112
+ return path.format(timestamp=timestamp, env=env)
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Re-export fixtures so pytest can discover them from this module
117
+ # ---------------------------------------------------------------------------
118
+
119
+ __all__ = [
120
+ "api_config",
121
+ "api_client",
122
+ "api_bearer_auth",
123
+ "api_basic_auth",
124
+ "api_key_auth",
125
+ ]
@@ -0,0 +1,3 @@
1
+ from pytest_api_core.reporters.html_reporter import HTMLReporter
2
+
3
+ __all__ = ["HTMLReporter"]
@@ -0,0 +1,245 @@
1
+ """
2
+ Custom HTML reporter for pytest-api-core.
3
+
4
+ Generates a single self-contained HTML file from a Jinja2 template with:
5
+ - Summary card: total / passed / failed / error / skipped counts + duration
6
+ - SVG donut chart
7
+ - Filterable, sortable results table
8
+ - Per-test expandable panels:
9
+ • HTTP call banners (method badge, URL, status, elapsed) with
10
+ tabbed Request Headers / Req Body / Res Headers / Res Body
11
+ • Assertion result rows (✅/❌ with expected vs actual)
12
+ • Captured logs, stdout, failure traceback
13
+ - Dark/light mode toggle
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import datetime
18
+ import json
19
+ import re
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ import pytest
24
+ from jinja2 import Environment, PackageLoader
25
+
26
+
27
+ _jinja_env = Environment(
28
+ loader=PackageLoader("pytest_api_core", "reporters/templates"),
29
+ autoescape=True,
30
+ )
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Data model
35
+ # ---------------------------------------------------------------------------
36
+
37
+
38
+ class _TestRecord:
39
+ __slots__ = (
40
+ "node_id",
41
+ "name",
42
+ "outcome", # "passed" | "failed" | "error" | "skipped"
43
+ "duration", # seconds
44
+ "stdout",
45
+ "stderr",
46
+ "logs", # captured log output (displayed in template)
47
+ "longrepr", # failure text
48
+ "api_calls", # list[dict] — parsed from __API_CALL__ sentinels
49
+ "assertions", # list[dict] — parsed from __API_ASSERT__ sentinels
50
+ "markers",
51
+ )
52
+
53
+ def __init__(self, node_id: str) -> None:
54
+ self.node_id = node_id
55
+ self.name = node_id.split("::")[-1]
56
+ self.outcome = "unknown"
57
+ self.duration = 0.0
58
+ self.stdout = ""
59
+ self.stderr = ""
60
+ self.logs = ""
61
+ self.longrepr = ""
62
+ self.api_calls: list[dict[str, Any]] = []
63
+ self.assertions: list[dict[str, Any]] = []
64
+ self.markers: list[str] = []
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Plugin hooks
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ class HTMLReporter:
73
+ """pytest plugin that captures results and writes an HTML report on finish."""
74
+
75
+ def __init__(self, report_path: str, config: pytest.Config) -> None:
76
+ self._path = Path(report_path)
77
+ self._records: dict[str, _TestRecord] = {}
78
+ self._start_time: datetime.datetime = datetime.datetime.now()
79
+ self._total_duration = 0.0
80
+ self._theme = config.getini("api_html_theme") or "dark"
81
+ self._title = config.getini("api_html_title") or "API Test Report"
82
+ self._header = config.getini("api_html_header") or "API Test Report"
83
+
84
+ # -- collection ----------------------------------------------------------
85
+
86
+ def pytest_collection_finish(self, session: pytest.Session) -> None:
87
+ # Pre-register every collected item so the table order is stable
88
+ for item in session.items:
89
+ rec = _TestRecord(item.nodeid)
90
+ rec.markers = [m.name for m in item.iter_markers()]
91
+ self._records[item.nodeid] = rec
92
+
93
+ # -- per-test result capture ---------------------------------------------
94
+
95
+ def pytest_runtest_logreport(self, report: pytest.TestReport) -> None:
96
+ node_id = report.nodeid
97
+ if node_id not in self._records:
98
+ self._records[node_id] = _TestRecord(node_id)
99
+
100
+ rec = self._records[node_id]
101
+
102
+ if report.when == "call" or (report.when == "setup" and report.failed):
103
+ rec.duration = report.duration
104
+ self._total_duration += report.duration
105
+
106
+ if report.passed:
107
+ rec.outcome = "passed"
108
+ elif report.failed:
109
+ rec.outcome = "failed" if report.when == "call" else "error"
110
+ elif report.skipped:
111
+ rec.outcome = "skipped"
112
+
113
+ # Capture printed output
114
+ if report.capstdout:
115
+ rec.stdout = report.capstdout
116
+ if report.capstderr:
117
+ rec.stderr = report.capstderr
118
+
119
+ # Parse captured log sections:
120
+ # - extract __API_CALL__ and __API_ASSERT__ sentinels into structured fields
121
+ # - keep human-readable log lines for the LOGS panel
122
+ log_text = ""
123
+ for header, content in report.sections:
124
+ if "log" not in header.lower() or not content.strip():
125
+ continue
126
+ clean_lines = []
127
+ for raw_line in _strip_ansi(content).splitlines():
128
+ if "__API_CALL__" in raw_line:
129
+ try:
130
+ payload = raw_line.split("__API_CALL__", 1)[1].strip()
131
+ rec.api_calls.append(json.loads(payload))
132
+ except (ValueError, IndexError):
133
+ pass
134
+ continue # don't add to human log
135
+ if "__API_ASSERT__" in raw_line:
136
+ try:
137
+ payload = raw_line.split("__API_ASSERT__", 1)[1].strip()
138
+ rec.assertions.append(json.loads(payload))
139
+ except (ValueError, IndexError):
140
+ pass
141
+ continue # don't add to human log
142
+ clean_lines.append(raw_line)
143
+ section_text = "\n".join(clean_lines).strip()
144
+ if section_text:
145
+ log_text += f"--- {header} ---\n{section_text}\n"
146
+ rec.logs = log_text
147
+
148
+ # Capture failure text
149
+ if report.longrepr:
150
+ rec.longrepr = str(report.longrepr)
151
+
152
+ elif report.when == "setup" and report.skipped:
153
+ rec.outcome = "skipped"
154
+ if report.longrepr:
155
+ rec.longrepr = str(report.longrepr)
156
+
157
+ # -- session finish — write report ----------------------------------------
158
+
159
+ def pytest_sessionfinish(self, session: pytest.Session, exitstatus: int) -> None:
160
+ self._path.parent.mkdir(parents=True, exist_ok=True)
161
+ html_content = _render_report(
162
+ records=list(self._records.values()),
163
+ start_time=self._start_time,
164
+ total_duration=self._total_duration,
165
+ theme=self._theme,
166
+ title=self._title,
167
+ header=self._header,
168
+ )
169
+ self._path.write_text(html_content, encoding="utf-8")
170
+ # Print path relative to cwd for readability
171
+ try:
172
+ rel = self._path.relative_to(Path.cwd())
173
+ except ValueError:
174
+ rel = self._path
175
+ print(f"\n 📄 API HTML report: {rel}\n")
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Rendering
180
+ # ---------------------------------------------------------------------------
181
+
182
+
183
+ def _render_report(
184
+ records: list[_TestRecord],
185
+ start_time: datetime.datetime,
186
+ total_duration: float,
187
+ theme: str,
188
+ title: str,
189
+ header: str,
190
+ ) -> str:
191
+ counts: dict[str, int] = {"passed": 0, "failed": 0, "error": 0, "skipped": 0, "unknown": 0}
192
+ for rec in records:
193
+ counts[rec.outcome] = counts.get(rec.outcome, 0) + 1
194
+
195
+ total = len(records)
196
+ template = _jinja_env.get_template("report.html")
197
+ return template.render(
198
+ records=records,
199
+ generated_at=start_time.strftime("%Y-%m-%d %H:%M:%S"),
200
+ duration=f"{total_duration:.2f}s",
201
+ total=total,
202
+ passed=counts["passed"],
203
+ failed=counts["failed"],
204
+ error=counts["error"],
205
+ skipped=counts["skipped"],
206
+ pass_rate=f"{(counts['passed'] / total * 100):.1f}" if total else "0.0",
207
+ donut_svg=_donut_svg(counts, total),
208
+ theme=theme,
209
+ report_title=title,
210
+ report_header=header,
211
+ )
212
+
213
+
214
+ def _strip_ansi(text: str) -> str:
215
+ """Strip ANSI escape codes from a string."""
216
+ return re.sub(r"\x1b\[[0-9;]*m", "", text)
217
+
218
+
219
+ def _donut_svg(counts: dict[str, int], total: int) -> str:
220
+ if total == 0:
221
+ return '<svg width="140" height="140"><circle cx="70" cy="70" r="55" fill="none" stroke="#ccc" stroke-width="20"/></svg>'
222
+
223
+ colors = {"passed": "#22c55e", "failed": "#ef4444", "error": "#f97316", "skipped": "#94a3b8"}
224
+ r = 55
225
+ cx = cy = 70
226
+ circumference = 2 * 3.14159 * r
227
+ segments: list[str] = []
228
+ offset = 0.0
229
+
230
+ for outcome, color in colors.items():
231
+ count = counts.get(outcome, 0)
232
+ if count == 0:
233
+ continue
234
+ fraction = count / total
235
+ dash = fraction * circumference
236
+ segments.append(
237
+ f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="none" stroke="{color}" '
238
+ f'stroke-width="20" stroke-dasharray="{dash:.2f} {circumference:.2f}" '
239
+ f'stroke-dashoffset="-{offset:.2f}" transform="rotate(-90 {cx} {cy})"/>'
240
+ )
241
+ offset += dash
242
+
243
+ inner_text = f'<text x="{cx}" y="{cy+5}" text-anchor="middle" font-size="18" font-weight="bold" fill="currentColor">{total}</text>'
244
+ joined = "".join(segments)
245
+ return f'<svg width="140" height="140">{joined}{inner_text}</svg>'