test: Replace pytest-asyncio plugin fixtures with anyio fixtures

This commit is contained in:
Chris Coutinho
2025-10-18 20:50:15 +02:00
parent 37164dbdbc
commit 1459fe9bc8
8 changed files with 86 additions and 121 deletions
+1 -4
View File
@@ -18,9 +18,7 @@ dependencies = [
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_test_loop_scope = "session"
asyncio_default_fixture_loop_scope = "session"
anyio_mode = "auto"
log_cli = 1
log_cli_level = "WARN"
log_level = "WARN"
@@ -53,7 +51,6 @@ dev = [
"ipython>=9.2.0",
"playwright>=1.49.1",
"pytest>=8.3.5",
"pytest-asyncio>=1.0.0",
"pytest-cov>=6.1.1",
"pytest-playwright-asyncio>=0.7.1",
"ruff>=0.11.13",
+3 -3
View File
@@ -9,7 +9,7 @@ logger = logging.getLogger(__name__)
pytestmark = pytest.mark.integration
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_create_and_delete_share(nc_client):
"""Test creating and deleting a file share."""
# Create a test user to share with
@@ -68,7 +68,7 @@ async def test_create_and_delete_share(nc_client):
pass
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_update_share_permissions(nc_client):
"""Test updating share permissions."""
# Create a test user to share with
@@ -120,7 +120,7 @@ async def test_update_share_permissions(nc_client):
pass
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_list_shares(nc_client):
"""Test listing all shares."""
# Create a test user to share with
+64 -94
View File
@@ -15,6 +15,12 @@ from nextcloud_mcp_server.client import NextcloudClient
logger = logging.getLogger(__name__)
@pytest.fixture(scope="session")
def anyio_backend():
"""Configure anyio to use asyncio backend for all tests."""
return "asyncio"
async def wait_for_nextcloud(
host: str, max_attempts: int = 30, delay: float = 2.0
) -> bool:
@@ -111,7 +117,7 @@ async def create_mcp_client_session(
@pytest.fixture(scope="session")
async def nc_client() -> AsyncGenerator[NextcloudClient, Any]:
async def nc_client(anyio_backend) -> AsyncGenerator[NextcloudClient, Any]:
"""
Fixture to create a NextcloudClient instance for integration tests.
Uses environment variables for configuration.
@@ -146,35 +152,21 @@ async def nc_client() -> AsyncGenerator[NextcloudClient, Any]:
@pytest.fixture(scope="session")
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
async def nc_mcp_client(anyio_backend) -> AsyncGenerator[ClientSession, Any]:
"""
Fixture to create an MCP client session for integration tests using streamable-http.
Note: This fixture uses a workaround for pytest-asyncio + anyio incompatibility.
pytest-asyncio runs fixture teardown in a new asyncio task, which violates anyio's
requirement that cancel scopes must be entered/exited in the same task. We catch
and ignore these expected teardown errors while allowing real errors to propagate.
See: https://github.com/modelcontextprotocol/python-sdk/issues/577
Uses anyio pytest plugin for proper async fixture handling.
"""
try:
async for session in create_mcp_client_session(
url="http://127.0.0.1:8000/mcp", client_name="Basic MCP"
):
yield session
except RuntimeError as e:
# Expected error during pytest-asyncio fixture teardown
# pytest-asyncio creates a new task for teardown, causing:
# "Attempted to exit cancel scope in a different task than it was entered in"
if "cancel scope" in str(e) and "different task" in str(e):
logger.debug(f"Ignoring expected pytest-asyncio teardown issue: {e}")
else:
# Unexpected RuntimeError - re-raise
raise
async for session in create_mcp_client_session(
url="http://127.0.0.1:8000/mcp", client_name="Basic MCP"
):
yield session
@pytest.fixture(scope="session")
async def nc_mcp_oauth_client(
anyio_backend,
playwright_oauth_token: str,
) -> AsyncGenerator[ClientSession, Any]:
"""
@@ -182,22 +174,14 @@ async def nc_mcp_oauth_client(
Connects to the OAuth-enabled MCP server on port 8001 with OAuth authentication.
Uses headless browser automation suitable for CI/CD.
Note: Includes workaround for pytest-asyncio + anyio incompatibility.
See nc_mcp_client fixture for details.
Uses anyio pytest plugin for proper async fixture handling.
"""
try:
async for session in create_mcp_client_session(
url="http://127.0.0.1:8001/mcp",
token=playwright_oauth_token,
client_name="OAuth MCP (Playwright)",
):
yield session
except RuntimeError as e:
if "cancel scope" in str(e) and "different task" in str(e):
logger.debug(f"Ignoring expected pytest-asyncio teardown issue: {e}")
else:
raise
async for session in create_mcp_client_session(
url="http://127.0.0.1:8001/mcp",
token=playwright_oauth_token,
client_name="OAuth MCP (Playwright)",
):
yield session
@pytest.fixture
@@ -519,6 +503,7 @@ async def temporary_board_with_card(
@pytest.fixture(scope="session")
async def nc_oauth_client(
anyio_backend,
playwright_oauth_token: str,
) -> AsyncGenerator[NextcloudClient, Any]:
"""
@@ -641,7 +626,7 @@ def oauth_callback_server():
@pytest.fixture(scope="session")
async def shared_oauth_client_credentials(oauth_callback_server):
async def shared_oauth_client_credentials(anyio_backend, oauth_callback_server):
"""
Fixture to obtain shared OAuth client credentials that will be reused for all users.
@@ -702,7 +687,7 @@ async def shared_oauth_client_credentials(oauth_callback_server):
@pytest.fixture(scope="session")
async def playwright_oauth_token(
browser, shared_oauth_client_credentials, oauth_callback_server
anyio_backend, browser, shared_oauth_client_credentials, oauth_callback_server
) -> str:
"""
Fixture to obtain an OAuth access token using Playwright headless browser automation.
@@ -865,7 +850,7 @@ async def playwright_oauth_token(
@pytest.fixture(scope="session")
async def test_users_setup(nc_client: NextcloudClient):
async def test_users_setup(anyio_backend, nc_client: NextcloudClient):
"""
Create test users for multi-user OAuth testing.
@@ -1112,7 +1097,11 @@ async def _get_oauth_token_for_user(
# Parallel token retrieval fixture - fetches all OAuth tokens concurrently
@pytest.fixture(scope="session")
async def all_oauth_tokens(
browser, shared_oauth_client_credentials, test_users_setup, oauth_callback_server
anyio_backend,
browser,
shared_oauth_client_credentials,
test_users_setup,
oauth_callback_server,
) -> dict[str, str]:
"""
Fetch OAuth tokens for all test users in parallel for speed.
@@ -1172,101 +1161,82 @@ async def all_oauth_tokens(
# Session-scoped OAuth token fixtures - now use the parallel fixture
@pytest.fixture(scope="session")
async def alice_oauth_token(all_oauth_tokens) -> str:
async def alice_oauth_token(anyio_backend, all_oauth_tokens) -> str:
"""OAuth token for alice (cached for session). Uses shared OAuth client."""
return all_oauth_tokens["alice"]
@pytest.fixture(scope="session")
async def bob_oauth_token(all_oauth_tokens) -> str:
async def bob_oauth_token(anyio_backend, all_oauth_tokens) -> str:
"""OAuth token for bob (cached for session). Uses shared OAuth client."""
return all_oauth_tokens["bob"]
@pytest.fixture(scope="session")
async def charlie_oauth_token(all_oauth_tokens) -> str:
async def charlie_oauth_token(anyio_backend, all_oauth_tokens) -> str:
"""OAuth token for charlie (cached for session). Uses shared OAuth client."""
return all_oauth_tokens["charlie"]
@pytest.fixture(scope="session")
async def diana_oauth_token(all_oauth_tokens) -> str:
async def diana_oauth_token(anyio_backend, all_oauth_tokens) -> str:
"""OAuth token for diana (cached for session). Uses shared OAuth client."""
return all_oauth_tokens["diana"]
@pytest.fixture(scope="session")
async def alice_mcp_client(
anyio_backend,
alice_oauth_token: str,
) -> AsyncGenerator[ClientSession, Any]:
"""MCP client authenticated as alice (owner role)."""
try:
async for session in create_mcp_client_session(
url="http://127.0.0.1:8001/mcp",
token=alice_oauth_token,
client_name="Alice MCP",
):
yield session
except RuntimeError as e:
if "cancel scope" in str(e) and "different task" in str(e):
logger.debug(f"Ignoring expected pytest-asyncio teardown issue: {e}")
else:
raise
async for session in create_mcp_client_session(
url="http://127.0.0.1:8001/mcp",
token=alice_oauth_token,
client_name="Alice MCP",
):
yield session
@pytest.fixture(scope="session")
async def bob_mcp_client(bob_oauth_token: str) -> AsyncGenerator[ClientSession, Any]:
async def bob_mcp_client(
anyio_backend, bob_oauth_token: str
) -> AsyncGenerator[ClientSession, Any]:
"""MCP client authenticated as bob (viewer role)."""
try:
async for session in create_mcp_client_session(
url="http://127.0.0.1:8001/mcp",
token=bob_oauth_token,
client_name="Bob MCP",
):
yield session
except RuntimeError as e:
if "cancel scope" in str(e) and "different task" in str(e):
logger.debug(f"Ignoring expected pytest-asyncio teardown issue: {e}")
else:
raise
async for session in create_mcp_client_session(
url="http://127.0.0.1:8001/mcp",
token=bob_oauth_token,
client_name="Bob MCP",
):
yield session
@pytest.fixture(scope="session")
async def charlie_mcp_client(
anyio_backend,
charlie_oauth_token: str,
) -> AsyncGenerator[ClientSession, Any]:
"""MCP client authenticated as charlie (editor role, in 'editors' group)."""
try:
async for session in create_mcp_client_session(
url="http://127.0.0.1:8001/mcp",
token=charlie_oauth_token,
client_name="Charlie MCP",
):
yield session
except RuntimeError as e:
if "cancel scope" in str(e) and "different task" in str(e):
logger.debug(f"Ignoring expected pytest-asyncio teardown issue: {e}")
else:
raise
async for session in create_mcp_client_session(
url="http://127.0.0.1:8001/mcp",
token=charlie_oauth_token,
client_name="Charlie MCP",
):
yield session
@pytest.fixture(scope="session")
async def diana_mcp_client(
anyio_backend,
diana_oauth_token: str,
) -> AsyncGenerator[ClientSession, Any]:
"""MCP client authenticated as diana (no-access role)."""
try:
async for session in create_mcp_client_session(
url="http://127.0.0.1:8001/mcp",
token=diana_oauth_token,
client_name="Diana MCP",
):
yield session
except RuntimeError as e:
if "cancel scope" in str(e) and "different task" in str(e):
logger.debug(f"Ignoring expected pytest-asyncio teardown issue: {e}")
else:
raise
async for session in create_mcp_client_session(
url="http://127.0.0.1:8001/mcp",
token=diana_oauth_token,
client_name="Diana MCP",
):
yield session
# Test user/group fixtures for clean test isolation
+4 -4
View File
@@ -46,7 +46,7 @@ async def delete_board_acl(nc_client, board_id: int, acl_id: int):
logger.info(f"Deleted ACL {acl_id} from board {board_id}")
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_deck_board_view_permissions(
nc_client, alice_mcp_client, bob_mcp_client, diana_mcp_client
):
@@ -119,7 +119,7 @@ async def test_deck_board_view_permissions(
await nc_client.deck.delete_board(board_id)
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_deck_board_edit_permissions(
nc_client, alice_mcp_client, charlie_mcp_client, bob_mcp_client
):
@@ -214,7 +214,7 @@ async def test_deck_board_edit_permissions(
await nc_client.deck.delete_board(board_id)
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_deck_board_manage_permissions(
nc_client, alice_mcp_client, charlie_mcp_client
):
@@ -289,7 +289,7 @@ async def test_deck_board_manage_permissions(
await nc_client.deck.delete_board(board_id)
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_deck_user_isolation(nc_client, alice_mcp_client, bob_mcp_client):
"""
Test that users can only see their own boards when not shared.
+4 -4
View File
@@ -18,7 +18,7 @@ logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_file_share_read_permissions(
alice_mcp_client, bob_mcp_client, diana_mcp_client
):
@@ -104,7 +104,7 @@ async def test_file_share_read_permissions(
)
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_file_share_write_permissions(
alice_mcp_client, charlie_mcp_client, bob_mcp_client
):
@@ -210,7 +210,7 @@ async def test_file_share_write_permissions(
)
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_file_list_permissions(alice_mcp_client, bob_mcp_client):
"""
Test that file listing respects share permissions.
@@ -326,7 +326,7 @@ async def test_file_list_permissions(alice_mcp_client, bob_mcp_client):
)
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_folder_share_permissions(alice_mcp_client, bob_mcp_client):
"""
Test that folder sharing works correctly.
+4 -4
View File
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_notes_share_read_permissions(
nc_client, alice_mcp_client, bob_mcp_client, diana_mcp_client
):
@@ -82,7 +82,7 @@ async def test_notes_share_read_permissions(
await nc_client.notes.delete_note(note_id)
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_notes_share_write_permissions(
nc_client, alice_mcp_client, charlie_mcp_client, bob_mcp_client
):
@@ -149,7 +149,7 @@ async def test_notes_share_write_permissions(
await nc_client.notes.delete_note(note_id)
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_user_isolation_notes(nc_client, alice_mcp_client, bob_mcp_client):
"""
Test that users can only see their own notes when not shared.
@@ -222,7 +222,7 @@ async def test_user_isolation_notes(nc_client, alice_mcp_client, bob_mcp_client)
await nc_client.notes.delete_note(bob_note_id)
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_oauth_mcp_clients_initialized(
alice_mcp_client, bob_mcp_client, charlie_mcp_client, diana_mcp_client
):
+6 -6
View File
@@ -3,7 +3,7 @@ import pytest
from nextcloud_mcp_server.client import NextcloudClient
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_create_and_delete_user(nc_client: NextcloudClient, test_user):
"""Test creating a user and verifying deletion (cleanup by fixture)."""
user_config = test_user
@@ -29,7 +29,7 @@ async def test_create_and_delete_user(nc_client: NextcloudClient, test_user):
# Note: Fixture cleanup will also try to delete but handle 404 gracefully
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_update_user_field(nc_client: NextcloudClient, test_user):
"""Test updating user fields."""
user_config = test_user
@@ -44,7 +44,7 @@ async def test_update_user_field(nc_client: NextcloudClient, test_user):
# Fixture will handle cleanup
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_user_groups(nc_client: NextcloudClient, test_user_in_group):
"""Test adding and removing users from groups."""
user_config, groupid = test_user_in_group
@@ -61,7 +61,7 @@ async def test_user_groups(nc_client: NextcloudClient, test_user_in_group):
# Fixtures will handle cleanup
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_user_subadmins(nc_client: NextcloudClient, test_user, test_group):
"""Test promoting and demoting subadmins."""
user_config = test_user
@@ -82,7 +82,7 @@ async def test_user_subadmins(nc_client: NextcloudClient, test_user, test_group)
# Fixtures will handle cleanup
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_disable_enable_user(nc_client: NextcloudClient, test_user):
"""Test disabling and enabling users."""
user_config = test_user
@@ -102,7 +102,7 @@ async def test_disable_enable_user(nc_client: NextcloudClient, test_user):
# Fixture will handle cleanup
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_get_editable_user_fields(nc_client: NextcloudClient):
editable_fields = await nc_client.users.get_editable_user_fields()
assert "displayname" in editable_fields
Generated
-2
View File
@@ -648,7 +648,6 @@ dev = [
{ name = "ipython" },
{ name = "playwright" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "pytest-playwright-asyncio" },
{ name = "ruff" },
@@ -671,7 +670,6 @@ dev = [
{ name = "ipython", specifier = ">=9.2.0" },
{ name = "playwright", specifier = ">=1.49.1" },
{ name = "pytest", specifier = ">=8.3.5" },
{ name = "pytest-asyncio", specifier = ">=1.0.0" },
{ name = "pytest-cov", specifier = ">=6.1.1" },
{ name = "pytest-playwright-asyncio", specifier = ">=0.7.1" },
{ name = "ruff", specifier = ">=0.11.13" },