apifacts-pytest 0.1.0__tar.gz
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.
- apifacts_pytest-0.1.0/.gitignore +35 -0
- apifacts_pytest-0.1.0/.python-version +1 -0
- apifacts_pytest-0.1.0/PKG-INFO +59 -0
- apifacts_pytest-0.1.0/README.md +42 -0
- apifacts_pytest-0.1.0/apifacts_pytest/__init__.py +3 -0
- apifacts_pytest-0.1.0/apifacts_pytest/client.py +22 -0
- apifacts_pytest-0.1.0/apifacts_pytest/extract.py +155 -0
- apifacts_pytest-0.1.0/apifacts_pytest/plugin.py +61 -0
- apifacts_pytest-0.1.0/apifacts_pytest/render.py +17 -0
- apifacts_pytest-0.1.0/pyproject.toml +52 -0
- apifacts_pytest-0.1.0/tests/conftest.py +54 -0
- apifacts_pytest-0.1.0/tests/test_extract.py +220 -0
- apifacts_pytest-0.1.0/tests/test_plugin.py +304 -0
- apifacts_pytest-0.1.0/uv.lock +1137 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# secrets never
|
|
2
|
+
.env
|
|
3
|
+
.env.*
|
|
4
|
+
*.key
|
|
5
|
+
*.pem
|
|
6
|
+
.private_keys/
|
|
7
|
+
|
|
8
|
+
# python
|
|
9
|
+
__pycache__/
|
|
10
|
+
*.pyc
|
|
11
|
+
.venv/
|
|
12
|
+
.pytest_cache/
|
|
13
|
+
.mypy_cache/
|
|
14
|
+
.ruff_cache/
|
|
15
|
+
dist/
|
|
16
|
+
build/
|
|
17
|
+
*.egg-info/
|
|
18
|
+
|
|
19
|
+
# node
|
|
20
|
+
node_modules/
|
|
21
|
+
coverage/
|
|
22
|
+
|
|
23
|
+
# probe runner state (results.jsonl, probe-state.sqlite): transient, never in git
|
|
24
|
+
var/
|
|
25
|
+
|
|
26
|
+
# local scratch
|
|
27
|
+
.DS_Store
|
|
28
|
+
tmp/
|
|
29
|
+
|
|
30
|
+
# check() API state (never in git)
|
|
31
|
+
observations.sqlite
|
|
32
|
+
observations.sqlite-*
|
|
33
|
+
|
|
34
|
+
# kill switch (spec 04): a host-local file, never committed
|
|
35
|
+
ops/KILL
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: apifacts-pytest
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: pytest plugin: a test failing on an HTTP error prints the known facts from the apifacts registry.
|
|
5
|
+
Project-URL: Homepage, https://apifacts.dev
|
|
6
|
+
Project-URL: Repository, https://github.com/Illouminus/apifacts
|
|
7
|
+
Project-URL: Issues, https://github.com/Illouminus/apifacts/issues
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Framework :: Pytest
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Requires-Python: >=3.12
|
|
14
|
+
Requires-Dist: apifacts>=0.1.0
|
|
15
|
+
Requires-Dist: pytest>=8.0
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# apifacts-pytest
|
|
19
|
+
|
|
20
|
+
A pytest plugin for the [apifacts](https://github.com/Illouminus/apifacts) registry: when a test
|
|
21
|
+
fails on an HTTP error from a registered API, the failure report gets a section with the verified facts
|
|
22
|
+
about that error and their typed fixes. Zero tokens and zero requests until a test fails.
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
pip install apifacts-pytest # loads automatically; or: pytest -p apifacts
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
──────────── Known facts (apifacts, CC BY 4.0) ────────────
|
|
30
|
+
POST api.stripe.com/v1/payment_intents → 400 idempotency_error
|
|
31
|
+
✅ verified:server:prod · Reusing an Idempotency-Key with a different request body returns HTTP 400 …
|
|
32
|
+
fix: Send header Idempotency-Key = new UUID per distinct request
|
|
33
|
+
https://github.com/Illouminus/apifacts/blob/main/pages/stripe/idempotency-key-reuse-different-params.md
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## What it reads and sends
|
|
37
|
+
|
|
38
|
+
- Recognized exceptions anywhere in the chain (`__cause__`, `__context__`, exception groups):
|
|
39
|
+
`requests.HTTPError`, `httpx.HTTPStatusError`, and SDK errors carrying `status_code`/`http_status`
|
|
40
|
+
and `request.url` (OpenAI, Anthropic and similar clients).
|
|
41
|
+
- Nothing is sent unless the host is in the registry's service list (`GET /v0/services`, cached 24 h
|
|
42
|
+
in `~/.cache/apifacts/`). Then one `POST /v0/check` per distinct failure, 2 s timeout, at most 20
|
|
43
|
+
per session; none on a pass; the first network failure turns the plugin off for the session.
|
|
44
|
+
- Sent: method, `scheme://host/path` (no query, fragment, credentials or port; secret-looking path
|
|
45
|
+
segments and tenant subdomains replaced), status, the first 2 KB of the error body with secrets,
|
|
46
|
+
e-mail addresses, long ids and URL query strings removed, and the vendor SDK name and version.
|
|
47
|
+
Never sent: request bodies, headers, cookies, tokens.
|
|
48
|
+
- Any plugin error is swallowed: it never changes a test outcome.
|
|
49
|
+
|
|
50
|
+
## Configuration
|
|
51
|
+
|
|
52
|
+
| Setting | Effect |
|
|
53
|
+
|---------|--------|
|
|
54
|
+
| `APIFACTS_DISABLE=1` or `-p no:apifacts` | off |
|
|
55
|
+
| `APIFACTS_ENDPOINT` | registry URL (default `https://apifacts.dev`) |
|
|
56
|
+
| `--apifacts-offline` | no network: answer from the snapshot shipped in the package |
|
|
57
|
+
| `APIFACTS_SNAPSHOT=<dir>` | snapshot directory (`services/*.yaml`, `facts/*/*.yaml`) for offline mode |
|
|
58
|
+
|
|
59
|
+
Code MIT; facts CC BY 4.0.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# apifacts-pytest
|
|
2
|
+
|
|
3
|
+
A pytest plugin for the [apifacts](https://github.com/Illouminus/apifacts) registry: when a test
|
|
4
|
+
fails on an HTTP error from a registered API, the failure report gets a section with the verified facts
|
|
5
|
+
about that error and their typed fixes. Zero tokens and zero requests until a test fails.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install apifacts-pytest # loads automatically; or: pytest -p apifacts
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
──────────── Known facts (apifacts, CC BY 4.0) ────────────
|
|
13
|
+
POST api.stripe.com/v1/payment_intents → 400 idempotency_error
|
|
14
|
+
✅ verified:server:prod · Reusing an Idempotency-Key with a different request body returns HTTP 400 …
|
|
15
|
+
fix: Send header Idempotency-Key = new UUID per distinct request
|
|
16
|
+
https://github.com/Illouminus/apifacts/blob/main/pages/stripe/idempotency-key-reuse-different-params.md
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## What it reads and sends
|
|
20
|
+
|
|
21
|
+
- Recognized exceptions anywhere in the chain (`__cause__`, `__context__`, exception groups):
|
|
22
|
+
`requests.HTTPError`, `httpx.HTTPStatusError`, and SDK errors carrying `status_code`/`http_status`
|
|
23
|
+
and `request.url` (OpenAI, Anthropic and similar clients).
|
|
24
|
+
- Nothing is sent unless the host is in the registry's service list (`GET /v0/services`, cached 24 h
|
|
25
|
+
in `~/.cache/apifacts/`). Then one `POST /v0/check` per distinct failure, 2 s timeout, at most 20
|
|
26
|
+
per session; none on a pass; the first network failure turns the plugin off for the session.
|
|
27
|
+
- Sent: method, `scheme://host/path` (no query, fragment, credentials or port; secret-looking path
|
|
28
|
+
segments and tenant subdomains replaced), status, the first 2 KB of the error body with secrets,
|
|
29
|
+
e-mail addresses, long ids and URL query strings removed, and the vendor SDK name and version.
|
|
30
|
+
Never sent: request bodies, headers, cookies, tokens.
|
|
31
|
+
- Any plugin error is swallowed: it never changes a test outcome.
|
|
32
|
+
|
|
33
|
+
## Configuration
|
|
34
|
+
|
|
35
|
+
| Setting | Effect |
|
|
36
|
+
|---------|--------|
|
|
37
|
+
| `APIFACTS_DISABLE=1` or `-p no:apifacts` | off |
|
|
38
|
+
| `APIFACTS_ENDPOINT` | registry URL (default `https://apifacts.dev`) |
|
|
39
|
+
| `--apifacts-offline` | no network: answer from the snapshot shipped in the package |
|
|
40
|
+
| `APIFACTS_SNAPSHOT=<dir>` | snapshot directory (`services/*.yaml`, `facts/*/*.yaml`) for offline mode |
|
|
41
|
+
|
|
42
|
+
Code MIT; facts CC BY 4.0.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""The plugin's `Client`: settings from the environment, `--apifacts-offline`, its user agent."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from apifacts.client.settings import Settings
|
|
5
|
+
from apifacts.client.transport import Client
|
|
6
|
+
|
|
7
|
+
from apifacts_pytest import __version__
|
|
8
|
+
|
|
9
|
+
USER_AGENT = f"apifacts-pytest/{__version__} (+https://github.com/Illouminus/apifacts)"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def make_client(config: pytest.Config) -> Client | None:
|
|
13
|
+
"""None when the user opted out (`APIFACTS_DISABLE=1`)."""
|
|
14
|
+
settings = Settings.from_env()
|
|
15
|
+
if settings.disabled:
|
|
16
|
+
return None
|
|
17
|
+
return Client(
|
|
18
|
+
settings.endpoint,
|
|
19
|
+
USER_AGENT,
|
|
20
|
+
offline=bool(config.getoption("apifacts_offline", False)),
|
|
21
|
+
snapshot=settings.snapshot,
|
|
22
|
+
)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""From a test's exception to a `Signal`: method, URL, status, body excerpt, vendor SDK.
|
|
2
|
+
|
|
3
|
+
Duck-typed, so neither requests nor httpx is imported: `requests.HTTPError` and
|
|
4
|
+
`httpx.HTTPStatusError` (`.response`, `.request`), SDK errors with `status_code`/`http_status` and
|
|
5
|
+
`request.url` (openai, anthropic, …), aiohttp's `status` + `request_info`. Reading never touches the
|
|
6
|
+
network: an unread streaming body is left alone and `str(exc)` stands in for it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import functools
|
|
10
|
+
import importlib.metadata
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from apifacts.client.request import RAW_CHARS, Signal, make_signal
|
|
16
|
+
|
|
17
|
+
CHAIN_LIMIT = 32
|
|
18
|
+
# Modules whose exceptions say nothing about a vendor SDK.
|
|
19
|
+
HTTP_LIBRARIES = frozenset(
|
|
20
|
+
{
|
|
21
|
+
"requests",
|
|
22
|
+
"httpx",
|
|
23
|
+
"httpcore",
|
|
24
|
+
"urllib3",
|
|
25
|
+
"aiohttp",
|
|
26
|
+
"urllib",
|
|
27
|
+
"http",
|
|
28
|
+
"builtins",
|
|
29
|
+
"pytest",
|
|
30
|
+
"_pytest",
|
|
31
|
+
"apifacts",
|
|
32
|
+
"apifacts_pytest",
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _attr(obj: Any, name: str) -> Any:
|
|
38
|
+
"""getattr that never raises (httpx raises RuntimeError for an unset `.request`)."""
|
|
39
|
+
if obj is None:
|
|
40
|
+
return None
|
|
41
|
+
try:
|
|
42
|
+
return getattr(obj, name, None)
|
|
43
|
+
except Exception:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _text(value: Any) -> str | None:
|
|
48
|
+
try:
|
|
49
|
+
return value if isinstance(value, str) else (None if value is None else str(value))
|
|
50
|
+
except Exception:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def iter_chain(exc: BaseException) -> Iterator[BaseException]:
|
|
55
|
+
"""The exception, its causes and contexts, and group members; breadth-first, each once."""
|
|
56
|
+
queue: list[Any] = [exc]
|
|
57
|
+
seen: set[int] = set()
|
|
58
|
+
while queue and len(seen) < CHAIN_LIMIT:
|
|
59
|
+
current = queue.pop(0)
|
|
60
|
+
if not isinstance(current, BaseException) or id(current) in seen:
|
|
61
|
+
continue
|
|
62
|
+
seen.add(id(current))
|
|
63
|
+
yield current
|
|
64
|
+
if isinstance(current, BaseExceptionGroup):
|
|
65
|
+
queue.extend(current.exceptions)
|
|
66
|
+
queue.extend((current.__cause__, current.__context__))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _status(*candidates: Any) -> int | None:
|
|
70
|
+
for value in candidates:
|
|
71
|
+
if isinstance(value, int) and not isinstance(value, bool) and 100 <= value <= 599:
|
|
72
|
+
return value
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _body(exc: BaseException, response: Any) -> str | None:
|
|
77
|
+
# requests keeps `_content = False` until a streamed body is consumed; reading it would block
|
|
78
|
+
# on the socket. httpx raises ResponseNotRead instead, which _attr turns into None.
|
|
79
|
+
if response is not None and _attr(response, "_content") is not False:
|
|
80
|
+
content = _attr(response, "content")
|
|
81
|
+
if isinstance(content, bytes | bytearray):
|
|
82
|
+
text = bytes(content[: RAW_CHARS * 4]).decode("utf-8", "replace")
|
|
83
|
+
else:
|
|
84
|
+
text = _text(_attr(response, "text"))
|
|
85
|
+
if text and text.strip():
|
|
86
|
+
return text
|
|
87
|
+
http_body = _attr(exc, "http_body") # stripe-style SDKs
|
|
88
|
+
if isinstance(http_body, bytes | bytearray):
|
|
89
|
+
http_body = bytes(http_body[: RAW_CHARS * 4]).decode("utf-8", "replace")
|
|
90
|
+
if isinstance(http_body, str) and http_body.strip():
|
|
91
|
+
return http_body
|
|
92
|
+
return _text(exc)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _signal(exc: BaseException) -> Signal | None:
|
|
96
|
+
response = _attr(exc, "response")
|
|
97
|
+
request = _attr(exc, "request") or _attr(response, "request")
|
|
98
|
+
request_info = _attr(exc, "request_info")
|
|
99
|
+
status = _status(
|
|
100
|
+
_attr(response, "status_code"),
|
|
101
|
+
_attr(exc, "status_code"),
|
|
102
|
+
_attr(exc, "http_status"),
|
|
103
|
+
_attr(exc, "status"),
|
|
104
|
+
)
|
|
105
|
+
if status is None:
|
|
106
|
+
return None
|
|
107
|
+
url = next(
|
|
108
|
+
(
|
|
109
|
+
u
|
|
110
|
+
for u in map(
|
|
111
|
+
_text, (_attr(request, "url"), _attr(request_info, "url"), _attr(response, "url"))
|
|
112
|
+
)
|
|
113
|
+
if u
|
|
114
|
+
),
|
|
115
|
+
None,
|
|
116
|
+
)
|
|
117
|
+
if url is None:
|
|
118
|
+
return None
|
|
119
|
+
method = _text(_attr(request, "method")) or _text(_attr(request_info, "method"))
|
|
120
|
+
return make_signal(
|
|
121
|
+
url, method=method, status=status, body=_body(exc, response), sdk=sdk_context(exc)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@functools.cache
|
|
126
|
+
def _distributions() -> dict[str, list[str]]:
|
|
127
|
+
return importlib.metadata.packages_distributions()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def sdk_context(exc: BaseException) -> dict[str, str] | None:
|
|
131
|
+
"""`{package, ecosystem, version}` of the installed distribution that defines the exception."""
|
|
132
|
+
module = (_text(type(exc).__module__) or "").split(".")[0]
|
|
133
|
+
if not module or module in HTTP_LIBRARIES or module.startswith("_"):
|
|
134
|
+
return None
|
|
135
|
+
try:
|
|
136
|
+
names = _distributions().get(module) or []
|
|
137
|
+
if len(set(names)) != 1: # unknown, or a namespace shared by several distributions
|
|
138
|
+
return None
|
|
139
|
+
version = importlib.metadata.version(names[0])
|
|
140
|
+
except Exception:
|
|
141
|
+
return None
|
|
142
|
+
package = re.sub(r"[-_.]+", "-", names[0]).lower()
|
|
143
|
+
return {"package": package, "ecosystem": "pypi", "version": version}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def signal_from_exception(exc: BaseException) -> Signal | None:
|
|
147
|
+
"""The first HTTP error in the chain (outermost first), as a filtered `Signal`."""
|
|
148
|
+
for current in iter_chain(exc):
|
|
149
|
+
try:
|
|
150
|
+
signal = _signal(current)
|
|
151
|
+
except Exception:
|
|
152
|
+
signal = None
|
|
153
|
+
if signal is not None:
|
|
154
|
+
return signal
|
|
155
|
+
return None
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""pytest hooks: a test failing on an HTTP error from a registered API gets a "Known facts" section.
|
|
2
|
+
|
|
3
|
+
Nothing happens on a pass. On a failure: extract the signal; if the host is in the registry's
|
|
4
|
+
services list, one `check()` (2 s timeout); render the facts into the report. Any error inside the
|
|
5
|
+
plugin is swallowed: it never changes an outcome or interrupts the run.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from apifacts.client.render import TITLE, render_section
|
|
10
|
+
from apifacts.client.transport import Client
|
|
11
|
+
|
|
12
|
+
from apifacts_pytest.client import make_client
|
|
13
|
+
from apifacts_pytest.extract import signal_from_exception
|
|
14
|
+
from apifacts_pytest.render import attach
|
|
15
|
+
|
|
16
|
+
CLIENT = pytest.StashKey[Client]()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
20
|
+
group = parser.getgroup("apifacts", "apifacts registry")
|
|
21
|
+
group.addoption(
|
|
22
|
+
"--apifacts-offline",
|
|
23
|
+
action="store_true",
|
|
24
|
+
default=False,
|
|
25
|
+
help="answer from the fact snapshot shipped with the package; no network",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
30
|
+
try:
|
|
31
|
+
client = make_client(config)
|
|
32
|
+
except Exception:
|
|
33
|
+
return
|
|
34
|
+
if client is not None:
|
|
35
|
+
config.stash[CLIENT] = client
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def annotate(report: pytest.TestReport, exc: BaseException, client: Client) -> None:
|
|
39
|
+
signal = signal_from_exception(exc)
|
|
40
|
+
if signal is None or not signal.complete:
|
|
41
|
+
return
|
|
42
|
+
host = client.registered_host(signal.host)
|
|
43
|
+
if host is None:
|
|
44
|
+
return # not a registered API: nothing leaves the machine
|
|
45
|
+
if host != signal.host:
|
|
46
|
+
signal = signal.with_host(host) # tenant subdomains are not sent
|
|
47
|
+
body = render_section(client.check(signal.payload()), signal)
|
|
48
|
+
if body:
|
|
49
|
+
attach(report, TITLE, body)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.hookimpl(wrapper=True)
|
|
53
|
+
def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[None]):
|
|
54
|
+
report = yield
|
|
55
|
+
client = item.config.stash.get(CLIENT, None)
|
|
56
|
+
if client is not None and report.failed and call.excinfo is not None:
|
|
57
|
+
try:
|
|
58
|
+
annotate(report, call.excinfo.value, client)
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
return report
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Putting the section into a pytest report.
|
|
2
|
+
|
|
3
|
+
As a section of the traceback representation when there is one: it prints right after the
|
|
4
|
+
traceback, is not filtered by `--show-capture`, and travels with the report (junitxml, xdist).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
SEPARATOR = "─"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def attach(report: pytest.TestReport, title: str, body: str) -> None:
|
|
13
|
+
longrepr = report.longrepr
|
|
14
|
+
if hasattr(longrepr, "addsection"):
|
|
15
|
+
longrepr.addsection(title, body, SEPARATOR)
|
|
16
|
+
else:
|
|
17
|
+
report.sections.append((title, body))
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "apifacts-pytest"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "pytest plugin: a test failing on an HTTP error prints the known facts from the apifacts registry."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"apifacts>=0.1.0",
|
|
10
|
+
"pytest>=8.0",
|
|
11
|
+
]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Framework :: Pytest",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://apifacts.dev"
|
|
21
|
+
Repository = "https://github.com/Illouminus/apifacts"
|
|
22
|
+
Issues = "https://github.com/Illouminus/apifacts/issues"
|
|
23
|
+
|
|
24
|
+
[project.entry-points.pytest11]
|
|
25
|
+
apifacts = "apifacts_pytest.plugin" # name used by `-p apifacts` / `-p no:apifacts`
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest-httpserver>=1.2.0",
|
|
30
|
+
"requests>=2.32.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[tool.uv.sources]
|
|
34
|
+
apifacts = { path = "../..", editable = true }
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["hatchling"]
|
|
38
|
+
build-backend = "hatchling.build"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.version]
|
|
41
|
+
path = "apifacts_pytest/__init__.py"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["apifacts_pytest"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
48
|
+
addopts = ["-p", "pytester", "-p", "no:apifacts"] # inner pytester runs load it from the entry point
|
|
49
|
+
|
|
50
|
+
[tool.ruff]
|
|
51
|
+
extend = "../../pyproject.toml"
|
|
52
|
+
src = [".", "tests"] # apifacts_pytest is first-party here; apifacts is a dependency
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from apifacts.client.offline import Snapshot
|
|
6
|
+
from apifacts.schema.yamlio import load_yaml, plain
|
|
7
|
+
|
|
8
|
+
FIXTURES = Path(__file__).resolve().parents[3] / "tests" / "fixtures" / "valid"
|
|
9
|
+
FACT_FILE = "fact-idempotency-key-reuse-different-params.yaml"
|
|
10
|
+
SERVICES = {"services": [{"key": "stripe", "hosts": ["api.stripe.com"], "facts": 1}]}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def answer() -> dict:
|
|
14
|
+
"""What a spec-03 server returns for the stripe fixture fact."""
|
|
15
|
+
fact = plain(load_yaml(FIXTURES / FACT_FILE))
|
|
16
|
+
return {
|
|
17
|
+
"service": "stripe",
|
|
18
|
+
"matched_template": {"method": "POST", "path": "/v1/payment_intents"},
|
|
19
|
+
"facts": [{**fact, "fixes_rendered": ["(server sentence)"]}],
|
|
20
|
+
"observed": {"status_distribution_30d": {"200": 0.97, "400": 0.03}},
|
|
21
|
+
"registry": {"version": "2026-10-20", "facts_for_service": 1, "license": "CC-BY-4.0"},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def plugin_env(monkeypatch, tmp_path):
|
|
27
|
+
"""Isolate every run: own cache dir, no inherited apifacts settings."""
|
|
28
|
+
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "xdg-cache"))
|
|
29
|
+
for name in ("APIFACTS_DISABLE", "APIFACTS_SNAPSHOT", "APIFACTS_ENDPOINT"):
|
|
30
|
+
monkeypatch.delenv(name, raising=False)
|
|
31
|
+
return monkeypatch
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture
|
|
35
|
+
def registry(httpserver, plugin_env):
|
|
36
|
+
"""A fake check() server: services list + one stripe fact."""
|
|
37
|
+
plugin_env.setenv("APIFACTS_ENDPOINT", httpserver.url_for("").rstrip("/"))
|
|
38
|
+
httpserver.expect_request("/v0/services", method="GET").respond_with_json(SERVICES)
|
|
39
|
+
return httpserver
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.fixture
|
|
43
|
+
def snapshot_dir(tmp_path):
|
|
44
|
+
root = tmp_path / "snapshot"
|
|
45
|
+
(root / "services").mkdir(parents=True)
|
|
46
|
+
(root / "facts" / "stripe").mkdir(parents=True)
|
|
47
|
+
shutil.copy(FIXTURES / "service-stripe.yaml", root / "services" / "stripe.yaml")
|
|
48
|
+
shutil.copy(FIXTURES / FACT_FILE, root / "facts" / "stripe" / FACT_FILE)
|
|
49
|
+
assert Snapshot.load(root).hosts == {"api.stripe.com"}
|
|
50
|
+
return root
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def requests_to(httpserver, path):
|
|
54
|
+
return [req for req, _ in httpserver.log if req.path == path]
|