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,20 @@
1
+ """
2
+ pytest-api-core: Reusable API automation framework for pytest.
3
+ Auto-registered as a pytest plugin via entry_points["pytest11"].
4
+ """
5
+
6
+ from pytest_api_core.client.api_client import APIClient
7
+ from pytest_api_core.client.api_response import APIResponse
8
+ from pytest_api_core.assertions.response_assertions import assert_that
9
+ from pytest_api_core.auth.auth_handlers import BearerAuth, BasicAuth, APIKeyAuth, OAuth2ClientCredentials
10
+
11
+ __version__ = "1.0.0"
12
+ __all__ = [
13
+ "APIClient",
14
+ "APIResponse",
15
+ "assert_that",
16
+ "BearerAuth",
17
+ "BasicAuth",
18
+ "APIKeyAuth",
19
+ "OAuth2ClientCredentials",
20
+ ]
@@ -0,0 +1,3 @@
1
+ from pytest_api_core.assertions.response_assertions import ResponseAssertions, assert_that
2
+
3
+ __all__ = ["ResponseAssertions", "assert_that"]
@@ -0,0 +1,350 @@
1
+ """
2
+ Fluent (chainable) response assertion API.
3
+
4
+ Usage
5
+ -----
6
+ from pytest_api_core.assertions import assert_that
7
+
8
+ assert_that(response).status_is(200)
9
+ assert_that(response).status_is(200).has_key("id").json_path("$.name").equals("Alice")
10
+ assert_that(response).content_type_contains("application/json")
11
+ assert_that(response).response_time_under(500) # ms
12
+
13
+ Every assertion method returns *self* so calls can be chained freely.
14
+ A clear AssertionError with context is raised on failure.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import logging
20
+ import re
21
+ from typing import Any
22
+
23
+ from pytest_api_core.client.api_response import APIResponse
24
+
25
+ _assert_log = logging.getLogger("pytest_api_core.assertions")
26
+
27
+
28
+ def _emit(name: str, passed: bool, **detail: Any) -> None:
29
+ """Emit a machine-readable sentinel line captured by HTMLReporter."""
30
+ _assert_log.debug(
31
+ "__API_ASSERT__ %s",
32
+ json.dumps({"name": name, "passed": passed, **detail}, separators=(",", ":")),
33
+ )
34
+
35
+
36
+ class ResponseAssertions:
37
+ """Chainable assertion wrapper for APIResponse objects."""
38
+
39
+ def __init__(self, response: APIResponse) -> None:
40
+ self._response = response
41
+ # jsonpath-ng is optional; fall back to simple key lookup if absent
42
+ self._json_path_value: Any = _UNSET
43
+
44
+ # ------------------------------------------------------------------
45
+ # Status code
46
+ # ------------------------------------------------------------------
47
+
48
+ def status_is(self, expected: int) -> "ResponseAssertions":
49
+ """Assert the HTTP status code equals *expected*."""
50
+ actual = self._response.status_code
51
+ passed = actual == expected
52
+ _emit("status_is", passed, expected=expected, actual=actual)
53
+ if not passed:
54
+ raise AssertionError(
55
+ f"Expected status {expected}, got {actual}.\n"
56
+ f" URL: {self._response.url}\n"
57
+ f" Body: {self._response.response_body_text()[:500]}"
58
+ )
59
+ return self
60
+
61
+ def status_in(self, *expected: int) -> "ResponseAssertions":
62
+ """Assert the status code is one of *expected*."""
63
+ actual = self._response.status_code
64
+ passed = actual in expected
65
+ _emit("status_in", passed, expected=list(expected), actual=actual)
66
+ if not passed:
67
+ raise AssertionError(
68
+ f"Expected status in {expected}, got {actual}.\n"
69
+ f" URL: {self._response.url}"
70
+ )
71
+ return self
72
+
73
+ def is_success(self) -> "ResponseAssertions":
74
+ """Assert 2xx status code."""
75
+ actual = self._response.status_code
76
+ passed = 200 <= actual < 300
77
+ _emit("is_success", passed, actual=actual)
78
+ if not passed:
79
+ raise AssertionError(
80
+ f"Expected 2xx status, got {actual}.\n"
81
+ f" URL: {self._response.url}\n"
82
+ f" Body: {self._response.response_body_text()[:500]}"
83
+ )
84
+ return self
85
+
86
+ def is_client_error(self) -> "ResponseAssertions":
87
+ """Assert 4xx status code."""
88
+ actual = self._response.status_code
89
+ if not (400 <= actual < 500):
90
+ raise AssertionError(f"Expected 4xx status, got {actual}.")
91
+ return self
92
+
93
+ def is_server_error(self) -> "ResponseAssertions":
94
+ """Assert 5xx status code."""
95
+ actual = self._response.status_code
96
+ if not (500 <= actual < 600):
97
+ raise AssertionError(f"Expected 5xx status, got {actual}.")
98
+ return self
99
+
100
+ # ------------------------------------------------------------------
101
+ # Headers
102
+ # ------------------------------------------------------------------
103
+
104
+ def has_header(self, name: str) -> "ResponseAssertions":
105
+ """Assert a response header with *name* is present (case-insensitive)."""
106
+ if name.lower() not in {k.lower() for k in self._response.headers}:
107
+ raise AssertionError(
108
+ f"Expected header '{name}' to be present.\n"
109
+ f" Headers: {dict(self._response.headers)}"
110
+ )
111
+ return self
112
+
113
+ def header_equals(self, name: str, expected: str) -> "ResponseAssertions":
114
+ """Assert header *name* equals *expected* (case-insensitive name match)."""
115
+ actual = self._response.headers.get(name)
116
+ if actual != expected:
117
+ raise AssertionError(
118
+ f"Expected header '{name}' = {expected!r}, got {actual!r}."
119
+ )
120
+ return self
121
+
122
+ def content_type_contains(self, fragment: str) -> "ResponseAssertions":
123
+ """Assert the Content-Type header contains *fragment*."""
124
+ ct = self._response.headers.get("Content-Type", "")
125
+ if fragment.lower() not in ct.lower():
126
+ raise AssertionError(
127
+ f"Expected Content-Type to contain {fragment!r}, got {ct!r}."
128
+ )
129
+ return self
130
+
131
+ # ------------------------------------------------------------------
132
+ # JSON body — top-level keys
133
+ # ------------------------------------------------------------------
134
+
135
+ def has_key(self, key: str) -> "ResponseAssertions":
136
+ """Assert the JSON body contains *key* at the top level."""
137
+ body = self._json_body()
138
+ passed = isinstance(body, dict) and key in body
139
+ _emit("has_key", passed, key=key)
140
+ if not passed:
141
+ raise AssertionError(
142
+ f"Expected JSON key '{key}' to be present.\n"
143
+ f" Body keys: {list(body.keys()) if isinstance(body, dict) else body}"
144
+ )
145
+ return self
146
+
147
+ def key_equals(self, key: str, expected: Any) -> "ResponseAssertions":
148
+ """Assert top-level JSON key *key* equals *expected*."""
149
+ body = self._json_body()
150
+ if not isinstance(body, dict):
151
+ raise AssertionError(f"Response body is not a JSON object: {body!r}")
152
+ actual = body.get(key, _UNSET)
153
+ if actual is _UNSET:
154
+ _emit("key_equals", False, key=key, expected=expected, actual="<missing>")
155
+ raise AssertionError(f"Key '{key}' not found in response body.")
156
+ passed = actual == expected
157
+ _emit("key_equals", passed, key=key, expected=expected, actual=actual)
158
+ if not passed:
159
+ raise AssertionError(
160
+ f"Expected body['{key}'] = {expected!r}, got {actual!r}."
161
+ )
162
+ return self
163
+
164
+ def body_contains(self, text: str) -> "ResponseAssertions":
165
+ """Assert the raw response text contains *text*."""
166
+ if text not in self._response.text:
167
+ raise AssertionError(
168
+ f"Expected response body to contain {text!r}.\n"
169
+ f" Body (first 300 chars): {self._response.text[:300]}"
170
+ )
171
+ return self
172
+
173
+ def is_json_list(self) -> "ResponseAssertions":
174
+ """Assert the JSON body is an array."""
175
+ body = self._json_body()
176
+ if not isinstance(body, list):
177
+ raise AssertionError(f"Expected JSON array, got {type(body).__name__}.")
178
+ return self
179
+
180
+ def list_length(self, expected: int) -> "ResponseAssertions":
181
+ """Assert the JSON array body has exactly *expected* items."""
182
+ body = self._json_body()
183
+ if not isinstance(body, list):
184
+ raise AssertionError(f"Expected JSON array, got {type(body).__name__}.")
185
+ if len(body) != expected:
186
+ raise AssertionError(
187
+ f"Expected JSON list length {expected}, got {len(body)}."
188
+ )
189
+ return self
190
+
191
+ def list_length_gte(self, minimum: int) -> "ResponseAssertions":
192
+ """Assert the JSON array length is at least *minimum*."""
193
+ body = self._json_body()
194
+ if not isinstance(body, list) or len(body) < minimum:
195
+ raise AssertionError(
196
+ f"Expected JSON list length >= {minimum}, got {len(body) if isinstance(body, list) else 'non-list'}."
197
+ )
198
+ return self
199
+
200
+ # ------------------------------------------------------------------
201
+ # JSONPath navigation (simple dot-notation; no external lib required)
202
+ # ------------------------------------------------------------------
203
+
204
+ def json_path(self, path: str) -> "ResponseAssertions":
205
+ """
206
+ Navigate to a value in the JSON body using a simple path expression.
207
+
208
+ Supported syntax:
209
+ ``$.key`` — top-level key
210
+ ``$.key.nested`` — nested key
211
+ ``$.items[0].id`` — array index
212
+ ``$[0].id`` — root is array
213
+
214
+ The extracted value is stored for the following ``.equals()`` /
215
+ ``.matches()`` call.
216
+ """
217
+ body = self._json_body()
218
+ self._json_path_value = _resolve_path(body, path)
219
+ return self
220
+
221
+ def equals(self, expected: Any) -> "ResponseAssertions":
222
+ """Assert the value from the last ``.json_path()`` call equals *expected*."""
223
+ if self._json_path_value is _UNSET:
224
+ raise RuntimeError("Call .json_path() before .equals()")
225
+ actual = self._json_path_value
226
+ self._json_path_value = _UNSET
227
+ if actual != expected:
228
+ raise AssertionError(f"Expected {expected!r}, got {actual!r}.")
229
+ return self
230
+
231
+ def matches(self, pattern: str) -> "ResponseAssertions":
232
+ """Assert the value from the last ``.json_path()`` call matches regex *pattern*."""
233
+ if self._json_path_value is _UNSET:
234
+ raise RuntimeError("Call .json_path() before .matches()")
235
+ actual = str(self._json_path_value)
236
+ self._json_path_value = _UNSET
237
+ if not re.search(pattern, actual):
238
+ raise AssertionError(
239
+ f"Expected value to match /{pattern}/, got {actual!r}."
240
+ )
241
+ return self
242
+
243
+ def is_not_none(self) -> "ResponseAssertions":
244
+ """Assert the value from the last ``.json_path()`` call is not None."""
245
+ if self._json_path_value is _UNSET:
246
+ raise RuntimeError("Call .json_path() before .is_not_none()")
247
+ actual = self._json_path_value
248
+ self._json_path_value = _UNSET
249
+ if actual is None:
250
+ raise AssertionError("Expected value to be non-null.")
251
+ return self
252
+
253
+ # ------------------------------------------------------------------
254
+ # Performance
255
+ # ------------------------------------------------------------------
256
+
257
+ def response_time_under(self, max_ms: float) -> "ResponseAssertions":
258
+ """Assert the response time is under *max_ms* milliseconds."""
259
+ actual = self._response.elapsed_ms
260
+ passed = actual < max_ms
261
+ _emit("response_time_under", passed, max_ms=max_ms, actual_ms=round(actual, 1))
262
+ if not passed:
263
+ raise AssertionError(
264
+ f"Expected response time < {max_ms}ms, got {actual:.1f}ms.\n"
265
+ f" URL: {self._response.url}"
266
+ )
267
+ return self
268
+
269
+ # ------------------------------------------------------------------
270
+ # Schema validation (uses jsonschema if available)
271
+ # ------------------------------------------------------------------
272
+
273
+ def matches_schema(self, schema: dict[str, Any]) -> "ResponseAssertions":
274
+ """Assert the JSON body conforms to the given JSON Schema."""
275
+ try:
276
+ import jsonschema
277
+
278
+ jsonschema.validate(instance=self._json_body(), schema=schema)
279
+ except ImportError:
280
+ raise ImportError(
281
+ "jsonschema is required for schema validation: pip install jsonschema"
282
+ )
283
+ except jsonschema.ValidationError as exc:
284
+ raise AssertionError(f"Schema validation failed: {exc.message}") from exc
285
+ return self
286
+
287
+ # ------------------------------------------------------------------
288
+ # Internal helpers
289
+ # ------------------------------------------------------------------
290
+
291
+ def _json_body(self) -> Any:
292
+ try:
293
+ return self._response.json()
294
+ except (ValueError, TypeError) as exc:
295
+ raise AssertionError(
296
+ f"Response body is not valid JSON.\n"
297
+ f" Status: {self._response.status_code}\n"
298
+ f" Body: {self._response.text[:200]}"
299
+ ) from exc
300
+
301
+
302
+ # ---------------------------------------------------------------------------
303
+ # Sentinel & helpers
304
+ # ---------------------------------------------------------------------------
305
+
306
+
307
+ class _Unset:
308
+ """Sentinel for unset json_path value."""
309
+ def __repr__(self) -> str:
310
+ return "<UNSET>"
311
+
312
+
313
+ _UNSET = _Unset()
314
+
315
+
316
+ def _resolve_path(data: Any, path: str) -> Any:
317
+ """Resolve a simple ``$.key.nested[0]`` path against *data*."""
318
+ # Strip leading $
319
+ path = path.lstrip("$")
320
+ # Tokenise on "." and "[N]"
321
+ tokens: list[str | int] = []
322
+ for segment in re.split(r"\.|\[(\d+)\]", path):
323
+ if segment is None or segment == "":
324
+ continue
325
+ try:
326
+ tokens.append(int(segment))
327
+ except ValueError:
328
+ tokens.append(segment)
329
+
330
+ current = data
331
+ for token in tokens:
332
+ if current is None:
333
+ raise AssertionError(f"Path traversal failed at token {token!r}: value is null.")
334
+ try:
335
+ current = current[token]
336
+ except (KeyError, IndexError, TypeError) as exc:
337
+ raise AssertionError(
338
+ f"Path token {token!r} not found in {type(current).__name__}: {current!r}"
339
+ ) from exc
340
+ return current
341
+
342
+
343
+ # ---------------------------------------------------------------------------
344
+ # Public factory
345
+ # ---------------------------------------------------------------------------
346
+
347
+
348
+ def assert_that(response: APIResponse) -> ResponseAssertions:
349
+ """Entry-point for the fluent assertion chain."""
350
+ return ResponseAssertions(response)
@@ -0,0 +1,8 @@
1
+ from pytest_api_core.auth.auth_handlers import (
2
+ BearerAuth,
3
+ BasicAuth,
4
+ APIKeyAuth,
5
+ OAuth2ClientCredentials,
6
+ )
7
+
8
+ __all__ = ["BearerAuth", "BasicAuth", "APIKeyAuth", "OAuth2ClientCredentials"]
@@ -0,0 +1,156 @@
1
+ """
2
+ Authentication strategy classes compatible with requests.auth.AuthBase.
3
+
4
+ Usage
5
+ -----
6
+ client = APIClient(base_url, auth=BearerAuth("my-token"))
7
+ client = APIClient(base_url, auth=BasicAuth("user", "pass"))
8
+ client = APIClient(base_url, auth=APIKeyAuth("x-api-key", "secret"))
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import time
14
+ from typing import Any
15
+
16
+ import requests
17
+ from requests.auth import AuthBase
18
+
19
+ log = logging.getLogger("pytest_api_core.auth")
20
+
21
+
22
+ class BearerAuth(AuthBase):
23
+ """Injects ``Authorization: Bearer <token>`` into every request."""
24
+
25
+ def __init__(self, token: str) -> None:
26
+ self._token = token
27
+
28
+ def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest:
29
+ r.headers["Authorization"] = f"Bearer {self._token}"
30
+ return r
31
+
32
+ def __repr__(self) -> str:
33
+ return f"<BearerAuth token=***{self._token[-4:]}>"
34
+
35
+
36
+ class BasicAuth(AuthBase):
37
+ """
38
+ HTTP Basic Authentication.
39
+ Delegates to requests' built-in HTTPBasicAuth but subclasses AuthBase
40
+ so it integrates cleanly with the fixture system.
41
+ """
42
+
43
+ def __init__(self, username: str, password: str) -> None:
44
+ self._inner = requests.auth.HTTPBasicAuth(username, password)
45
+
46
+ def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest:
47
+ return self._inner(r)
48
+
49
+ def __repr__(self) -> str:
50
+ return f"<BasicAuth username={self._inner.username!r}>"
51
+
52
+
53
+ class APIKeyAuth(AuthBase):
54
+ """
55
+ Injects an API key either as a request header or a query parameter.
56
+
57
+ Parameters
58
+ ----------
59
+ name:
60
+ Header name (e.g. ``x-api-key``) or query-parameter name.
61
+ value:
62
+ The API key value.
63
+ location:
64
+ ``"header"`` (default) or ``"query"``.
65
+ """
66
+
67
+ def __init__(self, name: str, value: str, location: str = "header") -> None:
68
+ if location not in ("header", "query"):
69
+ raise ValueError("location must be 'header' or 'query'")
70
+ self._name = name
71
+ self._value = value
72
+ self._location = location
73
+
74
+ def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest:
75
+ if self._location == "header":
76
+ r.headers[self._name] = self._value
77
+ else:
78
+ # Append to existing query string
79
+ from urllib.parse import urlencode, urlparse, urlunparse, parse_qsl
80
+
81
+ parsed = urlparse(r.url or "")
82
+ params = parse_qsl(parsed.query)
83
+ params.append((self._name, self._value))
84
+ r.url = urlunparse(parsed._replace(query=urlencode(params)))
85
+ return r
86
+
87
+ def __repr__(self) -> str:
88
+ return f"<APIKeyAuth name={self._name!r} location={self._location!r}>"
89
+
90
+
91
+ class OAuth2ClientCredentials(AuthBase):
92
+ """
93
+ Fetches and caches an OAuth2 access token using the *client credentials* flow.
94
+ Automatically refreshes when the token is within ``refresh_buffer`` seconds of expiry.
95
+
96
+ Parameters
97
+ ----------
98
+ token_url:
99
+ Full URL of the token endpoint.
100
+ client_id:
101
+ OAuth2 client ID.
102
+ client_secret:
103
+ OAuth2 client secret.
104
+ scope:
105
+ Space-separated list of scopes (optional).
106
+ refresh_buffer:
107
+ Seconds before expiry to proactively refresh (default 30).
108
+ """
109
+
110
+ def __init__(
111
+ self,
112
+ token_url: str,
113
+ client_id: str,
114
+ client_secret: str,
115
+ scope: str = "",
116
+ refresh_buffer: int = 30,
117
+ ) -> None:
118
+ self._token_url = token_url
119
+ self._client_id = client_id
120
+ self._client_secret = client_secret
121
+ self._scope = scope
122
+ self._refresh_buffer = refresh_buffer
123
+
124
+ self._access_token: str | None = None
125
+ self._expires_at: float = 0.0
126
+
127
+ def _fetch_token(self) -> None:
128
+ data: dict[str, str] = {
129
+ "grant_type": "client_credentials",
130
+ "client_id": self._client_id,
131
+ "client_secret": self._client_secret,
132
+ }
133
+ if self._scope:
134
+ data["scope"] = self._scope
135
+
136
+ log.debug("Fetching OAuth2 token from %s", self._token_url)
137
+ response = requests.post(self._token_url, data=data, timeout=15)
138
+ response.raise_for_status()
139
+
140
+ payload: dict[str, Any] = response.json()
141
+ self._access_token = payload["access_token"]
142
+ expires_in: int = payload.get("expires_in", 3600)
143
+ self._expires_at = time.monotonic() + expires_in
144
+ log.debug("OAuth2 token acquired, expires in %ds", expires_in)
145
+
146
+ def _is_expired(self) -> bool:
147
+ return time.monotonic() >= (self._expires_at - self._refresh_buffer)
148
+
149
+ def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest:
150
+ if self._access_token is None or self._is_expired():
151
+ self._fetch_token()
152
+ r.headers["Authorization"] = f"Bearer {self._access_token}"
153
+ return r
154
+
155
+ def __repr__(self) -> str:
156
+ return f"<OAuth2ClientCredentials client_id={self._client_id!r}>"
@@ -0,0 +1,4 @@
1
+ from pytest_api_core.client.api_client import APIClient
2
+ from pytest_api_core.client.api_response import APIResponse
3
+
4
+ __all__ = ["APIClient", "APIResponse"]