Skip to content

Commit 1a4fa1d

Browse files
author
Nathan Gillett
committed
test(sdk-python): enforce 95% coverage in CI
Add pytest-cov and a check-coverage script to the build workflow. Expand E2E and integration tests for wrap, outbox, export, and async paths so line coverage reaches the threshold. Signed-off-by: Nathan Gillett <nathan@intentproof.io>
1 parent 0a9fd42 commit 1a4fa1d

12 files changed

Lines changed: 725 additions & 7 deletions

.github/workflows/ci.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,8 @@ jobs:
2121
run: |
2222
pip install -e ".[dev]"
2323
24-
- name: Run tests
24+
- name: Run tests with coverage
2525
run: pytest -q
26+
27+
- name: Enforce coverage threshold
28+
run: bash ./scripts/check-coverage.sh 95

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ __pycache__/
33
*.py[cod]
44
*$py.class
55
.pytest_cache/
6+
.coverage
7+
htmlcov/
68

79
# Virtual environments
810
.venv/

README.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@ Early scaffolding repo for IntentProof's Python SDK. Tracks the
88
Node SDK's wrap()/exporter/outbox contract so a Python application
99
can emit and verify the same signed execution events.
1010

11-
## Planned scope
11+
## Development
1212

13-
- Event wrapping helpers
14-
- Correlation and chain management
15-
- Canonical serialization and signing
16-
- Hosted ingest transport
13+
```bash
14+
pip install -e ".[dev]"
15+
pytest
16+
```
17+
18+
CI enforces at least 95% line coverage on `src/intentproof/` (see
19+
`pyproject.toml` and `scripts/check-coverage.sh`).
1720

1821
## License
1922

pyproject.toml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,26 @@ dependencies = [
1717
[project.optional-dependencies]
1818
dev = [
1919
"pytest>=8.0.0",
20+
"pytest-cov>=5.0.0",
2021
]
2122

2223
[tool.setuptools.packages.find]
2324
where = ["src"]
2425

26+
[tool.coverage.run]
27+
source = ["intentproof"]
28+
omit = []
29+
30+
[tool.coverage.report]
31+
fail_under = 95
32+
show_missing = true
33+
skip_empty = true
34+
2535
[tool.pytest.ini_options]
2636
testpaths = ["tests"]
2737
pythonpath = ["src"]
38+
addopts = [
39+
"--cov=intentproof",
40+
"--cov-report=term-missing",
41+
"--cov-fail-under=95",
42+
]

scripts/check-coverage.sh

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
MIN_COVERAGE="${1:-95}"
6+
7+
COVERAGE=()
8+
if command -v coverage >/dev/null 2>&1; then
9+
COVERAGE=(coverage)
10+
else
11+
for py in python python3; do
12+
if command -v "$py" >/dev/null 2>&1 && "$py" -m coverage --version >/dev/null 2>&1; then
13+
COVERAGE=("$py" -m coverage)
14+
break
15+
fi
16+
done
17+
fi
18+
19+
if [[ ${#COVERAGE[@]} -eq 0 ]]; then
20+
echo "coverage CLI not found; install dev deps with: pip install -e \".[dev]\"" >&2
21+
exit 2
22+
fi
23+
24+
TOTAL_LINE="$("${COVERAGE[@]}" report --include='src/intentproof/*' | awk '/^TOTAL/{print; exit}')"
25+
if [[ -z "$TOTAL_LINE" ]]; then
26+
echo "unable to read total coverage; run pytest with --cov first" >&2
27+
exit 2
28+
fi
29+
30+
TOTAL_PERCENT="$(printf '%s' "$TOTAL_LINE" | awk '{print $NF}' | tr -d '%')"
31+
32+
echo "Total coverage: ${TOTAL_PERCENT}%"
33+
echo "Minimum required: ${MIN_COVERAGE}%"
34+
35+
if awk -v got="$TOTAL_PERCENT" -v min="$MIN_COVERAGE" 'BEGIN { exit !(got + 0 >= min + 0) }'; then
36+
echo "PASS: coverage threshold met"
37+
exit 0
38+
fi
39+
40+
echo "FAIL: coverage threshold not met" >&2
41+
exit 1

tests/test_async_wrap.py

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import pytest
88

9-
from intentproof import client, configure, wrap
9+
from intentproof import client, configure, run_with_correlation_id, wrap
1010

1111

1212
def test_cancelled_error_not_recorded(tmp_path) -> None:
@@ -25,3 +25,94 @@ async def cancelled() -> None:
2525
asyncio.run(fn())
2626

2727
assert client.get_outbox().get_events() == []
28+
29+
30+
def test_async_wrap_records_success(tmp_path) -> None:
31+
configure(
32+
db_path=str(tmp_path / "outbox.db"),
33+
data_dir=str(tmp_path / "data"),
34+
tenant_id="tnt_async",
35+
)
36+
37+
async def add(a: int, b: int) -> int:
38+
return a + b
39+
40+
fn = wrap(intent="Async", action="async.add", fn=add)
41+
result = run_with_correlation_id("corr-async-ok", lambda: asyncio.run(fn(2, 3)))
42+
43+
assert result == 5
44+
events = client.get_outbox().get_events()
45+
assert len(events) == 1
46+
assert events[0]["status"] == "ok"
47+
assert events[0]["output"] == 5
48+
49+
50+
def test_async_wrap_records_error(tmp_path) -> None:
51+
configure(
52+
db_path=str(tmp_path / "outbox.db"),
53+
data_dir=str(tmp_path / "data"),
54+
tenant_id="tnt_async",
55+
)
56+
57+
async def boom() -> None:
58+
raise ValueError("async boom")
59+
60+
fn = wrap(intent="Async", action="async.boom", fn=boom)
61+
with pytest.raises(ValueError, match="async boom"):
62+
asyncio.run(fn())
63+
64+
events = client.get_outbox().get_events()
65+
assert events[-1]["status"] == "error"
66+
assert events[-1]["error"] == {"message": "async boom"}
67+
68+
69+
def test_async_wrap_preserves_app_error_when_record_fails(
70+
tmp_path, monkeypatch: pytest.MonkeyPatch
71+
) -> None:
72+
configure(
73+
db_path=str(tmp_path / "outbox.db"),
74+
data_dir=str(tmp_path / "data"),
75+
tenant_id="tnt_async",
76+
)
77+
78+
async def boom() -> None:
79+
raise ValueError("async boom")
80+
81+
fn = wrap(intent="Async", action="async.boom", fn=boom)
82+
83+
def fail_record(**_kwargs: object) -> None:
84+
raise RuntimeError("outbox unavailable")
85+
86+
monkeypatch.setattr(
87+
"intentproof.instrumentation._record_execution", fail_record
88+
)
89+
90+
with pytest.raises(ValueError, match="async boom") as exc_info:
91+
asyncio.run(fn())
92+
93+
assert isinstance(exc_info.value.__cause__, RuntimeError)
94+
95+
96+
def test_async_wrap_record_failure_without_app_error_raises(
97+
tmp_path, monkeypatch: pytest.MonkeyPatch
98+
) -> None:
99+
configure(
100+
db_path=str(tmp_path / "outbox.db"),
101+
data_dir=str(tmp_path / "data"),
102+
tenant_id="tnt_async",
103+
)
104+
105+
async def ok() -> int:
106+
return 1
107+
108+
fn = wrap(intent="Async", action="async.ok", fn=ok)
109+
110+
def fail_record(**_kwargs: object) -> None:
111+
raise RuntimeError("outbox unavailable")
112+
113+
monkeypatch.setattr(
114+
"intentproof.instrumentation._record_execution", fail_record
115+
)
116+
117+
with pytest.raises(RuntimeError, match="outbox unavailable"):
118+
asyncio.run(fn())

tests/test_canon.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,40 @@ def test_rejects_unclosed_object(self):
191191
canonicalize('{')
192192

193193

194+
class TestLargeIntegers(unittest.TestCase):
195+
def test_safe_integer_uses_decimal(self):
196+
self.assertEqual(canonicalize(9007199254740992), "9007199254740992")
197+
198+
def test_out_of_range_integer_raises(self):
199+
with self.assertRaises(ValueError):
200+
canonicalize(10**400)
201+
202+
203+
class TestUnsupportedTypes(unittest.TestCase):
204+
def test_rejects_non_string_object_keys(self):
205+
with self.assertRaises(TypeError):
206+
canonicalize({1: "x"})
207+
208+
def test_rejects_unsupported_values(self):
209+
with self.assertRaises(TypeError):
210+
canonicalize(object())
211+
212+
def test_rejects_unsupported_nested_values(self):
213+
with self.assertRaises(TypeError):
214+
canonicalize([object()])
215+
216+
217+
class TestFloatEdgeBranches(unittest.TestCase):
218+
def test_zero_float(self):
219+
self.assertEqual(canonicalize(0.0), "0")
220+
221+
def test_negative_zero_string(self):
222+
self.assertEqual(canonicalize("-0"), "0")
223+
224+
def test_trailing_zero_decimal_string(self):
225+
self.assertEqual(canonicalize("5.0"), "5")
226+
227+
194228
class TestPolicyBodyCrossCheck(unittest.TestCase):
195229
def test_byte_equality_with_go_fixture(self):
196230
body = {

0 commit comments

Comments
 (0)