test: Replace pytest-asyncio plugin fixtures with anyio fixtures

This commit is contained in:
Chris Coutinho
2025-10-18 22:02:25 +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] [tool.pytest.ini_options]
asyncio_mode = "auto" anyio_mode = "auto"
asyncio_default_test_loop_scope = "session"
asyncio_default_fixture_loop_scope = "session"
log_cli = 1 log_cli = 1
log_cli_level = "WARN" log_cli_level = "WARN"
log_level = "WARN" log_level = "WARN"
@@ -53,7 +51,6 @@ dev = [
"ipython>=9.2.0", "ipython>=9.2.0",
"playwright>=1.49.1", "playwright>=1.49.1",
"pytest>=8.3.5", "pytest>=8.3.5",
"pytest-asyncio>=1.0.0",
"pytest-cov>=6.1.1", "pytest-cov>=6.1.1",
"pytest-playwright-asyncio>=0.7.1", "pytest-playwright-asyncio>=0.7.1",
"ruff>=0.11.13", "ruff>=0.11.13",
+3 -3
View File
@@ -9,7 +9,7 @@ logger = logging.getLogger(__name__)
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@pytest.mark.asyncio @pytest.mark.anyio
async def test_create_and_delete_share(nc_client): async def test_create_and_delete_share(nc_client):
"""Test creating and deleting a file share.""" """Test creating and deleting a file share."""
# Create a test user to share with # Create a test user to share with
@@ -68,7 +68,7 @@ async def test_create_and_delete_share(nc_client):
pass pass
@pytest.mark.asyncio @pytest.mark.anyio
async def test_update_share_permissions(nc_client): async def test_update_share_permissions(nc_client):
"""Test updating share permissions.""" """Test updating share permissions."""
# Create a test user to share with # Create a test user to share with
@@ -120,7 +120,7 @@ async def test_update_share_permissions(nc_client):
pass pass
@pytest.mark.asyncio @pytest.mark.anyio
async def test_list_shares(nc_client): async def test_list_shares(nc_client):
"""Test listing all shares.""" """Test listing all shares."""
# Create a test user to share with # 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__) 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( async def wait_for_nextcloud(
host: str, max_attempts: int = 30, delay: float = 2.0 host: str, max_attempts: int = 30, delay: float = 2.0
) -> bool: ) -> bool:
@@ -111,7 +117,7 @@ async def create_mcp_client_session(
@pytest.fixture(scope="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. Fixture to create a NextcloudClient instance for integration tests.
Uses environment variables for configuration. Uses environment variables for configuration.
@@ -146,35 +152,21 @@ async def nc_client() -> AsyncGenerator[NextcloudClient, Any]:
@pytest.fixture(scope="session") @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. Fixture to create an MCP client session for integration tests using streamable-http.
Note: This fixture uses a workaround for pytest-asyncio + anyio incompatibility. Uses anyio pytest plugin for proper async fixture handling.
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
""" """
try: async for session in create_mcp_client_session(
async for session in create_mcp_client_session( url="http://127.0.0.1:8000/mcp", client_name="Basic MCP"
url="http://127.0.0.1:8000/mcp", client_name="Basic MCP" ):
): yield session
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
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
async def nc_mcp_oauth_client( async def nc_mcp_oauth_client(
anyio_backend,
playwright_oauth_token: str, playwright_oauth_token: str,
) -> AsyncGenerator[ClientSession, Any]: ) -> 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. Connects to the OAuth-enabled MCP server on port 8001 with OAuth authentication.
Uses headless browser automation suitable for CI/CD. Uses headless browser automation suitable for CI/CD.
Uses anyio pytest plugin for proper async fixture handling.
Note: Includes workaround for pytest-asyncio + anyio incompatibility.
See nc_mcp_client fixture for details.
""" """
try: async for session in create_mcp_client_session(
async for session in create_mcp_client_session( url="http://127.0.0.1:8001/mcp",
url="http://127.0.0.1:8001/mcp", token=playwright_oauth_token,
token=playwright_oauth_token, client_name="OAuth MCP (Playwright)",
client_name="OAuth MCP (Playwright)", ):
): yield session
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
@pytest.fixture @pytest.fixture
@@ -519,6 +503,7 @@ async def temporary_board_with_card(
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
async def nc_oauth_client( async def nc_oauth_client(
anyio_backend,
playwright_oauth_token: str, playwright_oauth_token: str,
) -> AsyncGenerator[NextcloudClient, Any]: ) -> AsyncGenerator[NextcloudClient, Any]:
""" """
@@ -641,7 +626,7 @@ def oauth_callback_server():
@pytest.fixture(scope="session") @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. 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") @pytest.fixture(scope="session")
async def playwright_oauth_token( async def playwright_oauth_token(
browser, shared_oauth_client_credentials, oauth_callback_server anyio_backend, browser, shared_oauth_client_credentials, oauth_callback_server
) -> str: ) -> str:
""" """
Fixture to obtain an OAuth access token using Playwright headless browser automation. 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") @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. 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 # Parallel token retrieval fixture - fetches all OAuth tokens concurrently
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
async def all_oauth_tokens( 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]: ) -> dict[str, str]:
""" """
Fetch OAuth tokens for all test users in parallel for speed. 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 # Session-scoped OAuth token fixtures - now use the parallel fixture
@pytest.fixture(scope="session") @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.""" """OAuth token for alice (cached for session). Uses shared OAuth client."""
return all_oauth_tokens["alice"] return all_oauth_tokens["alice"]
@pytest.fixture(scope="session") @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.""" """OAuth token for bob (cached for session). Uses shared OAuth client."""
return all_oauth_tokens["bob"] return all_oauth_tokens["bob"]
@pytest.fixture(scope="session") @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.""" """OAuth token for charlie (cached for session). Uses shared OAuth client."""
return all_oauth_tokens["charlie"] return all_oauth_tokens["charlie"]
@pytest.fixture(scope="session") @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.""" """OAuth token for diana (cached for session). Uses shared OAuth client."""
return all_oauth_tokens["diana"] return all_oauth_tokens["diana"]
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
async def alice_mcp_client( async def alice_mcp_client(
anyio_backend,
alice_oauth_token: str, alice_oauth_token: str,
) -> AsyncGenerator[ClientSession, Any]: ) -> AsyncGenerator[ClientSession, Any]:
"""MCP client authenticated as alice (owner role).""" """MCP client authenticated as alice (owner role)."""
try: async for session in create_mcp_client_session(
async for session in create_mcp_client_session( url="http://127.0.0.1:8001/mcp",
url="http://127.0.0.1:8001/mcp", token=alice_oauth_token,
token=alice_oauth_token, client_name="Alice MCP",
client_name="Alice MCP", ):
): yield session
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
@pytest.fixture(scope="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).""" """MCP client authenticated as bob (viewer role)."""
try: async for session in create_mcp_client_session(
async for session in create_mcp_client_session( url="http://127.0.0.1:8001/mcp",
url="http://127.0.0.1:8001/mcp", token=bob_oauth_token,
token=bob_oauth_token, client_name="Bob MCP",
client_name="Bob MCP", ):
): yield session
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
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
async def charlie_mcp_client( async def charlie_mcp_client(
anyio_backend,
charlie_oauth_token: str, charlie_oauth_token: str,
) -> AsyncGenerator[ClientSession, Any]: ) -> AsyncGenerator[ClientSession, Any]:
"""MCP client authenticated as charlie (editor role, in 'editors' group).""" """MCP client authenticated as charlie (editor role, in 'editors' group)."""
try: async for session in create_mcp_client_session(
async for session in create_mcp_client_session( url="http://127.0.0.1:8001/mcp",
url="http://127.0.0.1:8001/mcp", token=charlie_oauth_token,
token=charlie_oauth_token, client_name="Charlie MCP",
client_name="Charlie MCP", ):
): yield session
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
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
async def diana_mcp_client( async def diana_mcp_client(
anyio_backend,
diana_oauth_token: str, diana_oauth_token: str,
) -> AsyncGenerator[ClientSession, Any]: ) -> AsyncGenerator[ClientSession, Any]:
"""MCP client authenticated as diana (no-access role).""" """MCP client authenticated as diana (no-access role)."""
try: async for session in create_mcp_client_session(
async for session in create_mcp_client_session( url="http://127.0.0.1:8001/mcp",
url="http://127.0.0.1:8001/mcp", token=diana_oauth_token,
token=diana_oauth_token, client_name="Diana MCP",
client_name="Diana MCP", ):
): yield session
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
# Test user/group fixtures for clean test isolation # 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}") logger.info(f"Deleted ACL {acl_id} from board {board_id}")
@pytest.mark.asyncio @pytest.mark.anyio
async def test_deck_board_view_permissions( async def test_deck_board_view_permissions(
nc_client, alice_mcp_client, bob_mcp_client, diana_mcp_client 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) await nc_client.deck.delete_board(board_id)
@pytest.mark.asyncio @pytest.mark.anyio
async def test_deck_board_edit_permissions( async def test_deck_board_edit_permissions(
nc_client, alice_mcp_client, charlie_mcp_client, bob_mcp_client 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) await nc_client.deck.delete_board(board_id)
@pytest.mark.asyncio @pytest.mark.anyio
async def test_deck_board_manage_permissions( async def test_deck_board_manage_permissions(
nc_client, alice_mcp_client, charlie_mcp_client 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) 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): 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. 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] pytestmark = [pytest.mark.integration, pytest.mark.oauth]
@pytest.mark.asyncio @pytest.mark.anyio
async def test_file_share_read_permissions( async def test_file_share_read_permissions(
alice_mcp_client, bob_mcp_client, diana_mcp_client 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( async def test_file_share_write_permissions(
alice_mcp_client, charlie_mcp_client, bob_mcp_client 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): async def test_file_list_permissions(alice_mcp_client, bob_mcp_client):
""" """
Test that file listing respects share permissions. 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): async def test_folder_share_permissions(alice_mcp_client, bob_mcp_client):
""" """
Test that folder sharing works correctly. 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] pytestmark = [pytest.mark.integration, pytest.mark.oauth]
@pytest.mark.asyncio @pytest.mark.anyio
async def test_notes_share_read_permissions( async def test_notes_share_read_permissions(
nc_client, alice_mcp_client, bob_mcp_client, diana_mcp_client 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) await nc_client.notes.delete_note(note_id)
@pytest.mark.asyncio @pytest.mark.anyio
async def test_notes_share_write_permissions( async def test_notes_share_write_permissions(
nc_client, alice_mcp_client, charlie_mcp_client, bob_mcp_client 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) 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): 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. 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) await nc_client.notes.delete_note(bob_note_id)
@pytest.mark.asyncio @pytest.mark.anyio
async def test_oauth_mcp_clients_initialized( async def test_oauth_mcp_clients_initialized(
alice_mcp_client, bob_mcp_client, charlie_mcp_client, diana_mcp_client 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 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): async def test_create_and_delete_user(nc_client: NextcloudClient, test_user):
"""Test creating a user and verifying deletion (cleanup by fixture).""" """Test creating a user and verifying deletion (cleanup by fixture)."""
user_config = test_user 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 # 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): async def test_update_user_field(nc_client: NextcloudClient, test_user):
"""Test updating user fields.""" """Test updating user fields."""
user_config = test_user user_config = test_user
@@ -44,7 +44,7 @@ async def test_update_user_field(nc_client: NextcloudClient, test_user):
# Fixture will handle cleanup # Fixture will handle cleanup
@pytest.mark.asyncio @pytest.mark.anyio
async def test_user_groups(nc_client: NextcloudClient, test_user_in_group): async def test_user_groups(nc_client: NextcloudClient, test_user_in_group):
"""Test adding and removing users from groups.""" """Test adding and removing users from groups."""
user_config, groupid = test_user_in_group 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 # Fixtures will handle cleanup
@pytest.mark.asyncio @pytest.mark.anyio
async def test_user_subadmins(nc_client: NextcloudClient, test_user, test_group): async def test_user_subadmins(nc_client: NextcloudClient, test_user, test_group):
"""Test promoting and demoting subadmins.""" """Test promoting and demoting subadmins."""
user_config = test_user user_config = test_user
@@ -82,7 +82,7 @@ async def test_user_subadmins(nc_client: NextcloudClient, test_user, test_group)
# Fixtures will handle cleanup # Fixtures will handle cleanup
@pytest.mark.asyncio @pytest.mark.anyio
async def test_disable_enable_user(nc_client: NextcloudClient, test_user): async def test_disable_enable_user(nc_client: NextcloudClient, test_user):
"""Test disabling and enabling users.""" """Test disabling and enabling users."""
user_config = test_user user_config = test_user
@@ -102,7 +102,7 @@ async def test_disable_enable_user(nc_client: NextcloudClient, test_user):
# Fixture will handle cleanup # Fixture will handle cleanup
@pytest.mark.asyncio @pytest.mark.anyio
async def test_get_editable_user_fields(nc_client: NextcloudClient): async def test_get_editable_user_fields(nc_client: NextcloudClient):
editable_fields = await nc_client.users.get_editable_user_fields() editable_fields = await nc_client.users.get_editable_user_fields()
assert "displayname" in editable_fields assert "displayname" in editable_fields
Generated
-2
View File
@@ -648,7 +648,6 @@ dev = [
{ name = "ipython" }, { name = "ipython" },
{ name = "playwright" }, { name = "playwright" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" }, { name = "pytest-cov" },
{ name = "pytest-playwright-asyncio" }, { name = "pytest-playwright-asyncio" },
{ name = "ruff" }, { name = "ruff" },
@@ -671,7 +670,6 @@ dev = [
{ name = "ipython", specifier = ">=9.2.0" }, { name = "ipython", specifier = ">=9.2.0" },
{ name = "playwright", specifier = ">=1.49.1" }, { name = "playwright", specifier = ">=1.49.1" },
{ name = "pytest", specifier = ">=8.3.5" }, { name = "pytest", specifier = ">=8.3.5" },
{ name = "pytest-asyncio", specifier = ">=1.0.0" },
{ name = "pytest-cov", specifier = ">=6.1.1" }, { name = "pytest-cov", specifier = ">=6.1.1" },
{ name = "pytest-playwright-asyncio", specifier = ">=0.7.1" }, { name = "pytest-playwright-asyncio", specifier = ">=0.7.1" },
{ name = "ruff", specifier = ">=0.11.13" }, { name = "ruff", specifier = ">=0.11.13" },