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,183 @@
1
+ """
2
+ HTTP client built on top of requests.Session.
3
+
4
+ Features
5
+ --------
6
+ - Automatic base-URL resolution
7
+ - Configurable retry with back-off (via urllib3.Retry)
8
+ - Request / response logging
9
+ - Pluggable auth strategies
10
+ - Returns APIResponse objects for use with the fluent assertion API
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ import time
17
+ from typing import Any
18
+
19
+ import requests
20
+ from requests.adapters import HTTPAdapter
21
+ from urllib3.util.retry import Retry
22
+
23
+ from pytest_api_core.client.api_response import APIResponse
24
+
25
+ log = logging.getLogger("pytest_api_core.client")
26
+
27
+
28
+ _DEFAULT_RETRY = Retry(
29
+ total=3,
30
+ backoff_factor=0.3,
31
+ status_forcelist=(500, 502, 503, 504),
32
+ allowed_methods={"GET", "OPTIONS", "HEAD"},
33
+ raise_on_status=False,
34
+ )
35
+
36
+
37
+ class APIClient:
38
+ """
39
+ Session-backed HTTP client used by API tests.
40
+
41
+ Parameters
42
+ ----------
43
+ base_url:
44
+ Root URL prepended to every relative path (e.g. ``https://api.example.com/v1``).
45
+ auth:
46
+ An auth handler instance (BearerAuth, BasicAuth, APIKeyAuth, …) or any
47
+ callable accepted by requests.
48
+ timeout:
49
+ Default timeout in seconds for every request.
50
+ verify_ssl:
51
+ Whether to verify TLS certificates.
52
+ default_headers:
53
+ Headers merged into every request.
54
+ retry:
55
+ urllib3 Retry config. Pass ``None`` to disable retries.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ base_url: str,
61
+ auth: Any = None,
62
+ timeout: int | float = 30,
63
+ verify_ssl: bool = True,
64
+ default_headers: dict[str, str] | None = None,
65
+ retry: Retry | None = _DEFAULT_RETRY,
66
+ ) -> None:
67
+ self.base_url = base_url.rstrip("/")
68
+ self.timeout = timeout
69
+ self.verify_ssl = verify_ssl
70
+
71
+ self._session = requests.Session()
72
+
73
+ if auth is not None:
74
+ self._session.auth = auth
75
+
76
+ if default_headers:
77
+ self._session.headers.update(default_headers)
78
+
79
+ if retry:
80
+ adapter = HTTPAdapter(max_retries=retry)
81
+ self._session.mount("https://", adapter)
82
+ self._session.mount("http://", adapter)
83
+
84
+ # ------------------------------------------------------------------
85
+ # HTTP verbs
86
+ # ------------------------------------------------------------------
87
+
88
+ def get(self, path: str, **kwargs: Any) -> APIResponse:
89
+ return self._request("GET", path, **kwargs)
90
+
91
+ def post(self, path: str, **kwargs: Any) -> APIResponse:
92
+ return self._request("POST", path, **kwargs)
93
+
94
+ def put(self, path: str, **kwargs: Any) -> APIResponse:
95
+ return self._request("PUT", path, **kwargs)
96
+
97
+ def patch(self, path: str, **kwargs: Any) -> APIResponse:
98
+ return self._request("PATCH", path, **kwargs)
99
+
100
+ def delete(self, path: str, **kwargs: Any) -> APIResponse:
101
+ return self._request("DELETE", path, **kwargs)
102
+
103
+ def head(self, path: str, **kwargs: Any) -> APIResponse:
104
+ return self._request("HEAD", path, **kwargs)
105
+
106
+ def options(self, path: str, **kwargs: Any) -> APIResponse:
107
+ return self._request("OPTIONS", path, **kwargs)
108
+
109
+ # ------------------------------------------------------------------
110
+ # Internal
111
+ # ------------------------------------------------------------------
112
+
113
+ def _resolve_url(self, path: str) -> str:
114
+ if path.startswith(("http://", "https://")):
115
+ return path
116
+ return f"{self.base_url}/{path.lstrip('/')}"
117
+
118
+ def _request(self, method: str, path: str, **kwargs: Any) -> APIResponse:
119
+ url = self._resolve_url(path)
120
+ kwargs.setdefault("timeout", self.timeout)
121
+ kwargs.setdefault("verify", self.verify_ssl)
122
+
123
+ # DEBUG: full request detail before sending
124
+ if log.isEnabledFor(logging.DEBUG):
125
+ log.debug(
126
+ "→ %s %s headers=%s body=%s",
127
+ method,
128
+ url,
129
+ dict(self._session.headers),
130
+ kwargs.get("json") or kwargs.get("data"),
131
+ )
132
+
133
+ start = time.monotonic()
134
+ response = self._session.request(method, url, **kwargs)
135
+ elapsed_ms = (time.monotonic() - start) * 1000
136
+
137
+ api_resp = APIResponse(response, elapsed_ms)
138
+
139
+ # INFO: one-liner always shown at INFO level
140
+ log.info("← %s %s %s (%.1f ms)", method, url, response.status_code, elapsed_ms)
141
+
142
+ # DEBUG: response body
143
+ if log.isEnabledFor(logging.DEBUG):
144
+ log.debug(" response body: %s", api_resp.response_body_text())
145
+
146
+ # Structured sentinel — parsed by HTMLReporter to build request/response banners
147
+ log.debug(
148
+ "__API_CALL__ %s",
149
+ json.dumps(
150
+ {
151
+ "method": method,
152
+ "url": url,
153
+ "req_headers": {
154
+ k: v for k, v in dict(self._session.headers).items()
155
+ if k.lower() not in ("authorization",) # never log auth tokens
156
+ },
157
+ "req_body": api_resp.request_body_text()[:2000],
158
+ "status": response.status_code,
159
+ "elapsed_ms": round(elapsed_ms, 1),
160
+ "res_headers": dict(response.headers),
161
+ "res_body": api_resp.response_body_text()[:2000],
162
+ },
163
+ separators=(",", ":"),
164
+ ),
165
+ )
166
+
167
+ return api_resp
168
+
169
+ # ------------------------------------------------------------------
170
+ # Context manager support
171
+ # ------------------------------------------------------------------
172
+
173
+ def __enter__(self) -> "APIClient":
174
+ return self
175
+
176
+ def __exit__(self, *args: Any) -> None:
177
+ self.close()
178
+
179
+ def close(self) -> None:
180
+ self._session.close()
181
+
182
+ def __repr__(self) -> str:
183
+ return f"<APIClient base_url={self.base_url!r}>"
@@ -0,0 +1,101 @@
1
+ """
2
+ Thin wrapper around requests.Response that adds helper properties
3
+ and is consumed by the fluent assertion API.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import time
9
+ from typing import Any
10
+
11
+ import requests
12
+
13
+
14
+ class APIResponse:
15
+ """Wraps requests.Response with additional convenience methods."""
16
+
17
+ def __init__(self, response: requests.Response, elapsed_ms: float | None = None) -> None:
18
+ self._response = response
19
+ self.elapsed_ms: float = elapsed_ms if elapsed_ms is not None else (
20
+ response.elapsed.total_seconds() * 1000 if response.elapsed else 0.0
21
+ )
22
+
23
+ # ------------------------------------------------------------------
24
+ # Delegated properties
25
+ # ------------------------------------------------------------------
26
+
27
+ @property
28
+ def status_code(self) -> int:
29
+ return self._response.status_code
30
+
31
+ @property
32
+ def headers(self) -> requests.structures.CaseInsensitiveDict: # type: ignore[type-arg]
33
+ return self._response.headers
34
+
35
+ @property
36
+ def text(self) -> str:
37
+ return self._response.text
38
+
39
+ @property
40
+ def content(self) -> bytes:
41
+ return self._response.content
42
+
43
+ @property
44
+ def url(self) -> str:
45
+ return self._response.url
46
+
47
+ @property
48
+ def ok(self) -> bool:
49
+ return self._response.ok
50
+
51
+ @property
52
+ def request(self) -> requests.PreparedRequest:
53
+ return self._response.request
54
+
55
+ # ------------------------------------------------------------------
56
+ # JSON helpers
57
+ # ------------------------------------------------------------------
58
+
59
+ def json(self, **kwargs: Any) -> Any:
60
+ return self._response.json(**kwargs)
61
+
62
+ def json_value(self, key: str, default: Any = None) -> Any:
63
+ """Return a top-level key from the JSON body, or *default* if missing."""
64
+ try:
65
+ return self.json().get(key, default)
66
+ except (ValueError, AttributeError):
67
+ return default
68
+
69
+ # ------------------------------------------------------------------
70
+ # Request echo (for logging / reporting)
71
+ # ------------------------------------------------------------------
72
+
73
+ def request_body_text(self) -> str:
74
+ """Return the raw request body as a string (best-effort)."""
75
+ body = self._response.request.body
76
+ if body is None:
77
+ return ""
78
+ if isinstance(body, bytes):
79
+ try:
80
+ parsed = json.loads(body)
81
+ return json.dumps(parsed, indent=2)
82
+ except (ValueError, TypeError):
83
+ return body.decode("utf-8", errors="replace")
84
+ return str(body)
85
+
86
+ def response_body_text(self) -> str:
87
+ """Return the response body pretty-printed if JSON, else raw text."""
88
+ try:
89
+ return json.dumps(self.json(), indent=2)
90
+ except (ValueError, TypeError):
91
+ return self.text
92
+
93
+ # ------------------------------------------------------------------
94
+ # Dunder
95
+ # ------------------------------------------------------------------
96
+
97
+ def __repr__(self) -> str:
98
+ return f"<APIResponse [{self.status_code}] {self.url} ({self.elapsed_ms:.1f} ms)>"
99
+
100
+ def raise_for_status(self) -> None:
101
+ self._response.raise_for_status()
@@ -0,0 +1,3 @@
1
+ from pytest_api_core.config.config_manager import ConfigManager
2
+
3
+ __all__ = ["ConfigManager"]
@@ -0,0 +1,54 @@
1
+ """
2
+ Base class for environment settings.
3
+
4
+ Consumer projects subclass this, define class-level attributes,
5
+ and register them in an ENVIRONMENTS dict.
6
+
7
+ Example
8
+ -------
9
+ from pytest_api_core.config.base_settings import BaseSettings
10
+ import os
11
+
12
+ class DevSettings(BaseSettings):
13
+ base_url = "https://api.dev.mycompany.com"
14
+ timeout = 30
15
+ verify_ssl = True
16
+ headers = {"Accept": "application/json"}
17
+
18
+ class StagingSettings(DevSettings): # inherit defaults, override what changes
19
+ base_url = "https://api.staging.mycompany.com"
20
+ timeout = 60
21
+
22
+ class ProdSettings(BaseSettings):
23
+ base_url = os.environ.get("API_BASE_URL", "https://api.mycompany.com")
24
+ timeout = 60
25
+ verify_ssl = True
26
+
27
+ ENVIRONMENTS = {
28
+ "dev": DevSettings,
29
+ "staging": StagingSettings,
30
+ "prod": ProdSettings,
31
+ }
32
+ """
33
+ from __future__ import annotations
34
+
35
+ from typing import Any
36
+
37
+
38
+ class BaseSettings:
39
+ """Base class for environment-specific API settings."""
40
+
41
+ base_url: str = "http://localhost"
42
+ timeout: int = 30
43
+ verify_ssl: bool = True
44
+ headers: dict[str, str] = {}
45
+
46
+ @classmethod
47
+ def as_dict(cls) -> dict[str, Any]:
48
+ """Return settings as a plain dict, respecting MRO for inheritance."""
49
+ result: dict[str, Any] = {}
50
+ for klass in reversed(cls.__mro__):
51
+ for k, v in vars(klass).items():
52
+ if not k.startswith("_") and not callable(v) and not isinstance(v, (classmethod, staticmethod)):
53
+ result[k] = v
54
+ return result
@@ -0,0 +1,156 @@
1
+ """
2
+ Environment-aware configuration manager.
3
+
4
+ Resolution order (highest → lowest priority):
5
+ 1. Environment variables (API_BASE_URL, API_TOKEN, …)
6
+ 2. ``--api-base-url`` CLI flag
7
+ 3. settings.py ENVIRONMENTS[env].as_dict()
8
+ 4. Built-in defaults
9
+
10
+ Usage
11
+ -----
12
+ manager = ConfigManager(env="staging", settings_module="config.settings")
13
+ cfg = manager.load()
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import importlib
18
+ import logging
19
+ import os
20
+ from typing import Any
21
+
22
+ log = logging.getLogger("pytest_api_core.config")
23
+
24
+ _DEFAULTS: dict[str, Any] = {
25
+ "base_url": "http://localhost",
26
+ "timeout": 30,
27
+ "verify_ssl": True,
28
+ "headers": {},
29
+ "retry": {
30
+ "total": 3,
31
+ "backoff_factor": 0.3,
32
+ "status_forcelist": [500, 502, 503, 504],
33
+ },
34
+ }
35
+
36
+ # Maps env-var names → config keys
37
+ _ENV_VAR_MAP: dict[str, str] = {
38
+ "API_BASE_URL": "base_url",
39
+ "API_TIMEOUT": "timeout",
40
+ "API_VERIFY_SSL": "verify_ssl",
41
+ "API_TOKEN": "_token", # consumed by fixtures, not stored in cfg directly
42
+ }
43
+
44
+
45
+ class ConfigManager:
46
+ """
47
+ Loads and merges configuration for a given environment name.
48
+
49
+ Parameters
50
+ ----------
51
+ env:
52
+ Environment label (e.g. ``"dev"``, ``"staging"``).
53
+ cli_overrides:
54
+ Extra key/value pairs from CLI options (e.g. ``--api-base-url``).
55
+ settings_module:
56
+ Dotted import path to a Python module that contains an
57
+ ``ENVIRONMENTS`` dict mapping env names to ``BaseSettings``
58
+ subclasses (e.g. ``"config.settings"``).
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ env: str | None = None,
64
+ cli_overrides: dict[str, Any] | None = None,
65
+ settings_module: str | None = None,
66
+ ) -> None:
67
+ self._env = env or os.environ.get("API_ENV", "dev")
68
+ self._cli_overrides: dict[str, Any] = cli_overrides or {}
69
+ self._settings_module = settings_module
70
+ self._cache: dict[str, Any] | None = None
71
+
72
+ # ------------------------------------------------------------------
73
+ # Public API
74
+ # ------------------------------------------------------------------
75
+
76
+ @property
77
+ def env(self) -> str:
78
+ return self._env
79
+
80
+ def load(self) -> dict[str, Any]:
81
+ """Return the fully-resolved config dict (cached after first call)."""
82
+ if self._cache is not None:
83
+ return self._cache
84
+
85
+ cfg: dict[str, Any] = dict(_DEFAULTS)
86
+
87
+ # Layer 1: settings.py
88
+ _deep_merge(cfg, self._load_settings())
89
+
90
+ # Layer 2: CLI overrides
91
+ _deep_merge(cfg, {k: v for k, v in self._cli_overrides.items() if v is not None})
92
+
93
+ # Layer 3: Environment variables
94
+ for env_var, cfg_key in _ENV_VAR_MAP.items():
95
+ value = os.environ.get(env_var)
96
+ if value is not None:
97
+ cfg[cfg_key] = self._coerce(cfg_key, value)
98
+
99
+ log.debug("Loaded config for env=%r: %s", self._env, cfg)
100
+ self._cache = cfg
101
+ return cfg
102
+
103
+ # ------------------------------------------------------------------
104
+ # Internal helpers
105
+ # ------------------------------------------------------------------
106
+
107
+ def _load_settings(self) -> dict[str, Any]:
108
+ if not self._settings_module:
109
+ log.warning("No api_settings_module configured — using defaults only")
110
+ return {}
111
+ try:
112
+ module = importlib.import_module(self._settings_module)
113
+ except ModuleNotFoundError:
114
+ log.warning("Settings module not found: %s — using defaults only", self._settings_module)
115
+ return {}
116
+
117
+ environments = getattr(module, "ENVIRONMENTS", None)
118
+ if not environments:
119
+ log.warning("No ENVIRONMENTS dict found in %s", self._settings_module)
120
+ return {}
121
+
122
+ settings_class = environments.get(self._env)
123
+ if not settings_class:
124
+ log.warning(
125
+ "Environment %r not in ENVIRONMENTS %s — using defaults only",
126
+ self._env, list(environments.keys()),
127
+ )
128
+ return {}
129
+
130
+ return settings_class.as_dict()
131
+
132
+ @staticmethod
133
+ def _coerce(key: str, raw: str) -> Any:
134
+ """Coerce env-var strings to appropriate Python types."""
135
+ if key in ("timeout",):
136
+ try:
137
+ return int(raw)
138
+ except ValueError:
139
+ return float(raw)
140
+ if key == "verify_ssl":
141
+ return raw.lower() not in ("0", "false", "no")
142
+ return raw
143
+
144
+
145
+ # ------------------------------------------------------------------
146
+ # Utility
147
+ # ------------------------------------------------------------------
148
+
149
+
150
+ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> None:
151
+ """Recursively merge *override* into *base* in-place."""
152
+ for key, value in override.items():
153
+ if isinstance(value, dict) and isinstance(base.get(key), dict):
154
+ _deep_merge(base[key], value)
155
+ else:
156
+ base[key] = value
@@ -0,0 +1,110 @@
1
+ """
2
+ .env file loader and typed environment-variable accessor.
3
+
4
+ The framework calls :func:`load_env_file` once at session start
5
+ (``pytest_configure``), so any values defined in the .env file are
6
+ available to ``settings.py``, fixtures, and test code via
7
+ :func:`get_env` — or directly through ``os.environ``.
8
+
9
+ Resolution order (highest → lowest priority)
10
+ ---------------------------------------------
11
+ 1. Real environment variables already set in the shell / CI secrets
12
+ 2. Values loaded from the ``.env`` file (``override=False`` keeps 1 winning)
13
+
14
+ Usage in tests
15
+ --------------
16
+ from pytest_api_core.config.env_loader import get_env
17
+
18
+ def test_protected(api_client):
19
+ token = get_env("BEARER_TOKEN", required=True)
20
+ api_client.get("/me", headers={"Authorization": f"Bearer {token}"})
21
+
22
+ Usage in settings.py
23
+ --------------------
24
+ import os # .env already loaded by framework before settings are read
25
+ from pytest_api_core.config.base_settings import BaseSettings
26
+
27
+ class StagingSettings(BaseSettings):
28
+ base_url = os.environ.get("API_BASE_URL", "https://api.staging.example.com")
29
+ headers = {"Authorization": f"Bearer {os.environ.get('BEARER_TOKEN', '')}"}
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import logging
34
+ import os
35
+ from pathlib import Path
36
+
37
+ log = logging.getLogger("pytest_api_core.config")
38
+
39
+ try:
40
+ from dotenv import load_dotenv as _load_dotenv
41
+
42
+ _HAS_DOTENV = True
43
+ except ImportError: # python-dotenv is an optional dependency
44
+ _HAS_DOTENV = False
45
+
46
+
47
+ def load_env_file(path: str | Path = ".env") -> None:
48
+ """
49
+ Load a ``.env`` file into ``os.environ``.
50
+
51
+ - Silently skips if the file does not exist.
52
+ - ``override=False`` ensures real shell / CI environment variables always
53
+ take precedence over values defined in the file.
54
+ - Requires ``python-dotenv``; logs a debug message and returns without
55
+ error if the package is not installed.
56
+
57
+ Parameters
58
+ ----------
59
+ path:
60
+ Path to the ``.env`` file. Relative paths are resolved from the
61
+ current working directory (the project root when pytest runs).
62
+ """
63
+ if not _HAS_DOTENV:
64
+ log.debug(
65
+ "python-dotenv is not installed — .env file loading skipped. "
66
+ "Install it with: pip install python-dotenv"
67
+ )
68
+ return
69
+
70
+ dotenv_path = Path(path)
71
+ if not dotenv_path.is_file():
72
+ log.debug("No .env file found at '%s' — skipping", dotenv_path.resolve())
73
+ return
74
+
75
+ _load_dotenv(dotenv_path=dotenv_path, override=False)
76
+ log.debug("Loaded .env file from '%s'", dotenv_path.resolve())
77
+
78
+
79
+ def get_env(key: str, default: str | None = None, required: bool = False) -> str | None:
80
+ """
81
+ Read a value from ``os.environ`` (which includes any values loaded by
82
+ :func:`load_env_file`).
83
+
84
+ Parameters
85
+ ----------
86
+ key:
87
+ Environment variable name (e.g. ``"BEARER_TOKEN"``).
88
+ default:
89
+ Value to return when the variable is not set.
90
+ required:
91
+ When ``True`` and the variable is not set (and no ``default`` is
92
+ given), raise :class:`ValueError` with a descriptive message.
93
+
94
+ Returns
95
+ -------
96
+ str or None
97
+ The variable value, or ``default`` if not set.
98
+
99
+ Raises
100
+ ------
101
+ ValueError
102
+ If ``required=True`` and the variable is absent with no default.
103
+ """
104
+ value = os.environ.get(key, default)
105
+ if required and value is None:
106
+ raise ValueError(
107
+ f"Required environment variable '{key}' is not set. "
108
+ f"Add it to your .env file or export it in your shell / CI secrets."
109
+ )
110
+ return value
@@ -0,0 +1,9 @@
1
+ from pytest_api_core.fixtures.api_fixtures import (
2
+ api_config,
3
+ api_client,
4
+ api_bearer_auth,
5
+ api_basic_auth,
6
+ api_key_auth,
7
+ )
8
+
9
+ __all__ = ["api_config", "api_client", "api_bearer_auth", "api_basic_auth", "api_key_auth"]