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.
@@ -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,3 @@
1
+ """apifacts-pytest: known facts from the apifacts registry in pytest failure reports."""
2
+
3
+ __version__ = "0.1.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]