diff --git a/pyproject.toml b/pyproject.toml index 2ce516a..d099b79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/client/test_sharing_api.py b/tests/client/test_sharing_api.py index 0733c19..04c7d6d 100644 --- a/tests/client/test_sharing_api.py +++ b/tests/client/test_sharing_api.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index 1276ea7..49fd7f5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/server/test_oauth_deck_permissions.py b/tests/server/test_oauth_deck_permissions.py index d244c12..ae048ea 100644 --- a/tests/server/test_oauth_deck_permissions.py +++ b/tests/server/test_oauth_deck_permissions.py @@ -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. diff --git a/tests/server/test_oauth_file_permissions.py b/tests/server/test_oauth_file_permissions.py index 3d78a0f..79982eb 100644 --- a/tests/server/test_oauth_file_permissions.py +++ b/tests/server/test_oauth_file_permissions.py @@ -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. diff --git a/tests/server/test_oauth_notes_permissions.py b/tests/server/test_oauth_notes_permissions.py index f630fdd..d117e3a 100644 --- a/tests/server/test_oauth_notes_permissions.py +++ b/tests/server/test_oauth_notes_permissions.py @@ -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 ): diff --git a/tests/server/test_users_api.py b/tests/server/test_users_api.py index f81c4f8..ed8d4d8 100644 --- a/tests/server/test_users_api.py +++ b/tests/server/test_users_api.py @@ -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 diff --git a/uv.lock b/uv.lock index 6df9ea1..21d1c86 100644 --- a/uv.lock +++ b/uv.lock @@ -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" },