Skip to content

Commit 295ff6c

Browse files
committed
enable test coverage for subprocesses, to include real transport coverage
1 parent dc6d944 commit 295ff6c

File tree

4 files changed

+105
-6
lines changed

4 files changed

+105
-6
lines changed

.coveragerc

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,17 @@
11
[run]
22
omit =
33
examples/*
4-
tests/*
4+
tests/*
5+
concurrency = multiprocessing
6+
parallel = true
7+
sigterm = true
8+
data_file = .coverage
9+
source = fastapi_mcp
10+
debug = config,dataio,process,multiprocess,dataop,pid,trace
11+
12+
[report]
13+
show_missing = true
14+
15+
[paths]
16+
source =
17+
fastapi_mcp/

pytest.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[pytest]
2-
addopts = -vvv --cov=. --cov-report xml --cov-report term-missing --cov-fail-under=80
2+
addopts = -vvv --cov=. --cov-report xml --cov-report term-missing --cov-fail-under=80 --cov-config=.coveragerc
33
asyncio_mode = auto
44
log_cli = true
55
log_cli_level = DEBUG

tests/conftest.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import sys
22
import os
3+
import pytest
4+
import coverage
35

46
# Add the parent directory to the path
57
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
@@ -8,3 +10,30 @@
810
from .fixtures.example_data import * # noqa: F403
911
from .fixtures.simple_app import * # noqa: F403
1012
from .fixtures.complex_app import * # noqa: F403
13+
14+
15+
@pytest.hookimpl(trylast=True)
16+
def pytest_configure(config):
17+
"""Configure pytest-cov for proper subprocess coverage."""
18+
if config.pluginmanager.hasplugin("pytest_cov"):
19+
# Ensure environment variables are set for subprocess coverage
20+
os.environ["COVERAGE_PROCESS_START"] = os.path.abspath(".coveragerc")
21+
22+
# Set up environment for combinining coverage data from subprocesses
23+
os.environ["PYTHONPATH"] = os.path.abspath(".")
24+
25+
# Make sure the pytest-cov plugin is active for subprocesses
26+
config.option.cov_fail_under = 0 # Disable fail under in the primary process
27+
28+
29+
@pytest.hookimpl(trylast=True)
30+
def pytest_sessionfinish(session, exitstatus):
31+
"""Combine coverage data from subprocesses at the end of the test session."""
32+
cov_dir = os.path.abspath(".")
33+
if exitstatus == 0 and os.environ.get("COVERAGE_PROCESS_START"):
34+
try:
35+
cov = coverage.Coverage()
36+
cov.combine(data_paths=[cov_dir], strict=True)
37+
cov.save()
38+
except Exception as e:
39+
print(f"Error combining coverage data: {e}", file=sys.stderr)

tests/test_sse_transport.py

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22
import multiprocessing
33
import socket
44
import time
5+
import os
6+
import signal
7+
import atexit
8+
import sys
9+
import threading
10+
import coverage
511
from typing import AsyncGenerator, Generator
612
from mcp.client.session import ClientSession
713
from mcp.client.sse import sse_client
@@ -32,6 +38,40 @@ def server_url(server_port: int) -> str:
3238

3339

3440
def run_server(server_port: int) -> None:
41+
# Initialize coverage for subprocesses
42+
cov = None
43+
if "COVERAGE_PROCESS_START" in os.environ:
44+
cov = coverage.Coverage(source=["fastapi_mcp"])
45+
cov.start()
46+
47+
# Create a function to save coverage data at exit
48+
def cleanup():
49+
if cov:
50+
cov.stop()
51+
cov.save()
52+
53+
# Register multiple cleanup mechanisms to ensure coverage data is saved
54+
atexit.register(cleanup)
55+
56+
# Setup signal handler for clean termination
57+
def handle_signal(signum, frame):
58+
cleanup()
59+
sys.exit(0)
60+
61+
signal.signal(signal.SIGTERM, handle_signal)
62+
63+
# Backup thread to ensure coverage is written if process is terminated abruptly
64+
def periodic_save():
65+
while True:
66+
time.sleep(1.0)
67+
if cov:
68+
cov.save()
69+
70+
save_thread = threading.Thread(target=periodic_save)
71+
save_thread.daemon = True
72+
save_thread.start()
73+
74+
# Configure the server
3575
fastapi = make_simple_fastapi_app()
3676
mcp = FastApiMCP(
3777
fastapi,
@@ -40,16 +80,26 @@ def run_server(server_port: int) -> None:
4080
)
4181
mcp.mount()
4282

83+
# Start the server
4384
server = uvicorn.Server(config=uvicorn.Config(app=fastapi, host=HOST, port=server_port, log_level="error"))
4485
server.run()
4586

4687
# Give server time to start
4788
while not server.started:
4889
time.sleep(0.5)
4990

91+
# Ensure coverage is saved if exiting the normal way
92+
if cov:
93+
cov.stop()
94+
cov.save()
95+
5096

5197
@pytest.fixture()
5298
def server(server_port: int) -> Generator[None, None, None]:
99+
# Ensure COVERAGE_PROCESS_START is set in the environment for subprocesses
100+
coverage_rc = os.path.abspath(".coveragerc")
101+
os.environ["COVERAGE_PROCESS_START"] = coverage_rc
102+
53103
proc = multiprocessing.Process(target=run_server, kwargs={"server_port": server_port}, daemon=True)
54104
proc.start()
55105

@@ -69,11 +119,18 @@ def server(server_port: int) -> Generator[None, None, None]:
69119

70120
yield
71121

72-
# Signal the server to stop
73-
proc.kill()
74-
proc.join(timeout=2)
122+
# Signal the server to stop - added graceful shutdown before kill
123+
try:
124+
proc.terminate()
125+
proc.join(timeout=2)
126+
except (OSError, AttributeError):
127+
pass
128+
75129
if proc.is_alive():
76-
raise RuntimeError("server process failed to terminate")
130+
proc.kill()
131+
proc.join(timeout=2)
132+
if proc.is_alive():
133+
raise RuntimeError("server process failed to terminate")
77134

78135

79136
@pytest.fixture()

0 commit comments

Comments
 (0)