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.
- pytest_api_core/__init__.py +20 -0
- pytest_api_core/assertions/__init__.py +3 -0
- pytest_api_core/assertions/response_assertions.py +350 -0
- pytest_api_core/auth/__init__.py +8 -0
- pytest_api_core/auth/auth_handlers.py +156 -0
- pytest_api_core/client/__init__.py +4 -0
- pytest_api_core/client/api_client.py +183 -0
- pytest_api_core/client/api_response.py +101 -0
- pytest_api_core/config/__init__.py +3 -0
- pytest_api_core/config/base_settings.py +54 -0
- pytest_api_core/config/config_manager.py +156 -0
- pytest_api_core/config/env_loader.py +110 -0
- pytest_api_core/fixtures/__init__.py +9 -0
- pytest_api_core/fixtures/api_fixtures.py +133 -0
- pytest_api_core/plugin.py +125 -0
- pytest_api_core/reporters/__init__.py +3 -0
- pytest_api_core/reporters/html_reporter.py +245 -0
- pytest_api_core/reporters/templates/report.html +469 -0
- pytest_api_core-1.0.1.dist-info/METADATA +314 -0
- pytest_api_core-1.0.1.dist-info/RECORD +24 -0
- pytest_api_core-1.0.1.dist-info/WHEEL +5 -0
- pytest_api_core-1.0.1.dist-info/entry_points.txt +2 -0
- pytest_api_core-1.0.1.dist-info/licenses/LICENSE +21 -0
- pytest_api_core-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -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,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
|