From 665cb9b1eb3f3b891b57f444c4eafb9bce5d0844 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Wed, 13 May 2026 01:12:01 +0200 Subject: [PATCH] refactor: convert f-string logging to lazy %-style format (G004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweep all 1676 G004 violations across 112 files, converting `logger.(f"…{x}…")` to `logger.("…%s…", x)`. Why: ruff rule G004 was added to pyproject.toml to enforce lazy %-style logging — defers formatting until the log level is enabled and lets structured log tooling match the unformatted template. Conversion preserves rendered output byte-for-byte: - `{x}` → `%s` + `x` - `{x!r}` / `{x!s}` / `{x!a}` → `%r` / `%s` / `%a` - Format specs (`{x:.2f}`, `{x:>10}`) → `%s` + `format(x, 'spec')` (printf-style specs aren't 1:1 with Python format specs, so we delegate to `format()` to keep identical output) - Literal `%` → `%%` - Concatenated f-strings (`f"a {x} " "b"`) flattened - Trailing kwargs (`exc_info=True`) preserved Verified: - `uv run ruff check --select G004` → 0 violations - `uv run ty check -- nextcloud_mcp_server` → passes - `uv run pytest tests/unit/` → 1010 passed Co-Authored-By: Claude Opus 4.7 (1M context) --- nextcloud_mcp_server/alembic/env.py | 4 +- nextcloud_mcp_server/api/management.py | 13 +- nextcloud_mcp_server/api/passwords.py | 15 +- nextcloud_mcp_server/api/visualization.py | 33 +- nextcloud_mcp_server/app.py | 197 ++++--- nextcloud_mcp_server/auth/astrolabe_client.py | 14 +- .../auth/client_registration.py | 67 ++- nextcloud_mcp_server/auth/client_registry.py | 26 +- nextcloud_mcp_server/auth/context_helper.py | 6 +- nextcloud_mcp_server/auth/login_flow.py | 11 +- nextcloud_mcp_server/auth/oauth_routes.py | 18 +- nextcloud_mcp_server/auth/permissions.py | 7 +- nextcloud_mcp_server/auth/provision_routes.py | 29 +- .../auth/provisioning_decorator.py | 13 +- .../auth/scope_authorization.py | 15 +- nextcloud_mcp_server/auth/storage.py | 119 ++-- nextcloud_mcp_server/auth/token_broker.py | 68 ++- nextcloud_mcp_server/auth/unified_verifier.py | 59 +- nextcloud_mcp_server/auth/userinfo_routes.py | 31 +- nextcloud_mcp_server/auth/viz_routes.py | 88 ++- nextcloud_mcp_server/client/__init__.py | 2 +- nextcloud_mcp_server/client/base.py | 20 +- nextcloud_mcp_server/client/calendar.py | 89 +-- nextcloud_mcp_server/client/contacts.py | 10 +- nextcloud_mcp_server/client/cookbook.py | 2 +- nextcloud_mcp_server/client/groups.py | 6 +- nextcloud_mcp_server/client/notes.py | 42 +- nextcloud_mcp_server/client/sharing.py | 12 +- nextcloud_mcp_server/client/webdav.py | 177 +++--- nextcloud_mcp_server/config.py | 4 +- nextcloud_mcp_server/config_validators.py | 13 +- nextcloud_mcp_server/context.py | 6 +- .../document_processors/custom_http.py | 14 +- .../document_processors/pymupdf.py | 13 +- .../document_processors/registry.py | 14 +- .../document_processors/tesseract.py | 9 +- .../document_processors/unstructured.py | 27 +- .../embedding/bm25_provider.py | 4 +- .../embedding/ollama_provider.py | 11 +- nextcloud_mcp_server/migrations.py | 16 +- .../observability/logging_config.py | 6 +- nextcloud_mcp_server/observability/metrics.py | 7 +- .../observability/middleware.py | 9 +- nextcloud_mcp_server/observability/tracing.py | 7 +- nextcloud_mcp_server/providers/anthropic.py | 2 +- nextcloud_mcp_server/providers/bedrock.py | 33 +- nextcloud_mcp_server/providers/ollama.py | 15 +- nextcloud_mcp_server/search/algorithms.py | 4 +- nextcloud_mcp_server/search/bm25_hybrid.py | 27 +- nextcloud_mcp_server/search/context.py | 96 ++- .../search/pdf_highlighter.py | 101 ++-- nextcloud_mcp_server/search/semantic.py | 20 +- nextcloud_mcp_server/server/auth_tools.py | 8 +- nextcloud_mcp_server/server/calendar.py | 14 +- nextcloud_mcp_server/server/oauth_tools.py | 6 +- nextcloud_mcp_server/utils/document_parser.py | 8 +- .../vector/document_chunker.py | 6 +- nextcloud_mcp_server/vector/html_processor.py | 2 +- nextcloud_mcp_server/vector/oauth_sync.py | 116 ++-- nextcloud_mcp_server/vector/pca.py | 8 +- nextcloud_mcp_server/vector/placeholder.py | 34 +- nextcloud_mcp_server/vector/processor.py | 115 ++-- nextcloud_mcp_server/vector/qdrant_client.py | 34 +- nextcloud_mcp_server/vector/scanner.py | 157 +++-- nextcloud_mcp_server/vector/visualization.py | 29 +- pyproject.toml | 2 +- .../calendar/test_calendar_operations.py | 72 +-- .../calendar/test_field_preservation.py | 24 +- tests/client/calendar/test_task_operations.py | 44 +- .../contacts/test_contacts_operations.py | 4 +- tests/client/notes/test_attachments.py | 90 ++- tests/client/notes/test_embedded_images.py | 26 +- tests/client/test_sharing_api.py | 4 +- tests/client/webdav/test_webdav_cleanup.py | 53 +- tests/client/webdav/test_webdav_operations.py | 24 +- tests/client/webdav/test_webdav_search.py | 28 +- tests/conftest.py | 545 ++++++++++-------- tests/integration/provider_fixtures.py | 14 +- tests/integration/sampling_support.py | 10 +- .../test_astrolabe_chunk_context.py | 8 +- ...st_astrolabe_multi_user_background_sync.py | 273 +++++---- .../test_astrolabe_plotly_visualization.py | 52 +- .../test_astrolabe_token_refresh.py | 36 +- tests/integration/test_deck_reorder_card.py | 30 +- tests/integration/test_rag.py | 21 +- tests/integration/test_unstructured_api.py | 4 +- tests/load/benchmark.py | 18 +- tests/load/oauth_benchmark.py | 49 +- tests/load/oauth_pool.py | 42 +- tests/load/oauth_workloads.py | 8 +- tests/load/workloads.py | 8 +- tests/server/login_flow/conftest.py | 78 +-- .../login_flow/test_dcr_deletion_methods.py | 36 +- tests/server/login_flow/test_dcr_lifecycle.py | 34 +- .../login_flow/test_dcr_new_implementation.py | 42 +- .../server/login_flow/test_dcr_token_type.py | 20 +- .../test_introspection_authorization.py | 60 +- .../login_flow/test_login_flow_integration.py | 49 +- .../login_flow/test_multi_user_permissions.py | 2 +- .../login_flow/test_scope_authorization.py | 39 +- tests/server/test_calendar_events_mcp.py | 2 +- tests/server/test_calendar_todos_mcp.py | 10 +- tests/server/test_collectives_mcp.py | 32 +- tests/server/test_contacts_mcp.py | 8 +- tests/server/test_cookbook_mcp.py | 92 +-- tests/server/test_deck_advanced_features.py | 62 +- tests/server/test_deck_mcp.py | 30 +- tests/server/test_error_propagation.py | 10 +- tests/server/test_mcp.py | 84 +-- tests/server/test_talk_mcp.py | 2 +- tests/server/test_webdav_search_mcp.py | 22 +- tests/test_models.py | 2 +- 112 files changed, 2534 insertions(+), 1859 deletions(-) diff --git a/nextcloud_mcp_server/alembic/env.py b/nextcloud_mcp_server/alembic/env.py index ffcc2d2b..912f1c79 100644 --- a/nextcloud_mcp_server/alembic/env.py +++ b/nextcloud_mcp_server/alembic/env.py @@ -61,8 +61,8 @@ def get_database_url() -> str: db_path = Path(get_token_db_path()) url = f"sqlite+aiosqlite:///{db_path}" logger.warning( - f"No database URL configured, using default: {url}. " - "Set sqlalchemy.url in alembic.ini or pass -x database_url=..." + "No database URL configured, using default: %s. Set sqlalchemy.url in alembic.ini or pass -x database_url=...", + url, ) return url diff --git a/nextcloud_mcp_server/api/management.py b/nextcloud_mcp_server/api/management.py index b621cec2..12283408 100644 --- a/nextcloud_mcp_server/api/management.py +++ b/nextcloud_mcp_server/api/management.py @@ -137,7 +137,7 @@ def _sanitize_error_for_client(error: Exception, context: str = "") -> str: Generic error message safe for client consumption """ # Log detailed error for debugging - logger.error(f"Error in {context}: {error}", exc_info=True) + logger.error("Error in %s: %s", context, error, exc_info=True) # Return generic message return "An internal error occurred. Please contact your administrator." @@ -307,7 +307,7 @@ async def get_vector_sync_status(request: Request) -> JSONResponse: indexed_count = count_result.count except Exception as e: - logger.warning(f"Failed to query Qdrant for indexed count: {e}") + logger.warning("Failed to query Qdrant for indexed count: %s", e) # Continue with indexed_count = 0 # Determine status @@ -355,7 +355,7 @@ async def get_user_session(request: Request) -> JSONResponse: # Verify token user matches requested user if token_user_id != path_user_id: logger.warning( - f"User {token_user_id} attempted to access session for {path_user_id}" + "User %s attempted to access session for %s", token_user_id, path_user_id ) return JSONResponse( { @@ -442,7 +442,7 @@ async def revoke_user_access(request: Request) -> JSONResponse: # Validate OAuth token and extract user token_user_id, validated = await validate_token_and_get_user(request) except Exception as e: - logger.warning(f"Unauthorized access to /api/v1/users/{{user_id}}/revoke: {e}") + logger.warning("Unauthorized access to /api/v1/users/{{user_id}}/revoke: %s", e) return JSONResponse( { "error": "Unauthorized", @@ -457,7 +457,7 @@ async def revoke_user_access(request: Request) -> JSONResponse: # Verify token user matches requested user if token_user_id != path_user_id: logger.warning( - f"User {token_user_id} attempted to revoke access for {path_user_id}" + "User %s attempted to revoke access for %s", token_user_id, path_user_id ) return JSONResponse( { @@ -492,7 +492,8 @@ async def revoke_user_access(request: Request) -> JSONResponse: await token_broker.cache.invalidate(token_user_id) logger.info( - f"Revoked background access for user {token_user_id} (cache and storage cleared)" + "Revoked background access for user %s (cache and storage cleared)", + token_user_id, ) return JSONResponse( diff --git a/nextcloud_mcp_server/api/passwords.py b/nextcloud_mcp_server/api/passwords.py index df3f5ba4..bf2651e6 100644 --- a/nextcloud_mcp_server/api/passwords.py +++ b/nextcloud_mcp_server/api/passwords.py @@ -143,7 +143,7 @@ def _extract_basic_auth( # Verify username matches path user_id if username != path_user_id: logger.warning( - f"Username mismatch in app password operation for path user {path_user_id}" + "Username mismatch in app password operation for path user %s", path_user_id ) return ( "", @@ -211,7 +211,7 @@ async def provision_app_password(request: Request) -> JSONResponse: is_allowed, retry_after = _check_rate_limit(path_user_id) if not is_allowed: logger.warning( - f"Rate limit exceeded for app password provisioning: {path_user_id}" + "Rate limit exceeded for app password provisioning: %s", path_user_id ) return JSONResponse( { @@ -263,7 +263,8 @@ async def provision_app_password(request: Request) -> JSONResponse: if response.status_code != 200: logger.warning( - f"App password validation failed for user: HTTP {response.status_code}" + "App password validation failed for user: HTTP %s", + response.status_code, ) _record_rate_limit_attempt(path_user_id, success=False) return JSONResponse( @@ -283,7 +284,7 @@ async def provision_app_password(request: Request) -> JSONResponse: ) except httpx.RequestError as e: - logger.error(f"Failed to validate app password: {e}") + logger.error("Failed to validate app password: %s", e) return JSONResponse( {"success": False, "error": "Failed to validate credentials"}, status_code=500, @@ -309,7 +310,7 @@ async def provision_app_password(request: Request) -> JSONResponse: invalidate_scope_cache(username) _record_rate_limit_attempt(path_user_id, success=True) - logger.info(f"Provisioned app password for user: {username}") + logger.info("Provisioned app password for user: %s", username) return JSONResponse( { @@ -409,7 +410,7 @@ async def delete_app_password(request: Request) -> JSONResponse: status_code=401, ) except httpx.RequestError as e: - logger.error(f"Failed to validate credentials: {e}") + logger.error("Failed to validate credentials: %s", e) return JSONResponse( {"success": False, "error": "Failed to validate credentials"}, status_code=500, @@ -420,7 +421,7 @@ async def delete_app_password(request: Request) -> JSONResponse: deleted = await storage.delete_app_password(username) if deleted: - logger.info(f"Deleted app password for user: {username}") + logger.info("Deleted app password for user: %s", username) return JSONResponse( { "success": True, diff --git a/nextcloud_mcp_server/api/visualization.py b/nextcloud_mcp_server/api/visualization.py index c3a9b790..c13d2f7d 100644 --- a/nextcloud_mcp_server/api/visualization.py +++ b/nextcloud_mcp_server/api/visualization.py @@ -90,7 +90,7 @@ async def unified_search(request: Request) -> JSONResponse: try: user_id, _validated = await validate_token_and_get_user(request) except Exception as e: - logger.warning(f"Unauthorized access to /api/v1/search: {e}") + logger.warning("Unauthorized access to /api/v1/search: %s", e) return JSONResponse( { "error": "Unauthorized", @@ -268,12 +268,12 @@ async def unified_search(request: Request) -> JSONResponse: ) response_data["pca_data"] = pca_data except Exception as e: - logger.warning(f"Failed to compute PCA for unified search: {e}") + logger.warning("Failed to compute PCA for unified search: %s", e) return JSONResponse(response_data) except Exception as e: - logger.error(f"Error in unified search: {e}") + logger.error("Error in unified search: %s", e) return JSONResponse( { "error": "Internal error", @@ -311,7 +311,7 @@ async def vector_search(request: Request) -> JSONResponse: try: user_id, _validated = await validate_token_and_get_user(request) except Exception as e: - logger.warning(f"Unauthorized access to /api/v1/vector-viz/search: {e}") + logger.warning("Unauthorized access to /api/v1/vector-viz/search: %s", e) return JSONResponse( { "error": "Unauthorized", @@ -428,7 +428,7 @@ async def vector_search(request: Request) -> JSONResponse: if "pca_variance" in pca_data: response_data["pca_variance"] = pca_data["pca_variance"] except Exception as e: - logger.warning(f"Failed to compute PCA coordinates: {e}") + logger.warning("Failed to compute PCA coordinates: %s", e) response_data["coordinates_3d"] = [] response_data["query_coords"] = [] elif include_pca: @@ -465,7 +465,7 @@ async def get_chunk_context(request: Request) -> JSONResponse: # Validate OAuth token and extract user user_id, validated = await validate_token_and_get_user(request) except Exception as e: - logger.warning(f"Unauthorized access to /api/v1/chunk-context: {e}") + logger.warning("Unauthorized access to /api/v1/chunk-context: %s", e) return JSONResponse( { "error": "Unauthorized", @@ -661,14 +661,16 @@ async def get_pdf_preview(request: Request) -> JSONResponse: # Log incoming request file_path_param = request.query_params.get("file_path", "") page_param = request.query_params.get("page", "1") - logger.info(f"PDF preview request: file_path={file_path_param}, page={page_param}") + logger.info( + "PDF preview request: file_path=%s, page=%s", file_path_param, page_param + ) try: # Validate OAuth token and extract user user_id, validated = await validate_token_and_get_user(request) - logger.info(f"PDF preview authenticated for user: {user_id}") + logger.info("PDF preview authenticated for user: %s", user_id) except Exception as e: - logger.warning(f"Unauthorized access to /api/v1/pdf-preview: {e}") + logger.warning("Unauthorized access to /api/v1/pdf-preview: %s", e) return JSONResponse( { "success": False, @@ -763,8 +765,11 @@ async def get_pdf_preview(request: Request) -> JSONResponse: image_b64 = base64.b64encode(png_bytes).decode("ascii") logger.info( - f"Rendered PDF preview: {file_path} page {page_num}/{total_pages}, " - f"{len(png_bytes):,} bytes" + "Rendered PDF preview: %s page %s/%s, %s bytes", + file_path, + page_num, + total_pages, + format(len(png_bytes), ","), ) return JSONResponse( @@ -777,19 +782,19 @@ async def get_pdf_preview(request: Request) -> JSONResponse: ) except FileNotFoundError: - logger.warning(f"PDF file not found: {file_path_param}") + logger.warning("PDF file not found: %s", file_path_param) return JSONResponse( {"success": False, "error": "PDF file not found"}, status_code=404, ) except (pymupdf.FileDataError, pymupdf.EmptyFileError): - logger.warning(f"Invalid or corrupted PDF file: {file_path_param}") + logger.warning("Invalid or corrupted PDF file: %s", file_path_param) return JSONResponse( {"success": False, "error": "Invalid or corrupted PDF file"}, status_code=400, ) except Exception as e: - logger.error(f"PDF preview error: {e}", exc_info=True) + logger.error("PDF preview error: %s", e, exc_info=True) error_msg = _sanitize_error_for_client(e, "get_pdf_preview") return JSONResponse( {"success": False, "error": error_msg}, diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index 5bdedf86..ce1024d6 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -169,10 +169,10 @@ def initialize_document_processors(): progress_interval=unst_config.get("progress_interval", 10), ) registry.register(processor, priority=10) - logger.info(f"Registered Unstructured processor: {unst_config['api_url']}") + logger.info("Registered Unstructured processor: %s", unst_config["api_url"]) registered_count += 1 except Exception as e: - logger.warning(f"Failed to register Unstructured processor: {e}") + logger.warning("Failed to register Unstructured processor: %s", e) # Register Tesseract processor if "tesseract" in config["processors"]: @@ -187,10 +187,10 @@ def initialize_document_processors(): default_lang=tess_config["lang"], ) registry.register(processor, priority=5) - logger.info(f"Registered Tesseract processor: lang={tess_config['lang']}") + logger.info("Registered Tesseract processor: lang=%s", tess_config["lang"]) registered_count += 1 except Exception as e: - logger.warning(f"Failed to register Tesseract processor: {e}") + logger.warning("Failed to register Tesseract processor: %s", e) # Register PyMuPDF processor (high priority, local, no API required) if "pymupdf" in config["processors"]: @@ -206,11 +206,12 @@ def initialize_document_processors(): ) registry.register(processor, priority=15) # Higher than unstructured logger.info( - f"Registered PyMuPDF processor: extract_images={pymupdf_config.get('extract_images', True)}" + "Registered PyMuPDF processor: extract_images=%s", + pymupdf_config.get("extract_images", True), ) registered_count += 1 except Exception as e: - logger.warning(f"Failed to register PyMuPDF processor: {e}") + logger.warning("Failed to register PyMuPDF processor: %s", e) # Register custom processor if "custom" in config["processors"]: @@ -229,16 +230,19 @@ def initialize_document_processors(): ) registry.register(processor, priority=1) logger.info( - f"Registered Custom processor '{custom_config['name']}': {custom_config['api_url']}" + "Registered Custom processor '%s': %s", + custom_config["name"], + custom_config["api_url"], ) registered_count += 1 except Exception as e: - logger.warning(f"Failed to register Custom processor: {e}") + logger.warning("Failed to register Custom processor: %s", e) if registered_count > 0: logger.info( - f"Document processing initialized with {registered_count} processor(s): " - f"{', '.join(registry.list_processors())}" + "Document processing initialized with %s processor(s): %s", + registered_count, + ", ".join(registry.list_processors()), ) else: logger.warning("Document processing enabled but no processors registered") @@ -409,10 +413,10 @@ class BasicAuthMiddleware: "password": password, } logger.debug( - f"BasicAuth credentials extracted for user: {username}" + "BasicAuth credentials extracted for user: %s", username ) except Exception as e: - logger.warning(f"Failed to extract BasicAuth credentials: {e}") + logger.warning("Failed to extract BasicAuth credentials: %s", e) await self.app(scope, receive, send) @@ -451,7 +455,7 @@ async def load_oauth_client_credentials( client_data = await storage.get_oauth_client() if client_data: logger.info( - f"Loaded OAuth client from SQLite: {client_data['client_id'][:16]}..." + "Loaded OAuth client from SQLite: %s...", client_data["client_id"][:16] ) return (client_data["client_id"], client_data["client_secret"]) except ValueError: @@ -496,7 +500,7 @@ async def load_oauth_client_credentials( dcr_scopes = f"{dcr_scopes} offline_access" logger.info("✓ offline_access scope enabled for refresh tokens") - logger.info(f"MCP server DCR scopes (resource server): {dcr_scopes}") + logger.info("MCP server DCR scopes (resource server): %s", dcr_scopes) # Get token type from environment (Bearer or jwt) # Note: Must be lowercase "jwt" to match OIDC app's check @@ -504,7 +508,7 @@ async def load_oauth_client_credentials( # Special case: "bearer" should remain capitalized for compatibility if token_type != "jwt": token_type = "Bearer" - logger.info(f"Requesting token type: {token_type}") + logger.info("Requesting token type: %s", token_type) # Ensure OAuth client in SQLite storage storage = RefreshTokenStorage.from_env() @@ -525,7 +529,7 @@ async def load_oauth_client_credentials( resource_url=resource_url, # RFC 9728 Protected Resource URL ) - logger.info(f"OAuth client ready: {client_info.client_id[:16]}...") + logger.info("OAuth client ready: %s...", client_info.client_id[:16]) return (client_info.client_id, client_info.client_secret) # No credentials available @@ -555,7 +559,8 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]: is_multi_user = settings.enable_multi_user_basic_auth logger.info( - f"Starting MCP session in {'multi-user' if is_multi_user else 'single-user'} BasicAuth mode" + "Starting MCP session in %s BasicAuth mode", + "multi-user" if is_multi_user else "single-user", ) # Only create shared client for single-user mode @@ -633,7 +638,7 @@ async def setup_oauth_config(): discovery_url = os.getenv( "OIDC_DISCOVERY_URL", f"{nextcloud_host}/.well-known/openid-configuration" ) - logger.info(f"Performing OIDC discovery: {discovery_url}") + logger.info("Performing OIDC discovery: %s", discovery_url) # Perform OIDC discovery async with nextcloud_httpx_client(follow_redirects=True) as client: @@ -654,12 +659,12 @@ async def setup_oauth_config(): registration_endpoint = discovery.get("registration_endpoint") logger.info("OIDC endpoints discovered:") - logger.info(f" Issuer: {issuer}") - logger.info(f" Userinfo: {userinfo_uri}") + logger.info(" Issuer: %s", issuer) + logger.info(" Userinfo: %s", userinfo_uri) if jwks_uri: - logger.info(f" JWKS: {jwks_uri}") + logger.info(" JWKS: %s", jwks_uri) if introspection_uri: - logger.info(f" Introspection: {introspection_uri}") + logger.info(" Introspection: %s", introspection_uri) # Auto-detect provider mode based on issuer # External IdP mode: issuer doesn't match Nextcloud host @@ -685,7 +690,9 @@ async def setup_oauth_config(): if is_external_idp: oauth_provider = "external" # Could be Keycloak, Auth0, Okta, etc. logger.info( - f"✓ Detected external IdP mode (issuer: {issuer} != Nextcloud: {nextcloud_host})" + "✓ Detected external IdP mode (issuer: %s != Nextcloud: %s)", + issuer, + nextcloud_host, ) logger.info(" Tokens will be validated via Nextcloud user_oidc app") else: @@ -716,7 +723,7 @@ async def setup_oauth_config(): "✓ Refresh token storage initialized (offline_access enabled)" ) except Exception as e: - logger.error(f"Failed to initialize refresh token storage: {e}") + logger.error("Failed to initialize refresh token storage: %s", e) logger.warning( "Continuing without refresh token storage - users will need to re-authenticate after token expiration" ) @@ -726,7 +733,7 @@ async def setup_oauth_config(): client_secret = os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET") if client_id and client_secret: - logger.info(f"Using static OIDC client credentials: {client_id}") + logger.info("Using static OIDC client credentials: %s", client_id) elif registration_endpoint: logger.info( "NEXTCLOUD_OIDC_CLIENT_ID not set, attempting Dynamic Client Registration" @@ -753,13 +760,13 @@ async def setup_oauth_config(): # Warn if resource URIs are not configured (required for ADR-005 compliance) if not os.getenv("NEXTCLOUD_MCP_SERVER_URL"): logger.warning( - f"NEXTCLOUD_MCP_SERVER_URL not set, defaulting to: {mcp_server_url}. " - "This should be set explicitly for proper audience validation." + "NEXTCLOUD_MCP_SERVER_URL not set, defaulting to: %s. This should be set explicitly for proper audience validation.", + mcp_server_url, ) if not os.getenv("NEXTCLOUD_RESOURCE_URI"): logger.warning( - f"NEXTCLOUD_RESOURCE_URI not set, defaulting to: {nextcloud_resource_uri}. " - "This should be set explicitly for proper audience validation." + "NEXTCLOUD_RESOURCE_URI not set, defaulting to: %s. This should be set explicitly for proper audience validation.", + nextcloud_resource_uri, ) # Create settings for UnifiedTokenVerifier (use same settings instance from start of function) @@ -790,8 +797,8 @@ async def setup_oauth_config(): logger.info( "✓ Multi-audience mode enabled (ADR-005) - tokens must contain both MCP and Nextcloud audiences" ) - logger.info(f" Required MCP audience: {client_id} or {mcp_server_url}") - logger.info(f" Required Nextcloud audience: {nextcloud_resource_uri}") + logger.info(" Required MCP audience: %s or %s", client_id, mcp_server_url) + logger.info(" Required Nextcloud audience: %s", nextcloud_resource_uri) if introspection_uri: logger.info("✓ Opaque token introspection enabled (RFC 7662)") @@ -881,7 +888,8 @@ async def setup_oauth_config_for_multi_user_basic( f"{nextcloud_host}/.well-known/openid-configuration", ) logger.info( - f"Performing OIDC discovery for multi-user BasicAuth hybrid mode: {discovery_url}" + "Performing OIDC discovery for multi-user BasicAuth hybrid mode: %s", + discovery_url, ) # Perform OIDC discovery @@ -894,20 +902,22 @@ async def setup_oauth_config_for_multi_user_basic( discovery = response.json() except httpx.HTTPStatusError as e: logger.error( - f"OIDC discovery failed: HTTP {e.response.status_code} from {discovery_url}" + "OIDC discovery failed: HTTP %s from %s", + e.response.status_code, + discovery_url, ) raise ValueError( f"OIDC discovery failed: HTTP {e.response.status_code} from {discovery_url}. " "Ensure Nextcloud OIDC (user_oidc app) is installed and configured." ) from e except httpx.RequestError as e: - logger.error(f"OIDC discovery failed: {e}") + logger.error("OIDC discovery failed: %s", e) raise ValueError( f"OIDC discovery failed: Cannot connect to {discovery_url}. Error: {e}" ) from e except (KeyError, ValueError) as e: logger.error( - f"OIDC discovery failed: Invalid response from {discovery_url}: {e}" + "OIDC discovery failed: Invalid response from %s: %s", discovery_url, e ) raise ValueError( f"OIDC discovery failed: Invalid response from {discovery_url}. " @@ -923,10 +933,10 @@ async def setup_oauth_config_for_multi_user_basic( introspection_uri = discovery.get("introspection_endpoint") logger.info("OIDC endpoints configured for management API:") - logger.info(f" Issuer: {issuer}") - logger.info(f" Userinfo: {userinfo_uri}") - logger.info(f" JWKS: {jwks_uri}") - logger.info(f" Introspection: {introspection_uri}") + logger.info(" Issuer: %s", issuer) + logger.info(" Userinfo: %s", userinfo_uri) + logger.info(" JWKS: %s", jwks_uri) + logger.info(" Introspection: %s", introspection_uri) # Get MCP server URL for audience validation mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000") @@ -982,8 +992,8 @@ async def setup_oauth_config_for_multi_user_basic( "✓ Refresh token storage initialized for background operations (hybrid mode)" ) except Exception as e: - logger.error(f"Failed to initialize refresh token storage: {e}") - logger.debug(f"Full traceback:\n{traceback.format_exc()}") + logger.error("Failed to initialize refresh token storage: %s", e) + logger.debug("Full traceback:\\n%s", traceback.format_exc()) logger.warning( "Continuing without refresh token storage - webhook management may be limited" ) @@ -1012,8 +1022,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = logger.error(error_msg) raise ValueError(error_msg) - logger.info(f"✅ Configuration validated successfully for {mode.value} mode") - logger.debug(f"Mode details:\n{get_mode_summary(mode)}") + logger.info("✅ Configuration validated successfully for %s mode", mode.value) + logger.debug("Mode details:\\n%s", get_mode_summary(mode)) # Derive helper variables for backward compatibility with existing code. # `oauth_enabled` is True for the LOGIN_FLOW (formerly OAUTH_SINGLE_AUDIENCE) @@ -1033,7 +1043,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = if settings.metrics_enabled: setup_metrics(port=settings.metrics_port) logger.info( - f"Prometheus metrics enabled on dedicated port {settings.metrics_port}" + "Prometheus metrics enabled on dedicated port %s", settings.metrics_port ) # Setup OpenTelemetry tracing (optional) @@ -1045,7 +1055,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = sampling_rate=settings.otel_traces_sampler_arg, ) logger.info( - f"OpenTelemetry tracing enabled (endpoint: {settings.otel_exporter_otlp_endpoint})" + "OpenTelemetry tracing enabled (endpoint: %s)", + settings.otel_exporter_otlp_endpoint, ) else: logger.info( @@ -1093,7 +1104,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = # This avoids relying on discovery doc which may use public URLs unreachable from containers registration_endpoint = f"{settings.nextcloud_host}/apps/oidc/register" logger.info( - f"Attempting Dynamic Client Registration at: {registration_endpoint}" + "Attempting Dynamic Client Registration at: %s", + registration_endpoint, ) # Perform DCR @@ -1108,13 +1120,13 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = registration_endpoint=registration_endpoint, ) logger.info( - f"✓ Dynamic Client Registration successful for background operations " - f"(client_id: {client_id[:16]}...)" + "✓ Dynamic Client Registration successful for background operations (client_id: %s...)", + client_id[:16], ) return (client_id, client_secret) except Exception as e: - logger.error(f"Dynamic Client Registration failed: {e}") - logger.debug(f"Full traceback:\n{traceback.format_exc()}") + logger.error("Dynamic Client Registration failed: %s", e) + logger.debug("Full traceback:\\n%s", traceback.format_exc()) logger.warning("Background vector sync will be disabled.") return None @@ -1151,8 +1163,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = # - httpx.HTTPError: Network issues, OIDC discovery failures # - ValueError: Missing required configuration (NEXTCLOUD_HOST) # - KeyError: Missing required fields in OIDC discovery response - logger.error(f"Failed to setup OAuth infrastructure: {e}") - logger.debug(f"Full traceback:\n{traceback.format_exc()}") + logger.error("Failed to setup OAuth infrastructure: %s", e) + logger.debug("Full traceback:\\n%s", traceback.format_exc()) logger.warning( "Management API will be unavailable. " "Webhook management from Astrolabe admin UI will not work." @@ -1163,8 +1175,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = except Exception as e: # Unexpected error - this is a programming error, re-raise it logger.error( - f"Unexpected error during OAuth infrastructure setup: {e}. " - "This is likely a programming error that should be fixed." + "Unexpected error during OAuth infrastructure setup: %s. This is likely a programming error that should be fixed.", + e, ) raise @@ -1191,7 +1203,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = Lifespan context for OAuth mode - captures OAuth configuration from outer scope. """ logger.info("Starting MCP server in OAuth mode") - logger.info(f"Using OAuth provider: {oauth_provider}") + logger.info("Using OAuth provider: %s", oauth_provider) if refresh_token_storage: logger.info("Refresh token storage is available") if oauth_client: @@ -1223,7 +1235,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = try: await oauth_client.close() except Exception as e: - logger.warning(f"Error closing OAuth client: {e}") + logger.warning("Error closing OAuth client: %s", e) logger.info("MCP server shutdown complete") mcp = FastMCP( @@ -1239,7 +1251,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = ) else: # BasicAuth modes (single-user or multi-user) - logger.info(f"Configuring MCP server for {mode.value} mode") + logger.info("Configuring MCP server for %s mode", mode.value) mcp = FastMCP( "Nextcloud MCP", lifespan=app_lifespan_basic, @@ -1264,11 +1276,13 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = # Configure only the enabled apps for app_name in enabled_apps: if app_name in AVAILABLE_APPS: - logger.info(f"Configuring {app_name} tools") + logger.info("Configuring %s tools", app_name) AVAILABLE_APPS[app_name](mcp) else: logger.warning( - f"Unknown app: {app_name}. Available apps: {list(AVAILABLE_APPS.keys())}" + "Unknown app: %s. Available apps: %s", + app_name, + list(AVAILABLE_APPS.keys()), ) # Register semantic search tools (cross-app feature) @@ -1304,8 +1318,9 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = user_scopes = get_access_token_scopes() is_jwt = is_jwt_token() logger.info( - f"🔍 list_tools called - Token type: {'JWT' if is_jwt else 'opaque/none'}, " - f"User scopes: {user_scopes}" + "🔍 list_tools called - Token type: %s, User scopes: %s", + "JWT" if is_jwt else "opaque/none", + user_scopes, ) # Get all tools @@ -1323,14 +1338,17 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = ] token_type = "JWT" if is_jwt else "Bearer" logger.info( - f"✂️ {token_type} scope filtering: {len(allowed_tools)}/{len(all_tools)} tools " - f"available for scopes: {user_scopes}" + "✂️ %s scope filtering: %s/%s tools available for scopes: %s", + token_type, + len(allowed_tools), + len(all_tools), + user_scopes, ) else: # BasicAuth mode or no token - show all tools allowed_tools = all_tools logger.info( - f"📋 Showing all {len(all_tools)} tools (no token/BasicAuth)" + "📋 Showing all %s tools (no token/BasicAuth)", len(all_tools) ) # Return the Tool objects directly (they're already in the correct format) @@ -1457,7 +1475,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = break logger.info( - f"OAuth context initialized for login routes (client_id={client_id[:16]}...)" + "OAuth context initialized for login routes (client_id=%s...)", + client_id[:16], ) else: # BasicAuth mode - initialize storage for webhook management @@ -1505,7 +1524,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = } app.state.oauth_context = oauth_context_dict logger.info( - f"✓ OAuth context initialized for management APIs (hybrid mode, client_id={sync_client_id[:16]}...)" + "✓ OAuth context initialized for management APIs (hybrid mode, client_id=%s...)", + sync_client_id[:16], ) elif multi_user_basic_oauth_creds and multi_user_token_verifier is None: logger.warning( @@ -1573,7 +1593,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = await get_qdrant_client() # Triggers collection creation if needed logger.info("Qdrant collection ready") except Exception as e: - logger.error(f"Failed to initialize Qdrant collection: {e}") + logger.error("Failed to initialize Qdrant collection: %s", e) raise RuntimeError( f"Cannot start vector sync - Qdrant initialization failed: {e}" ) from e @@ -1639,8 +1659,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = _vector_sync_state.eviction_task_group = tg logger.info( - f"Background sync tasks started: 1 scanner + " - f"{settings.vector_sync_processor_workers} processors" + "Background sync tasks started: 1 scanner + %s processors", + settings.vector_sync_processor_workers, ) # Run MCP session manager and yield @@ -1664,7 +1684,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = # OAuth mode with background operations - multi-user sync # Also used for multi-user BasicAuth mode (client auth is BasicAuth, background sync uses app passwords or OAuth) mode_desc = "OAuth mode" if oauth_enabled else "Multi-user BasicAuth mode" - logger.info(f"Starting background vector sync tasks for {mode_desc}") + logger.info("Starting background vector sync tasks for %s", mode_desc) # Get nextcloud_host (from settings - already validated) nextcloud_host_for_sync = settings.nextcloud_host @@ -1735,7 +1755,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = await get_qdrant_client() # Triggers collection creation if needed logger.info("Qdrant collection ready") except Exception as e: - logger.error(f"Failed to initialize Qdrant collection: {e}") + logger.error("Failed to initialize Qdrant collection: %s", e) raise RuntimeError( f"Cannot start vector sync - Qdrant initialization failed: {e}" ) from e @@ -1748,10 +1768,12 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = ) if removed: logger.info( - f"Cleaned up {len(removed)} stale app password(s): {removed}" + "Cleaned up %s stale app password(s): %s", + len(removed), + removed, ) except Exception as e: - logger.warning(f"App password cleanup failed (non-fatal): {e}") + logger.warning("App password cleanup failed (non-fatal): %s", e) # Initialize shared state send_stream, receive_stream = anyio.create_memory_object_stream( @@ -1829,8 +1851,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = _vector_sync_state.eviction_task_group = tg logger.info( - f"Background sync tasks started: 1 user manager + " - f"{settings.vector_sync_processor_workers} processors" + "Background sync tasks started: 1 user manager + %s processors", + settings.vector_sync_processor_workers, ) # Run MCP session manager and yield @@ -2137,8 +2159,10 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = if oauth_provisioning_available: logger.info( - f"OAuth provisioning routes enabled for mode: {mode.value} " - f"(oauth_enabled={oauth_enabled}, hybrid_mode={not oauth_enabled})" + "OAuth provisioning routes enabled for mode: %s (oauth_enabled=%s, hybrid_mode=%s)", + mode.value, + oauth_enabled, + not oauth_enabled, ) def oauth_protected_resource_metadata(request): @@ -2330,7 +2354,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = browser_routes.append( Mount("/static", StaticFiles(directory=static_dir), name="static") ) - logger.info(f"Mounted static files from {static_dir}") + logger.info("Mounted static files from %s", static_dir) browser_app = Starlette(routes=browser_routes) browser_app.add_middleware( @@ -2381,13 +2405,14 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = token_preview = ( auth_header[:50] + "..." if len(auth_header) > 50 else auth_header ) - logger.info(f"🔑 /mcp request with Authorization: {token_preview}") + logger.info("🔑 /mcp request with Authorization: %s", token_preview) else: # Only warn about missing Authorization in OAuth mode # In BasicAuth mode, /mcp requests without Authorization are expected if oauth_enabled: logger.warning( - f"⚠️ /mcp request WITHOUT Authorization header from {request.client}" + "⚠️ /mcp request WITHOUT Authorization header from %s", + request.client, ) # Log client capabilities on initialize request @@ -2404,8 +2429,9 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = client_info = params.get("clientInfo", {}) logger.info( - f"🔌 MCP client connected: {client_info.get('name', 'unknown')} " - f"v{client_info.get('version', 'unknown')}" + "🔌 MCP client connected: %s v%s", + client_info.get("name", "unknown"), + client_info.get("version", "unknown"), ) # Log capabilities in a structured way @@ -2421,16 +2447,17 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = ) logger.info( - f"📋 Client capabilities: {', '.join(cap_summary) if cap_summary else 'none'}" + "📋 Client capabilities: %s", + ", ".join(cap_summary) if cap_summary else "none", ) # Log full capabilities at INFO level to diagnose capability issues logger.info( - f"Full capabilities JSON: {json.dumps(capabilities)}" + "Full capabilities JSON: %s", json.dumps(capabilities) ) except Exception as e: # Don't fail the request if logging fails logger.debug( - f"Failed to parse MCP request for capability logging: {e}" + "Failed to parse MCP request for capability logging: %s", e ) response = await call_next(request) diff --git a/nextcloud_mcp_server/auth/astrolabe_client.py b/nextcloud_mcp_server/auth/astrolabe_client.py index c280a85d..976290d5 100644 --- a/nextcloud_mcp_server/auth/astrolabe_client.py +++ b/nextcloud_mcp_server/auth/astrolabe_client.py @@ -61,12 +61,12 @@ class AstrolabeClient: discovery_url = f"{self.nextcloud_host}/.well-known/openid-configuration" async with nextcloud_httpx_client() as client: - logger.debug(f"Discovering token endpoint from {discovery_url}") + logger.debug("Discovering token endpoint from %s", discovery_url) discovery_resp = await client.get(discovery_url) discovery_resp.raise_for_status() token_endpoint = discovery_resp.json()["token_endpoint"] - logger.debug(f"Requesting client credentials token from {token_endpoint}") + logger.debug("Requesting client credentials token from %s", token_endpoint) # Request token using client credentials grant token_resp = await client.post( @@ -88,7 +88,7 @@ class AstrolabeClient: "expires_at": time.time() + expires_in - 60, } - logger.info(f"Obtained Astrolabe API token (expires in {expires_in}s)") + logger.info("Obtained Astrolabe API token (expires in %ss)", expires_in) return data["access_token"] async def get_user_app_password(self, user_id: str) -> Optional[str]: @@ -108,7 +108,7 @@ class AstrolabeClient: url = f"{self.nextcloud_host}/apps/astrolabe/api/v1/background-sync/credentials/{user_id}" async with nextcloud_httpx_client() as client: - logger.debug(f"Retrieving app password for user: {user_id}") + logger.debug("Retrieving app password for user: %s", user_id) response = await client.get( url, @@ -117,14 +117,16 @@ class AstrolabeClient: ) if response.status_code == 404: - logger.debug(f"No app password configured for user: {user_id}") + logger.debug("No app password configured for user: %s", user_id) return None response.raise_for_status() data = response.json() logger.info( - f"Retrieved app password for user: {user_id} (type: {data.get('credential_type')})" + "Retrieved app password for user: %s (type: %s)", + user_id, + data.get("credential_type"), ) return data.get("app_password") diff --git a/nextcloud_mcp_server/auth/client_registration.py b/nextcloud_mcp_server/auth/client_registration.py index 7f1a9fc3..469b78a6 100644 --- a/nextcloud_mcp_server/auth/client_registration.py +++ b/nextcloud_mcp_server/auth/client_registration.py @@ -133,8 +133,8 @@ async def register_client( if resource_url: client_metadata["resource_url"] = resource_url - logger.info(f"Registering OAuth client with Nextcloud: {client_name}") - logger.debug(f"Registration endpoint: {registration_endpoint}") + logger.info("Registering OAuth client with Nextcloud: %s", client_name) + logger.debug("Registration endpoint: %s", registration_endpoint) async with nextcloud_httpx_client(timeout=30.0) as client: for attempt in range(max_retries): @@ -151,14 +151,17 @@ async def register_client( retry_after = int(response.headers.get("Retry-After", 2)) wait_time = min(retry_after, 2**attempt) logger.warning( - f"Rate limited (429) registering client, " - f"retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})" + "Rate limited (429) registering client, retrying in %ss (attempt %s/%s)", + wait_time, + attempt + 1, + max_retries, ) await anyio.sleep(wait_time) continue else: logger.error( - f"Failed to register client after {max_retries} attempts: Rate limited (429)" + "Failed to register client after %s attempts: Rate limited (429)", + max_retries, ) response.raise_for_status() @@ -166,14 +169,15 @@ async def register_client( client_info = response.json() logger.info( - f"Successfully registered client: {client_info.get('client_id')}" + "Successfully registered client: %s", client_info.get("client_id") ) expires_at = dt.datetime.fromtimestamp( client_info.get("client_secret_expires_at") ) logger.info( - f"Client expires at: {expires_at} " - f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)" + "Client expires at: %s (in %s seconds)", + expires_at, + client_info.get("client_secret_expires_at", 0) - int(time.time()), ) # Log if RFC 7592 fields are present @@ -206,13 +210,13 @@ async def register_client( except httpx.HTTPStatusError as e: logger.error( - f"Failed to register client: HTTP {e.response.status_code}" + "Failed to register client: HTTP %s", e.response.status_code ) - logger.error(f"Response: {e.response.text}") + logger.error("Response: %s", e.response.text) raise except KeyError as e: logger.error( - f"Invalid response from registration endpoint: missing {e}" + "Invalid response from registration endpoint: missing %s", e ) raise ValueError(f"Invalid registration response: missing {e}") @@ -264,8 +268,8 @@ async def delete_client( else: deletion_endpoint = f"{nextcloud_url}/apps/oidc/register/{client_id}" - logger.info(f"Deleting OAuth client: {client_id[:16]}...") - logger.debug(f"Deletion endpoint: {deletion_endpoint}") + logger.info("Deleting OAuth client: %s...", client_id[:16]) + logger.debug("Deletion endpoint: %s", deletion_endpoint) async with nextcloud_httpx_client(timeout=30.0) as http_client: for attempt in range(max_retries): @@ -296,7 +300,7 @@ async def delete_client( # RFC 7592: Successful deletion returns 204 No Content if response.status_code == 204: logger.info( - f"Successfully deleted OAuth client: {client_id[:16]}..." + "Successfully deleted OAuth client: %s...", client_id[:16] ) return True elif response.status_code == 429: @@ -307,42 +311,53 @@ async def delete_client( retry_after, 2**attempt ) # Exponential backoff, max from header logger.warning( - f"Rate limited (429) deleting client {client_id[:16]}..., " - f"retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})" + "Rate limited (429) deleting client %s..., retrying in %ss (attempt %s/%s)", + client_id[:16], + wait_time, + attempt + 1, + max_retries, ) await anyio.sleep(wait_time) continue else: logger.error( - f"Failed to delete client {client_id[:16]}... after {max_retries} attempts: Rate limited (429)" + "Failed to delete client %s... after %s attempts: Rate limited (429)", + client_id[:16], + max_retries, ) return False elif response.status_code == 401: logger.error( - f"Failed to delete client {client_id[:16]}...: Authentication failed (invalid credentials)" + "Failed to delete client %s...: Authentication failed (invalid credentials)", + client_id[:16], ) return False elif response.status_code == 403: logger.error( - f"Failed to delete client {client_id[:16]}...: Not authorized (not a DCR client or wrong client)" + "Failed to delete client %s...: Not authorized (not a DCR client or wrong client)", + client_id[:16], ) return False else: logger.error( - f"Failed to delete client {client_id[:16]}...: HTTP {response.status_code}" + "Failed to delete client %s...: HTTP %s", + client_id[:16], + response.status_code, ) - logger.debug(f"Response: {response.text}") + logger.debug("Response: %s", response.text) return False except httpx.HTTPStatusError as e: logger.error( - f"HTTP error deleting client {client_id[:16]}...: {e.response.status_code}" + "HTTP error deleting client %s...: %s", + client_id[:16], + e.response.status_code, ) - logger.debug(f"Response: {e.response.text}") + logger.debug("Response: %s", e.response.text) return False except Exception as e: logger.error( - f"Unexpected error deleting client {client_id[:16]}...: {e}" + "Unexpected error deleting client %s...: %s", client_id[:16], e ) return False @@ -390,14 +405,14 @@ async def ensure_oauth_client( client_data = await storage.get_oauth_client() if client_data: logger.info( - f"Loaded OAuth client from SQLite: {client_data['client_id'][:16]}..." + "Loaded OAuth client from SQLite: %s...", client_data["client_id"][:16] ) return ClientInfo.from_dict(client_data) # Register new client logger.info("Registering new OAuth client...") if resource_url: - logger.info(f" with resource_url: {resource_url}") + logger.info(" with resource_url: %s", resource_url) client_info = await register_client( nextcloud_url=nextcloud_url, registration_endpoint=registration_endpoint, diff --git a/nextcloud_mcp_server/auth/client_registry.py b/nextcloud_mcp_server/auth/client_registry.py index 1a9e317c..590b2317 100644 --- a/nextcloud_mcp_server/auth/client_registry.py +++ b/nextcloud_mcp_server/auth/client_registry.py @@ -89,7 +89,7 @@ class ClientRegistry: if not cid or not redirect: logger.warning( - f"Skipping malformed ALLOWED_MCP_CLIENTS entry: {entry!r}" + "Skipping malformed ALLOWED_MCP_CLIENTS entry: %r", entry ) continue @@ -97,8 +97,9 @@ class ClientRegistry: hostname = parsed.hostname if hostname is None: logger.warning( - f"Skipping client {cid!r}: cannot parse hostname " - f"from {redirect!r}" + "Skipping client %r: cannot parse hostname from %r", + cid, + redirect, ) continue is_loopback = hostname in ("localhost", "127.0.0.1", "::1") @@ -108,8 +109,9 @@ class ClientRegistry: or (parsed.scheme == "http" and is_loopback) ): logger.warning( - f"Rejecting client {cid!r}: HTTP redirect URIs are only " - f"allowed for localhost, got {redirect!r}" + "Rejecting client %r: HTTP redirect URIs are only allowed for localhost, got %r", + cid, + redirect, ) continue @@ -120,7 +122,7 @@ class ClientRegistry: allowed_scopes=["*"], is_public=True, ) - logger.info(f"Registered static client: {cid}") + logger.info("Registered static client: %s", cid) else: self._clients[entry] = MCPClientInfo( client_id=entry, @@ -129,7 +131,7 @@ class ClientRegistry: allowed_scopes=["*"], is_public=True, ) - logger.info(f"Registered static client: {entry}") + logger.info("Registered static client: %s", entry) if not self._clients: logger.warning( @@ -170,7 +172,7 @@ class ClientRegistry: if not client: if self.allow_dynamic_registration: # In production, would attempt DCR here - logger.info(f"Unknown client {client_id}, would attempt DCR") + logger.info("Unknown client %s, would attempt DCR", client_id) return True, None else: return False, f"Unknown client_id: {client_id}" @@ -229,15 +231,15 @@ class ClientRegistry: True if registered successfully """ if not self.allow_dynamic_registration: - logger.warning(f"DCR disabled, cannot register {client_info.client_id}") + logger.warning("DCR disabled, cannot register %s", client_info.client_id) return False if client_info.client_id in self._clients: - logger.warning(f"Client {client_info.client_id} already registered") + logger.warning("Client %s already registered", client_info.client_id) return False self._clients[client_info.client_id] = client_info - logger.info(f"Dynamically registered client: {client_info.client_id}") + logger.info("Dynamically registered client: %s", client_info.client_id) # In production, would persist to database return True @@ -263,7 +265,7 @@ class ClientRegistry: allowed_scopes=["*"], # Nextcloud enforces actual scopes is_public=True, ) - logger.info(f"Registered proxy client: {client_id}") + logger.info("Registered proxy client: %s", client_id) def get_client(self, client_id: str) -> Optional[MCPClientInfo]: """ diff --git a/nextcloud_mcp_server/auth/context_helper.py b/nextcloud_mcp_server/auth/context_helper.py index 480be8ba..094ef203 100644 --- a/nextcloud_mcp_server/auth/context_helper.py +++ b/nextcloud_mcp_server/auth/context_helper.py @@ -54,8 +54,8 @@ def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient: raise ValueError("Username not available in OAuth token context") logger.debug( - f"Creating NextcloudClient for user {username} with multi-audience token " - f"(no exchange needed)" + "Creating NextcloudClient for user %s with multi-audience token (no exchange needed)", + username, ) # Token was validated to have MCP audience @@ -65,6 +65,6 @@ def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient: ) except AttributeError as e: - logger.error(f"Failed to extract OAuth context: {e}") + logger.error("Failed to extract OAuth context: %s", e) logger.error("This may indicate the server is not running in OAuth mode") raise diff --git a/nextcloud_mcp_server/auth/login_flow.py b/nextcloud_mcp_server/auth/login_flow.py index 0bfdd10a..95dfd3e1 100644 --- a/nextcloud_mcp_server/auth/login_flow.py +++ b/nextcloud_mcp_server/auth/login_flow.py @@ -129,7 +129,7 @@ class LoginFlowV2Client: f"Malformed Login Flow v2 initiate response from Nextcloud (missing key: {e})" ) from e - logger.info(f"Login Flow v2 initiated: login_url={result.login_url[:60]}...") + logger.info("Login Flow v2 initiated: login_url=%s...", result.login_url[:60]) return result def _rewrite_to_nextcloud_host(self, url: str) -> str: @@ -142,7 +142,7 @@ class LoginFlowV2Client: """ result = rewrite_url_origin(url, self.nextcloud_host) if result != url: - logger.debug(f"Rewrote Login Flow v2 URL: {url} → {result}") + logger.debug("Rewrote Login Flow v2 URL: %s → %s", url, result) return result async def poll(self, poll_endpoint: str, poll_token: str) -> LoginFlowPollResult: @@ -172,8 +172,9 @@ class LoginFlowV2Client: if response.status_code == 200: data = response.json() logger.info( - f"Login Flow v2 completed: server={data.get('server')}, " - f"loginName={data.get('loginName')}" + "Login Flow v2 completed: server=%s, loginName=%s", + data.get("server"), + data.get("loginName"), ) try: return LoginFlowPollResult( @@ -193,6 +194,6 @@ class LoginFlowV2Client: # Any other status indicates the flow has expired or is invalid logger.warning( - f"Login Flow v2 poll returned unexpected status: {response.status_code}" + "Login Flow v2 poll returned unexpected status: %s", response.status_code ) return LoginFlowPollResult(status="expired") diff --git a/nextcloud_mcp_server/auth/oauth_routes.py b/nextcloud_mcp_server/auth/oauth_routes.py index 92ac6268..75a72ab8 100644 --- a/nextcloud_mcp_server/auth/oauth_routes.py +++ b/nextcloud_mcp_server/auth/oauth_routes.py @@ -359,7 +359,8 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse: if auth_parsed.query: authorization_endpoint += f"?{auth_parsed.query}" logger.info( - f"Rewrote authorization endpoint for browser access: {authorization_endpoint}" + "Rewrote authorization endpoint for browser access: %s", + authorization_endpoint, ) # Prefix resource scopes with the resource server identifier if configured. @@ -952,7 +953,7 @@ async def _oauth_callback_as_proxy( if response.status_code != 200: logger.error( - f"AS proxy token exchange failed: {response.status_code} {response.text}" + "AS proxy token exchange failed: %s %s", response.status_code, response.text ) params = urlencode( { @@ -968,8 +969,8 @@ async def _oauth_callback_as_proxy( nc_token_response = response.json() logger.info( - "AS proxy: Successfully exchanged code for Nextcloud token " - f"(token_type={nc_token_response.get('token_type')})" + "AS proxy: Successfully exchanged code for Nextcloud token (token_type=%s)", + nc_token_response.get("token_type"), ) # Verify the ID token signature + claims before caching the response @@ -1017,7 +1018,8 @@ async def _oauth_callback_as_proxy( redirect_url = f"{session.client_redirect_uri}?{redirect_params}" logger.info( - f"AS proxy: Redirecting to client with proxy_code (client_id={session.client_id})" + "AS proxy: Redirecting to client with proxy_code (client_id=%s)", + session.client_id, ) return RedirectResponse(redirect_url, status_code=302) @@ -1294,7 +1296,7 @@ async def _token_refresh(request: Request, form) -> JSONResponse: if response.status_code != 200: logger.error( - f"AS proxy token refresh failed: {response.status_code} {response.text}" + "AS proxy token refresh failed: %s %s", response.status_code, response.text ) return JSONResponse( { @@ -1396,7 +1398,9 @@ async def oauth_register_proxy(request: Request) -> JSONResponse: if response.status_code not in (200, 201): logger.error( - f"DCR proxy: Upstream registration failed: {response.status_code} {response.text}" + "DCR proxy: Upstream registration failed: %s %s", + response.status_code, + response.text, ) return JSONResponse( response.json() diff --git a/nextcloud_mcp_server/auth/permissions.py b/nextcloud_mcp_server/auth/permissions.py index 551d201c..2c8964b0 100644 --- a/nextcloud_mcp_server/auth/permissions.py +++ b/nextcloud_mcp_server/auth/permissions.py @@ -44,11 +44,14 @@ async def is_nextcloud_admin(request: Request, http_client: AsyncClient) -> bool # Check if user is in the admin group is_admin = "admin" in user_groups logger.debug( - f"Admin check for user '{username}': {is_admin} (groups: {user_groups})" + "Admin check for user '%s': %s (groups: %s)", + username, + is_admin, + user_groups, ) return is_admin except Exception as e: - logger.error(f"Error checking admin permissions: {e}", exc_info=True) + logger.error("Error checking admin permissions: %s", e, exc_info=True) return False diff --git a/nextcloud_mcp_server/auth/provision_routes.py b/nextcloud_mcp_server/auth/provision_routes.py index 07962ba2..651c196a 100644 --- a/nextcloud_mcp_server/auth/provision_routes.py +++ b/nextcloud_mcp_server/auth/provision_routes.py @@ -90,7 +90,7 @@ async def _poll_and_store(provision_id: str) -> None: result = await flow_client.poll(poll_endpoint, poll_token) except Exception as e: logger.warning( - f"Login Flow v2 poll error for provision {provision_id}: {e}" + "Login Flow v2 poll error for provision %s: %s", provision_id, e ) await anyio.sleep(2) continue @@ -106,7 +106,8 @@ async def _poll_and_store(provision_id: str) -> None: if session: session["status"] = "error" logger.error( - f"Login Flow v2 completed but no app_password (provision_id={provision_id})" + "Login Flow v2 completed but no app_password (provision_id=%s)", + provision_id, ) return await storage.store_app_password_with_scopes( @@ -121,8 +122,9 @@ async def _poll_and_store(provision_id: str) -> None: session["status"] = "completed" session["username"] = result.login_name logger.info( - f"Login Flow v2 web provision completed for user {effective_user_id} " - f"(provision_id={provision_id})" + "Login Flow v2 web provision completed for user %s (provision_id=%s)", + effective_user_id, + provision_id, ) return @@ -131,7 +133,7 @@ async def _poll_and_store(provision_id: str) -> None: if session: session["status"] = "expired" logger.warning( - f"Login Flow v2 web provision expired (provision_id={provision_id})" + "Login Flow v2 web provision expired (provision_id=%s)", provision_id ) return @@ -142,7 +144,7 @@ async def _poll_and_store(provision_id: str) -> None: if session: session["status"] = "expired" logger.warning( - f"Login Flow v2 web provision timed out (provision_id={provision_id})" + "Login Flow v2 web provision timed out (provision_id=%s)", provision_id ) @@ -166,7 +168,7 @@ async def provision_page( try: user_id, _token_data = await validate_token_and_get_user(request) except (ValueError, KeyError, AttributeError) as e: - logger.warning(f"Provision request rejected: {e}") + logger.warning("Provision request rejected: %s", e) return JSONResponse({"error": "Authentication required"}, status_code=401) _cleanup_expired_sessions() @@ -180,14 +182,14 @@ async def provision_page( ) if urlparse(redirect_uri).scheme == "http": - logger.warning(f"Provision redirect_uri uses insecure HTTP: {redirect_uri}") + logger.warning("Provision redirect_uri uses insecure HTTP: %s", redirect_uri) # Check if user already has an app password — skip straight to redirect if user_id: storage = await get_shared_storage() existing = await storage.get_app_password_with_scopes(user_id) if existing: - logger.info(f"User {user_id} already has app password, skipping provision") + logger.info("User %s already has app password, skipping provision", user_id) return RedirectResponse(redirect_uri) # Initiate Login Flow v2 @@ -206,7 +208,7 @@ async def provision_page( ) init_response = await flow_client.initiate() except Exception as e: - logger.error(f"Failed to initiate Login Flow v2 for web provision: {e}") + logger.error("Failed to initiate Login Flow v2 for web provision: %s", e) return HTMLResponse( content=_render_error( "Failed to start login flow. Please try again later." @@ -241,8 +243,9 @@ async def provision_page( poll_tg.start_soon(_poll_and_store, provision_id) logger.info( - f"Login Flow v2 web provision initiated (provision_id={provision_id}, " - f"user_id={user_id or 'unknown'}), redirecting to NC login" + "Login Flow v2 web provision initiated (provision_id=%s, user_id=%s), redirecting to NC login", + provision_id, + user_id or "unknown", ) # Redirect to Nextcloud's Login Flow v2 login page. @@ -277,7 +280,7 @@ async def provision_status(request: Request) -> JSONResponse: try: _user_id, _token_data = await validate_token_and_get_user(request) except (ValueError, KeyError, AttributeError) as e: - logger.warning(f"Provision status request rejected: {e}") + logger.warning("Provision status request rejected: %s", e) return JSONResponse({"error": "Authentication required"}, status_code=401) provision_id = request.query_params.get("id", "") diff --git a/nextcloud_mcp_server/auth/provisioning_decorator.py b/nextcloud_mcp_server/auth/provisioning_decorator.py index b28eecc3..19351cb1 100644 --- a/nextcloud_mcp_server/auth/provisioning_decorator.py +++ b/nextcloud_mcp_server/auth/provisioning_decorator.py @@ -89,7 +89,7 @@ def require_provisioning(func: Callable) -> Callable: if not refresh_data: # User has not completed Flow 2 - provide helpful error logger.info( - f"User {user_id} attempted to use Nextcloud tool without provisioning" + "User %s attempted to use Nextcloud tool without provisioning", user_id ) raise McpError( ErrorData( @@ -104,7 +104,7 @@ def require_provisioning(func: Callable) -> Callable: ) logger.debug( - f"User {user_id} has provisioned access - proceeding with tool execution" + "User %s has provisioned access - proceeding with tool execution", user_id ) # User has provisioned - allow access @@ -156,15 +156,14 @@ def require_provisioning_or_suggest(func: Callable) -> Callable: if not refresh_data: logger.info( - f"User {user_id} has not provisioned Nextcloud access. " - "Some features may not work. Consider running " - "'provision_nextcloud_access' tool." + "User %s has not provisioned Nextcloud access. Some features may not work. Consider running 'provision_nextcloud_access' tool.", + user_id, ) else: - logger.debug(f"User {user_id} has provisioned access") + logger.debug("User %s has provisioned access", user_id) except Exception as e: - logger.debug(f"Could not check provisioning status: {e}") + logger.debug("Could not check provisioning status: %s", e) # Always proceed with the function return await func(*args, **kwargs) diff --git a/nextcloud_mcp_server/auth/scope_authorization.py b/nextcloud_mcp_server/auth/scope_authorization.py index 6e6efd66..315c0f18 100644 --- a/nextcloud_mcp_server/auth/scope_authorization.py +++ b/nextcloud_mcp_server/auth/scope_authorization.py @@ -119,7 +119,7 @@ def require_scopes(*required_scopes: str): # No context parameter found - likely BasicAuth mode # In BasicAuth mode, all operations are allowed logger.debug( - f"No context parameter for {func_name} - allowing (BasicAuth mode)" + "No context parameter for %s - allowing (BasicAuth mode)", func_name ) return await func(*args, **kwargs) @@ -131,7 +131,7 @@ def require_scopes(*required_scopes: str): if access_token is None: # No OAuth token — BasicAuth mode bypasses scope checks logger.debug( - f"No access token for {func_name} - allowing (BasicAuth mode)" + "No access token for %s - allowing (BasicAuth mode)", func_name ) return await func(*args, **kwargs) @@ -210,7 +210,8 @@ def require_scopes(*required_scopes: str): if stored_scopes == "all": # NULL scopes in DB = legacy app password = all allowed logger.debug( - f"Stored app password scope check passed for {func_name}: all scopes" + "Stored app password scope check passed for %s: all scopes", + func_name, ) return await func(*args, **kwargs) @@ -227,7 +228,7 @@ def require_scopes(*required_scopes: str): raise InsufficientScopeError(list(missing), error_msg) logger.debug( - f"Stored app password scope check passed for {func_name}" + "Stored app password scope check passed for %s", func_name ) return await func(*args, **kwargs) @@ -303,7 +304,7 @@ def require_scopes(*required_scopes: str): # All required scopes present - allow execution logger.debug( - f"Scope authorization passed for {func_name}: {required_scopes}" + "Scope authorization passed for %s: %s", func_name, required_scopes ) return await func(*args, **kwargs) @@ -366,7 +367,7 @@ def get_access_token_scopes(ctx: Context | None = None) -> set[str]: scopes = set(access_token.scopes or []) scopes = _strip_resource_prefix(scopes) - logger.info(f"✅ Extracted scopes from access token: {scopes}") + logger.info("✅ Extracted scopes from access token: %s", scopes) return scopes @@ -446,7 +447,7 @@ def is_jwt_token() -> bool: token_string = access_token.token is_jwt = "." in token_string and token_string.count(".") == 2 - logger.debug(f"Token format check: is_jwt={is_jwt}") + logger.debug("Token format check: is_jwt=%s", is_jwt) return is_jwt diff --git a/nextcloud_mcp_server/auth/storage.py b/nextcloud_mcp_server/auth/storage.py index e7e8d0b3..4090aa25 100644 --- a/nextcloud_mcp_server/auth/storage.py +++ b/nextcloud_mcp_server/auth/storage.py @@ -184,12 +184,12 @@ class RefreshTokenStorage: if has_schema: logger.info( - f"Detected pre-Alembic database at {self.db_path}, " - "stamping with initial revision" + "Detected pre-Alembic database at %s, stamping with initial revision", + self.db_path, ) else: logger.info( - f"Initializing new database at {self.db_path} with migrations" + "Initializing new database at %s with migrations", self.db_path ) # Run migrations in a worker thread using anyio.to_thread @@ -215,7 +215,7 @@ class RefreshTokenStorage: os.chmod(self.db_path, 0o600) self._initialized = True - logger.info(f"Initialized refresh token storage at {self.db_path}") + logger.info("Initialized refresh token storage at %s", self.db_path) async def store_refresh_token( self, @@ -333,7 +333,7 @@ class RefreshTokenStorage: ) await db.commit() - logger.debug(f"Cached user profile for {user_id}") + logger.debug("Cached user profile for %s", user_id) async def get_user_profile(self, user_id: str) -> dict[str, Any] | None: """ @@ -419,7 +419,7 @@ class RefreshTokenStorage: row = await cursor.fetchone() if not row: - logger.debug(f"No refresh token found for user {user_id}") + logger.debug("No refresh token found for user %s", user_id) duration = time.time() - start_time record_db_operation("sqlite", "select", duration, "success") return None @@ -437,7 +437,9 @@ class RefreshTokenStorage: # Check expiration if expires_at is not None and expires_at < time.time(): logger.warning( - f"Refresh token for user {user_id} has expired (expired at {expires_at})" + "Refresh token for user %s has expired (expired at %s)", + user_id, + expires_at, ) await self.delete_refresh_token(user_id) duration = time.time() - start_time @@ -448,7 +450,9 @@ class RefreshTokenStorage: scopes = json.loads(scopes_json) if scopes_json else None logger.debug( - f"Retrieved refresh token for user {user_id} (flow_type: {flow_type})" + "Retrieved refresh token for user %s (flow_type: %s)", + user_id, + flow_type, ) duration = time.time() - start_time @@ -467,7 +471,7 @@ class RefreshTokenStorage: except Exception as e: duration = time.time() - start_time record_db_operation("sqlite", "select", duration, "error") - logger.error(f"Failed to decrypt refresh token for user {user_id}: {e}") + logger.error("Failed to decrypt refresh token for user %s: %s", user_id, e) return None async def get_refresh_token_by_provisioning_client_id( @@ -511,7 +515,8 @@ class RefreshTokenStorage: if not row: logger.debug( - f"No refresh token found for provisioning_client_id {provisioning_client_id[:16]}..." + "No refresh token found for provisioning_client_id %s...", + provisioning_client_id[:16], ) return None @@ -529,7 +534,8 @@ class RefreshTokenStorage: # Check expiration if expires_at is not None and expires_at < time.time(): logger.warning( - f"Refresh token for provisioning_client_id {provisioning_client_id[:16]}... has expired" + "Refresh token for provisioning_client_id %s... has expired", + provisioning_client_id[:16], ) return None @@ -538,7 +544,9 @@ class RefreshTokenStorage: scopes = json.loads(scopes_json) if scopes_json else None logger.debug( - f"Retrieved refresh token for provisioning_client_id {provisioning_client_id[:16]}... (user_id: {user_id})" + "Retrieved refresh token for provisioning_client_id %s... (user_id: %s)", + provisioning_client_id[:16], + user_id, ) return { @@ -553,7 +561,9 @@ class RefreshTokenStorage: } except Exception as e: logger.error( - f"Failed to decrypt refresh token for provisioning_client_id {provisioning_client_id[:16]}...: {e}" + "Failed to decrypt refresh token for provisioning_client_id %s...: %s", + provisioning_client_id[:16], + e, ) return None @@ -584,14 +594,14 @@ class RefreshTokenStorage: record_db_operation("sqlite", "delete", duration, "success") if deleted: - logger.info(f"Deleted refresh token for user {user_id}") + logger.info("Deleted refresh token for user %s", user_id) await self._audit_log( event="delete_refresh_token", user_id=user_id, auth_method="offline_access", ) else: - logger.debug(f"No refresh token to delete for user {user_id}") + logger.debug("No refresh token to delete for user %s", user_id) return deleted except Exception: @@ -616,7 +626,7 @@ class RefreshTokenStorage: rows = await cursor.fetchall() user_ids = [row[0] for row in rows] - logger.debug(f"Found {len(user_ids)} users with refresh tokens") + logger.debug("Found %s users with refresh tokens", len(user_ids)) return user_ids async def cleanup_expired_tokens(self) -> int: @@ -640,7 +650,7 @@ class RefreshTokenStorage: deleted = cursor.rowcount if deleted > 0: - logger.info(f"Cleaned up {deleted} expired refresh token(s)") + logger.info("Cleaned up %s expired refresh token(s)", deleted) return deleted @@ -718,8 +728,9 @@ class RefreshTokenStorage: await db.commit() logger.info( - f"Stored OAuth client credentials (client_id: {client_id[:16]}..., " - f"expires at {client_secret_expires_at})" + "Stored OAuth client credentials (client_id: %s..., expires at %s)", + client_id[:16], + client_secret_expires_at, ) # Audit log @@ -785,7 +796,7 @@ class RefreshTokenStorage: # Check expiration if expires_at < time.time(): logger.warning( - f"OAuth client has expired (expired at {expires_at}), deleting" + "OAuth client has expired (expired at %s), deleting", expires_at ) await self.delete_oauth_client() return None @@ -803,7 +814,7 @@ class RefreshTokenStorage: redirect_uris = json.loads(redirect_uris_json) logger.debug( - f"Retrieved OAuth client credentials (client_id: {client_id[:16]}...)" + "Retrieved OAuth client credentials (client_id: %s...)", client_id[:16] ) return { @@ -817,7 +828,7 @@ class RefreshTokenStorage: } except Exception as e: - logger.error(f"Failed to decrypt OAuth client credentials: {e}") + logger.error("Failed to decrypt OAuth client credentials: %s", e) return None async def delete_oauth_client(self) -> bool: @@ -1017,7 +1028,9 @@ class RefreshTokenStorage: ) await db.commit() - logger.debug(f"Stored OAuth session {session_id} (expires in {ttl_seconds}s)") + logger.debug( + "Stored OAuth session %s (expires in %ss)", session_id, ttl_seconds + ) async def get_oauth_session(self, session_id: str) -> dict | None: """ @@ -1043,7 +1056,7 @@ class RefreshTokenStorage: # Check expiration if session["expires_at"] < time.time(): - logger.debug(f"OAuth session {session_id} has expired") + logger.debug("OAuth session %s has expired", session_id) await self.delete_oauth_session(session_id) return None @@ -1077,7 +1090,8 @@ class RefreshTokenStorage: # Check expiration if session["expires_at"] < time.time(): logger.debug( - f"OAuth session with MCP code {mcp_authorization_code[:16]}... has expired" + "OAuth session with MCP code %s... has expired", + mcp_authorization_code[:16], ) await self.delete_oauth_session(session["session_id"]) return None @@ -1133,7 +1147,7 @@ class RefreshTokenStorage: updated = cursor.rowcount > 0 if updated: - logger.debug(f"Updated OAuth session {session_id}") + logger.debug("Updated OAuth session %s", session_id) return updated @@ -1155,7 +1169,7 @@ class RefreshTokenStorage: deleted = cursor.rowcount > 0 if deleted: - logger.debug(f"Deleted OAuth session {session_id}") + logger.debug("Deleted OAuth session %s", session_id) return deleted @@ -1179,7 +1193,7 @@ class RefreshTokenStorage: deleted = cursor.rowcount if deleted > 0: - logger.info(f"Cleaned up {deleted} expired OAuth session(s)") + logger.info("Cleaned up %s expired OAuth session(s)", deleted) return deleted @@ -1339,7 +1353,7 @@ class RefreshTokenStorage: ) await db.commit() - logger.debug(f"Stored webhook {webhook_id} for preset '{preset_id}'") + logger.debug("Stored webhook %s for preset '%s'", webhook_id, preset_id) async def get_webhooks_by_preset(self, preset_id: str) -> list[int]: """ @@ -1384,7 +1398,7 @@ class RefreshTokenStorage: deleted = cursor.rowcount > 0 if deleted: - logger.debug(f"Deleted webhook {webhook_id} from tracking") + logger.debug("Deleted webhook %s from tracking", webhook_id) return deleted @@ -1430,7 +1444,7 @@ class RefreshTokenStorage: deleted = cursor.rowcount if deleted > 0: - logger.debug(f"Cleared {deleted} webhook(s) for preset '{preset_id}'") + logger.debug("Cleared %s webhook(s) for preset '%s'", deleted, preset_id) return deleted @@ -1482,7 +1496,7 @@ class RefreshTokenStorage: duration = time.time() - start_time record_db_operation("sqlite", "insert", duration, "success") - logger.info(f"Stored app password for user {user_id}") + logger.info("Stored app password for user %s", user_id) except Exception: duration = time.time() - start_time @@ -1525,7 +1539,7 @@ class RefreshTokenStorage: row = await cursor.fetchone() if not row: - logger.debug(f"No app password found for user {user_id}") + logger.debug("No app password found for user %s", user_id) duration = time.time() - start_time record_db_operation("sqlite", "select", duration, "success") return None @@ -1535,14 +1549,14 @@ class RefreshTokenStorage: duration = time.time() - start_time record_db_operation("sqlite", "select", duration, "success") - logger.debug(f"Retrieved app password for user {user_id}") + logger.debug("Retrieved app password for user %s", user_id) return decrypted_password except Exception as e: duration = time.time() - start_time record_db_operation("sqlite", "select", duration, "error") - logger.error(f"Failed to decrypt app password for user {user_id}: {e}") + logger.error("Failed to decrypt app password for user %s: %s", user_id, e) return None async def delete_app_password(self, user_id: str) -> bool: @@ -1572,14 +1586,14 @@ class RefreshTokenStorage: record_db_operation("sqlite", "delete", duration, "success") if deleted: - logger.info(f"Deleted app password for user {user_id}") + logger.info("Deleted app password for user %s", user_id) await self._audit_log( event="delete_app_password", user_id=user_id, auth_method="app_password", ) else: - logger.debug(f"No app password to delete for user {user_id}") + logger.debug("No app password to delete for user %s", user_id) return deleted @@ -1605,7 +1619,7 @@ class RefreshTokenStorage: rows = await cursor.fetchall() user_ids = [row[0] for row in rows] - logger.debug(f"Found {len(user_ids)} users with app passwords") + logger.debug("Found %s users with app passwords", len(user_ids)) return user_ids async def cleanup_invalid_app_passwords(self, nextcloud_host: str) -> list[str]: @@ -1651,19 +1665,21 @@ class RefreshTokenStorage: if response.status_code in (401, 403): logger.info( - f"App password for {user_id} is invalid " - f"(HTTP {response.status_code}), removing" + "App password for %s is invalid (HTTP %s), removing", + user_id, + response.status_code, ) await self.delete_app_password(user_id) removed.append(user_id) else: logger.debug( - f"App password for {user_id} validated " - f"(HTTP {response.status_code})" + "App password for %s validated (HTTP %s)", + user_id, + response.status_code, ) except Exception as e: - logger.warning(f"Could not validate app password for {user_id}: {e}") + logger.warning("Could not validate app password for %s: %s", user_id, e) async with anyio.create_task_group() as tg: for user_id in user_ids: @@ -1745,9 +1761,10 @@ class RefreshTokenStorage: duration = time.time() - start_time record_db_operation("sqlite", "insert", duration, "success") logger.info( - f"Stored scoped app password for user {user_id} " - f"(scopes={'all' if scopes is None else len(scopes)}, " - f"username={username or 'N/A'})" + "Stored scoped app password for user %s (scopes=%s, username=%s)", + user_id, + "all" if scopes is None else len(scopes), + username or "N/A", ) except Exception: @@ -1793,7 +1810,7 @@ class RefreshTokenStorage: row = await cursor.fetchone() if not row: - logger.debug(f"No app password found for user {user_id}") + logger.debug("No app password found for user %s", user_id) duration = time.time() - start_time record_db_operation("sqlite", "select", duration, "success") return None @@ -1917,7 +1934,7 @@ class RefreshTokenStorage: duration = time.time() - start_time record_db_operation("sqlite", "insert", duration, "success") - logger.info(f"Stored login flow session for user {user_id}") + logger.info("Stored login flow session for user %s", user_id) except Exception: duration = time.time() - start_time @@ -1984,7 +2001,7 @@ class RefreshTokenStorage: duration = time.time() - start_time record_db_operation("sqlite", "select", duration, "error") logger.error( - f"Failed to retrieve login flow session for user {user_id}: {e}" + "Failed to retrieve login flow session for user %s: %s", user_id, e ) raise @@ -2014,7 +2031,7 @@ class RefreshTokenStorage: record_db_operation("sqlite", "delete", duration, "success") if deleted: - logger.info(f"Deleted login flow session for user {user_id}") + logger.info("Deleted login flow session for user %s", user_id) await self._audit_log( event="delete_login_flow_session", user_id=user_id, @@ -2052,7 +2069,7 @@ class RefreshTokenStorage: record_db_operation("sqlite", "delete", duration, "success") if count > 0: - logger.info(f"Cleaned up {count} expired login flow sessions") + logger.info("Cleaned up %s expired login flow sessions", count) await self._audit_log( event="delete_expired_login_flow_sessions", user_id="system", diff --git a/nextcloud_mcp_server/auth/token_broker.py b/nextcloud_mcp_server/auth/token_broker.py index 9885246f..f8ba7d6e 100644 --- a/nextcloud_mcp_server/auth/token_broker.py +++ b/nextcloud_mcp_server/auth/token_broker.py @@ -56,15 +56,15 @@ class TokenCache: # Check if token has expired if now >= expiry: del self._cache[user_id] - logger.debug(f"Cached token expired for user {user_id}") + logger.debug("Cached token expired for user %s", user_id) return None # Check if token will expire soon (refresh early) if now >= expiry - self._early_refresh: - logger.debug(f"Cached token expiring soon for user {user_id}") + logger.debug("Cached token expiring soon for user %s", user_id) return None - logger.debug(f"Using cached token for user {user_id}") + logger.debug("Using cached token for user %s", user_id) return token async def set(self, user_id: str, token: str, expires_in: int | None = None): @@ -77,14 +77,14 @@ class TokenCache: expiry = datetime.now(timezone.utc) + self._ttl self._cache[user_id] = (token, expiry) - logger.debug(f"Cached token for user {user_id} until {expiry}") + logger.debug("Cached token for user %s until %s", user_id, expiry) async def invalidate(self, user_id: str): """Remove token from cache.""" async with self._lock: if user_id in self._cache: del self._cache[user_id] - logger.debug(f"Invalidated cached token for user {user_id}") + logger.debug("Invalidated cached token for user %s", user_id) class TokenBrokerService: @@ -214,7 +214,7 @@ class TokenBrokerService: # Get stored refresh token refresh_data = await self.storage.get_refresh_token(user_id) if not refresh_data: - logger.info(f"No refresh token found for user {user_id}") + logger.info("No refresh token found for user %s", user_id) return None try: @@ -230,7 +230,7 @@ class TokenBrokerService: return access_token except Exception as e: - logger.error(f"Failed to get Nextcloud token for user {user_id}: {e}") + logger.error("Failed to get Nextcloud token for user %s: %s", user_id, e) # Invalidate cache on error await self.cache.invalidate(user_id) return None @@ -273,19 +273,21 @@ class TokenBrokerService: cached_token = await self.cache.get(cache_key) if cached_token: logger.debug( - f"Token found in cache after lock acquisition for user {user_id}" + "Token found in cache after lock acquisition for user %s", user_id ) return cached_token # Check if another thread is currently refreshing if await self.cache.get(refresh_in_progress_key): - logger.debug(f"Refresh in progress for user {user_id}, waiting briefly") + logger.debug( + "Refresh in progress for user %s, waiting briefly", user_id + ) await anyio.sleep(0.1) # Brief wait for in-progress refresh # Check cache one more time after wait cached_token = await self.cache.get(cache_key) if cached_token: logger.debug( - f"Token refreshed by another thread for user {user_id}" + "Token refreshed by another thread for user %s", user_id ) return cached_token @@ -296,7 +298,7 @@ class TokenBrokerService: # Get stored refresh token refresh_data = await self.storage.get_refresh_token(user_id) if not refresh_data: - logger.info(f"No refresh token found for user {user_id}") + logger.info("No refresh token found for user %s", user_id) return None # storage.get_refresh_token() returns already-decrypted token @@ -312,14 +314,18 @@ class TokenBrokerService: await self.cache.set(cache_key, access_token, expires_in) logger.info( - f"Generated background token for user {user_id} with scopes: {required_scopes}" + "Generated background token for user %s with scopes: %s", + user_id, + required_scopes, ) return access_token except Exception as e: logger.error( - f"Failed to get background token for user {user_id}: {e}", + "Failed to get background token for user %s: %s", + user_id, + e, exc_info=True, ) await self.cache.invalidate(cache_key) @@ -375,7 +381,7 @@ class TokenBrokerService: if response.status_code != 200: logger.error( - f"Token refresh failed: {response.status_code} - {response.text}" + "Token refresh failed: %s - %s", response.status_code, response.text ) raise Exception(f"Token refresh failed: {response.status_code}") @@ -395,11 +401,11 @@ class TokenBrokerService: refresh_token=new_refresh_token, expires_at=expires_at, ) - logger.info(f"Stored rotated refresh token for user {user_id}") + logger.info("Stored rotated refresh token for user %s", user_id) # Note: Nextcloud validates token audience on API calls - no need to pre-validate here - logger.info(f"Refreshed access token (expires in {expires_in}s)") + logger.info("Refreshed access token (expires in %ss)", expires_in) return access_token, expires_in async def _refresh_access_token_with_scopes( @@ -446,7 +452,9 @@ class TokenBrokerService: } logger.info( - f"Token refresh request to {token_endpoint} with client_id={self.client_id[:16]}..." + "Token refresh request to %s with client_id=%s...", + token_endpoint, + self.client_id[:16], ) response = await client.post( @@ -457,9 +465,11 @@ class TokenBrokerService: if response.status_code != 200: logger.error( - f"Token refresh with scopes failed: {response.status_code} - {response.text}" + "Token refresh with scopes failed: %s - %s", + response.status_code, + response.text, ) - logger.error(f" client_id used: {self.client_id[:16]}...") + logger.error(" client_id used: %s...", self.client_id[:16]) raise Exception(f"Token refresh failed: {response.status_code}") token_data = response.json() @@ -479,12 +489,12 @@ class TokenBrokerService: refresh_token=new_refresh_token, expires_at=expires_at, ) - logger.info(f"Stored rotated refresh token for user {user_id}") + logger.info("Stored rotated refresh token for user %s", user_id) # Note: Nextcloud validates token audience on API calls - no need to pre-validate here logger.info( - f"Refreshed access token with scopes {scopes} (expires in {expires_in}s)" + "Refreshed access token with scopes %s (expires in %ss)", scopes, expires_in ) return access_token, expires_in @@ -503,7 +513,7 @@ class TokenBrokerService: """ refresh_data = await self.storage.get_refresh_token(user_id) if not refresh_data: - logger.warning(f"No refresh token to rotate for user {user_id}") + logger.warning("No refresh token to rotate for user %s", user_id) return False try: @@ -538,7 +548,7 @@ class TokenBrokerService: ) if response.status_code != 200: - logger.error(f"Master token refresh failed: {response.status_code}") + logger.error("Master token refresh failed: %s", response.status_code) return False token_data = response.json() @@ -555,7 +565,7 @@ class TokenBrokerService: refresh_token=new_refresh_token, expires_at=expires_at, ) - logger.info(f"Rotated master refresh token for user {user_id}") + logger.info("Rotated master refresh token for user %s", user_id) # Invalidate cached access token await self.cache.invalidate(user_id) @@ -564,7 +574,7 @@ class TokenBrokerService: return True except Exception as e: - logger.error(f"Failed to refresh master token for user {user_id}: {e}") + logger.error("Failed to refresh master token for user %s: %s", user_id, e) return False async def has_nextcloud_provisioning(self, user_id: str) -> bool: @@ -601,7 +611,7 @@ class TokenBrokerService: refresh_token = refresh_data["refresh_token"] await self._revoke_token_at_idp(refresh_token) except Exception as e: - logger.warning(f"Failed to revoke at IdP: {e}") + logger.warning("Failed to revoke at IdP: %s", e) # Remove from storage await self.storage.delete_refresh_token(user_id) @@ -609,11 +619,11 @@ class TokenBrokerService: # Clear cache await self.cache.invalidate(user_id) - logger.info(f"Revoked Nextcloud access for user {user_id}") + logger.info("Revoked Nextcloud access for user %s", user_id) return True except Exception as e: - logger.error(f"Failed to revoke access for user {user_id}: {e}") + logger.error("Failed to revoke access for user %s: %s", user_id, e) return False async def _revoke_token_at_idp(self, token: str): @@ -638,7 +648,7 @@ class TokenBrokerService: if response.status_code == 200: logger.info("Token revoked at IdP") else: - logger.warning(f"Token revocation returned {response.status_code}") + logger.warning("Token revocation returned %s", response.status_code) async def close(self): """Clean up resources.""" diff --git a/nextcloud_mcp_server/auth/unified_verifier.py b/nextcloud_mcp_server/auth/unified_verifier.py index 5f64f689..bbdade87 100644 --- a/nextcloud_mcp_server/auth/unified_verifier.py +++ b/nextcloud_mcp_server/auth/unified_verifier.py @@ -69,7 +69,7 @@ class UnifiedTokenVerifier(TokenVerifier): # JWT verification support self.jwks_client: PyJWKClient | None = None if hasattr(settings, "jwks_uri") and settings.jwks_uri: - logger.info(f"JWT verification enabled with JWKS URI: {settings.jwks_uri}") + logger.info("JWT verification enabled with JWKS URI: %s", settings.jwks_uri) self.jwks_client = PyJWKClient(settings.jwks_uri, cache_keys=True) # Introspection support (for opaque tokens) @@ -81,7 +81,7 @@ class UnifiedTokenVerifier(TokenVerifier): and settings.oidc_client_secret ): self.introspection_uri = settings.introspection_uri - logger.info(f"Token introspection enabled: {self.introspection_uri}") + logger.info("Token introspection enabled: %s", self.introspection_uri) # Build list of valid issuers (internal + public may differ in Docker) # AS proxy obtains tokens via internal URL (e.g. http://app:80), while @@ -114,14 +114,16 @@ class UnifiedTokenVerifier(TokenVerifier): ) else: logger.info( - f"Management API allowlist: {sorted(self._allowed_mgmt_clients)}" + "Management API allowlist: %s", sorted(self._allowed_mgmt_clients) ) logger.info( - f"UnifiedTokenVerifier initialized in {self.mode} mode. " - f"MCP audience: {settings.oidc_client_id} or {settings.nextcloud_mcp_server_url}, " - f"Nextcloud resource URI: {settings.nextcloud_resource_uri}, " - f"Valid issuers: {self.valid_issuers}" + "UnifiedTokenVerifier initialized in %s mode. MCP audience: %s or %s, Nextcloud resource URI: %s, Valid issuers: %s", + self.mode, + settings.oidc_client_id, + settings.nextcloud_mcp_server_url, + settings.nextcloud_resource_uri, + self.valid_issuers, ) async def verify_token(self, token: str) -> AccessToken | None: @@ -281,9 +283,10 @@ class UnifiedTokenVerifier(TokenVerifier): if not self._has_mcp_audience(payload): audiences = payload.get("aud", []) logger.error( - f"Token rejected: Missing MCP audience. " - f"Got {audiences}, need MCP ({self.settings.oidc_client_id} or " - f"{self.settings.nextcloud_mcp_server_url})" + "Token rejected: Missing MCP audience. Got %s, need MCP (%s or %s)", + audiences, + self.settings.oidc_client_id, + self.settings.nextcloud_mcp_server_url, ) # Record as invalid due to audience mismatch record_oauth_token_validation(validation_method, "invalid") @@ -303,7 +306,7 @@ class UnifiedTokenVerifier(TokenVerifier): return self._create_access_token(token, payload) except Exception as e: - logger.error(f"Token verification failed: {e}") + logger.error("Token verification failed: %s", e) record_oauth_token_validation(validation_method, "error") return None @@ -368,14 +371,15 @@ class UnifiedTokenVerifier(TokenVerifier): # Skip audience validation - any valid Nextcloud token is accepted logger.debug( - f"Management API token validated (no audience check) for user: {payload.get('sub')}" + "Management API token validated (no audience check) for user: %s", + payload.get("sub"), ) # Cache and return the token return self._create_access_token_with_cache_key(token, payload, cache_key) except Exception as e: - logger.error(f"Management API token verification failed: {e}") + logger.error("Management API token verification failed: %s", e) record_oauth_token_validation(validation_method, "error") return None @@ -478,20 +482,20 @@ class UnifiedTokenVerifier(TokenVerifier): f"expected one of: {self.valid_issuers}" ) - logger.debug(f"JWT signature verified for user: {payload.get('sub')}") + logger.debug("JWT signature verified for user: %s", payload.get("sub")) return payload except jwt.ExpiredSignatureError: logger.info("JWT token has expired") return None except jwt.InvalidIssuerError as e: - logger.warning(f"JWT issuer validation failed: {e}") + logger.warning("JWT issuer validation failed: %s", e) return None except jwt.InvalidTokenError as e: - logger.warning(f"JWT validation failed: {e}") + logger.warning("JWT validation failed: %s", e) return None except Exception as e: - logger.error(f"Unexpected error during JWT verification: {e}") + logger.error("Unexpected error during JWT verification: %s", e) return None async def _introspect_token(self, token: str) -> dict[str, Any] | None: @@ -528,20 +532,23 @@ class UnifiedTokenVerifier(TokenVerifier): return None logger.debug( - f"Token introspected successfully for user: {introspection_data.get('sub')}" + "Token introspected successfully for user: %s", + introspection_data.get("sub"), ) return introspection_data elif response.status_code in (400, 401, 403): logger.warning( - f"Token introspection failed: HTTP {response.status_code}. " - f"Response: {response.text[:200] if response.text else 'empty'}" + "Token introspection failed: HTTP %s. Response: %s", + response.status_code, + response.text[:200] if response.text else "empty", ) return None else: logger.warning( - f"Unexpected response from introspection: {response.status_code}. " - f"Response: {response.text[:200] if response.text else 'empty'}" + "Unexpected response from introspection: %s. Response: %s", + response.status_code, + response.text[:200] if response.text else "empty", ) return None @@ -549,10 +556,10 @@ class UnifiedTokenVerifier(TokenVerifier): logger.error("Timeout while introspecting token") return None except httpx.RequestError as e: - logger.error(f"Network error while introspecting token: {e}") + logger.error("Network error while introspecting token: %s", e) return None except Exception as e: - logger.error(f"Unexpected error during token introspection: {e}") + logger.error("Unexpected error during token introspection: %s", e) return None def _create_access_token( @@ -598,7 +605,9 @@ class UnifiedTokenVerifier(TokenVerifier): scope_string = payload.get("scope", "") scopes = scope_string.split() if scope_string else [] logger.debug( - f"Extracted scopes from token - scope claim: '{scope_string}' -> scopes list: {scopes}" + "Extracted scopes from token - scope claim: '%s' -> scopes list: %s", + scope_string, + scopes, ) # Extract expiration diff --git a/nextcloud_mcp_server/auth/userinfo_routes.py b/nextcloud_mcp_server/auth/userinfo_routes.py index a9277134..a2e56d51 100644 --- a/nextcloud_mcp_server/auth/userinfo_routes.py +++ b/nextcloud_mcp_server/auth/userinfo_routes.py @@ -143,7 +143,7 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None: indexed_count = count_result.count except Exception as e: - logger.warning(f"Failed to query Qdrant for indexed count: {e}") + logger.warning("Failed to query Qdrant for indexed count: %s", e) # Continue with indexed_count = 0 # Determine status @@ -156,7 +156,7 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None: } except Exception as e: - logger.error(f"Error getting processing status: {e}") + logger.error("Error getting processing status: %s", e) return None @@ -243,11 +243,11 @@ async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None: try: await oauth_client.discover() except Exception as e: - logger.error(f"Failed to discover IdP endpoints: {e}") + logger.error("Failed to discover IdP endpoints: %s", e) return None logger.debug( - f"Using external IdP userinfo endpoint: {oauth_client.userinfo_endpoint}" + "Using external IdP userinfo endpoint: %s", oauth_client.userinfo_endpoint ) return oauth_client.userinfo_endpoint @@ -269,7 +269,8 @@ async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None: if userinfo_endpoint: logger.debug( - f"Using Nextcloud userinfo endpoint from discovery: {userinfo_endpoint}" + "Using Nextcloud userinfo endpoint from discovery: %s", + userinfo_endpoint, ) return userinfo_endpoint @@ -277,7 +278,7 @@ async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None: return None except Exception as e: - logger.error(f"Failed to query discovery document for userinfo endpoint: {e}") + logger.error("Failed to query discovery document for userinfo endpoint: %s", e) return None @@ -302,7 +303,7 @@ async def _query_idp_userinfo( response.raise_for_status() return response.json() except Exception as e: - logger.warning(f"Failed to query IdP userinfo endpoint: {e}") + logger.warning("Failed to query IdP userinfo endpoint: %s", e) return None @@ -379,9 +380,9 @@ async def _get_user_info(request: Request) -> dict[str, Any]: # Include cached profile if available if profile_data: user_context["idp_profile"] = profile_data - logger.debug(f"Loaded cached profile for {session_id[:16]}...") + logger.debug("Loaded cached profile for %s...", session_id[:16]) else: - logger.warning(f"No cached profile found for {session_id[:16]}...") + logger.warning("No cached profile found for %s...", session_id[:16]) user_context["idp_profile_error"] = ( "Profile not cached. Try logging out and back in." ) @@ -389,8 +390,8 @@ async def _get_user_info(request: Request) -> dict[str, Any]: return user_context except Exception as e: - logger.error(f"Error retrieving user info: {e}") - logger.error(f"Traceback: {traceback.format_exc()}") + logger.error("Error retrieving user info: %s", e) + logger.error("Traceback: %s", traceback.format_exc()) return { "error": f"Failed to retrieve user info: {e}", "username": username, @@ -439,7 +440,7 @@ async def user_info_html(request: Request) -> HTMLResponse: is_admin = await is_nextcloud_admin(request, nc_client._client) await nc_client.close() except Exception as e: - logger.warning(f"Failed to check admin status: {e}") + logger.warning("Failed to check admin status: %s", e) # Default to not admin if check fails # Check for error @@ -697,9 +698,9 @@ async def revoke_session(request: Request) -> HTMLResponse: try: # Delete the refresh token - logger.info(f"Revoking background access for session {session_id[:16]}...") + logger.info("Revoking background access for session %s...", session_id[:16]) await storage.delete_refresh_token(session_id) - logger.info(f"✓ Background access revoked for session {session_id[:16]}...") + logger.info("✓ Background access revoked for session %s...", session_id[:16]) # Redirect back to user page user_page_url = str(request.url_for("user_info_html")) @@ -718,7 +719,7 @@ async def revoke_session(request: Request) -> HTMLResponse: ) except Exception as e: - logger.error(f"Failed to revoke background access: {e}") + logger.error("Failed to revoke background access: %s", e) template = _jinja_env.get_template("error.html") return HTMLResponse( content=template.render( diff --git a/nextcloud_mcp_server/auth/viz_routes.py b/nextcloud_mcp_server/auth/viz_routes.py index 1c6ad627..c1741ebd 100644 --- a/nextcloud_mcp_server/auth/viz_routes.py +++ b/nextcloud_mcp_server/auth/viz_routes.py @@ -140,8 +140,13 @@ async def vector_visualization_search(request: Request) -> JSONResponse: doc_types = doc_types_param.split(",") if doc_types_param else None logger.info( - f"Viz search: user={username}, query='{query}', " - f"algorithm={algorithm}, fusion={fusion}, limit={limit}, doc_types={doc_types}" + "Viz search: user=%s, query='%s', algorithm=%s, fusion=%s, limit=%s, doc_types=%s", + username, + query, + algorithm, + fusion, + limit, + doc_types, ) try: @@ -230,8 +235,9 @@ async def vector_visualization_search(request: Request) -> JSONResponse: score_range = max_score - min_score if max_score > min_score else 1.0 logger.info( - f"Normalizing scores for viz: original range [{min_score:.3f}, {max_score:.3f}] " - f"→ [0.0, 1.0]" + "Normalizing scores for viz: original range [%s, %s] → [0.0, 1.0]", + format(min_score, ".3f"), + format(max_score, ".3f"), ) # Store original score and rescale to 0-1 for visualization @@ -338,7 +344,7 @@ async def vector_visualization_search(request: Request) -> JSONResponse: status_code=500, ) - logger.info(f"Detected embedding dimension: {embedding_dim}") + logger.info("Detected embedding dimension: %s", embedding_dim) # Build chunk vectors array in search_results order (1:1 mapping) chunk_vectors = [] @@ -349,7 +355,8 @@ async def vector_visualization_search(request: Request) -> JSONResponse: else: # Chunk not found in vectors (shouldn't happen) logger.warning( - f"Chunk {chunk_key} not found in fetched vectors, using zero vector" + "Chunk %s not found in fetched vectors, using zero vector", + chunk_key, ) # Use zero vector as fallback chunk_vectors.append(np.zeros(embedding_dim)) @@ -361,14 +368,16 @@ async def vector_visualization_search(request: Request) -> JSONResponse: if search_algo.query_embedding is not None: query_embedding = search_algo.query_embedding logger.info( - f"Reusing query embedding from search algorithm " - f"(dimension={len(query_embedding)})" + "Reusing query embedding from search algorithm (dimension=%s)", + len(query_embedding), ) else: # Fallback: generate embedding if not available from search embedding_service = get_embedding_service() query_embedding = await embedding_service.embed(query) - logger.info(f"Generated query embedding (dimension={len(query_embedding)})") + logger.info( + "Generated query embedding (dimension=%s)", len(query_embedding) + ) query_embed_duration = time.perf_counter() - query_embed_start # Combine query vector with chunk vectors for PCA @@ -387,16 +396,19 @@ async def vector_visualization_search(request: Request) -> JSONResponse: if zero_norm_mask.any(): zero_indices = np.where(zero_norm_mask)[0] logger.warning( - f"Found {zero_norm_mask.sum()} zero-norm vectors at indices {zero_indices.tolist()}. " - "Replacing with small epsilon to avoid division by zero." + "Found %s zero-norm vectors at indices %s. Replacing with small epsilon to avoid division by zero.", + zero_norm_mask.sum(), + zero_indices.tolist(), ) # Replace zero norms with small epsilon to avoid NaN norms[zero_norm_mask] = 1e-10 all_vectors_normalized = all_vectors / norms logger.info( - f"Normalized vectors: query_norm={norms[-1][0]:.3f}, " - f"doc_norm_range=[{norms[:-1].min():.3f}, {norms[:-1].max():.3f}]" + "Normalized vectors: query_norm=%s, doc_norm_range=[%s, %s]", + format(norms[-1][0], ".3f"), + format(norms[:-1].min(), ".3f"), + format(norms[:-1].max(), ".3f"), ) # Apply PCA dimensionality reduction (768-dim → 3D) on normalized vectors @@ -428,8 +440,9 @@ async def vector_visualization_search(request: Request) -> JSONResponse: if nan_mask.any(): nan_rows = np.where(nan_mask.any(axis=1))[0] logger.error( - f"Found NaN values in PCA output at {len(nan_rows)} points: {nan_rows.tolist()[:10]}. " - "Replacing NaN with 0.0 to prevent JSON serialization error." + "Found NaN values in PCA output at %s points: %s. Replacing NaN with 0.0 to prevent JSON serialization error.", + len(nan_rows), + nan_rows.tolist()[:10], ) # Replace NaN with 0 to allow JSON serialization coords_3d = np.nan_to_num(coords_3d, nan=0.0) @@ -442,13 +455,16 @@ async def vector_visualization_search(request: Request) -> JSONResponse: chunk_coords_3d = coords_3d[:-1] # All but last are chunks logger.info( - f"PCA explained variance: PC1={pca.explained_variance_ratio_[0]:.3f}, " - f"PC2={pca.explained_variance_ratio_[1]:.3f}, " - f"PC3={pca.explained_variance_ratio_[2]:.3f}" + "PCA explained variance: PC1=%s, PC2=%s, PC3=%s", + format(pca.explained_variance_ratio_[0], ".3f"), + format(pca.explained_variance_ratio_[1], ".3f"), + format(pca.explained_variance_ratio_[2], ".3f"), ) logger.info( - f"Embedding stats: chunks={len(chunk_vectors)}, " - f"query_dim={len(query_embedding)}, chunk_vector_dim={chunk_vectors.shape[1] if chunk_vectors.size > 0 else 0}" + "Embedding stats: chunks=%s, query_dim=%s, chunk_vector_dim=%s", + len(chunk_vectors), + len(query_embedding), + chunk_vectors.shape[1] if chunk_vectors.size > 0 else 0, ) # Coordinates already match search_results order (1:1 mapping) @@ -479,12 +495,18 @@ async def vector_visualization_search(request: Request) -> JSONResponse: # Log comprehensive timing metrics logger.info( - f"Viz search timing: total={total_duration * 1000:.1f}ms, " - f"search={search_duration * 1000:.1f}ms ({search_duration / total_duration * 100:.1f}%), " - f"vector_fetch={vector_fetch_duration * 1000:.1f}ms ({vector_fetch_duration / total_duration * 100:.1f}%), " - f"query_embed={query_embed_duration * 1000:.1f}ms ({query_embed_duration / total_duration * 100:.1f}%), " - f"pca={pca_duration * 1000:.1f}ms ({pca_duration / total_duration * 100:.1f}%), " - f"results={len(search_results)}, chunk_vectors={len(chunk_vectors)}" + "Viz search timing: total=%sms, search=%sms (%s%%), vector_fetch=%sms (%s%%), query_embed=%sms (%s%%), pca=%sms (%s%%), results=%s, chunk_vectors=%s", + format(total_duration * 1000, ".1f"), + format(search_duration * 1000, ".1f"), + format(search_duration / total_duration * 100, ".1f"), + format(vector_fetch_duration * 1000, ".1f"), + format(vector_fetch_duration / total_duration * 100, ".1f"), + format(query_embed_duration * 1000, ".1f"), + format(query_embed_duration / total_duration * 100, ".1f"), + format(pca_duration * 1000, ".1f"), + format(pca_duration / total_duration * 100, ".1f"), + len(search_results), + len(chunk_vectors), ) return JSONResponse( @@ -511,7 +533,7 @@ async def vector_visualization_search(request: Request) -> JSONResponse: ) except Exception as e: - logger.error(f"Viz search error: {e}", exc_info=True) + logger.error("Viz search error: %s", e, exc_info=True) return JSONResponse( {"success": False, "error": str(e)}, status_code=500, @@ -637,10 +659,12 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse: ) logger.info( - f"Fetched chunk context for {doc_type}_{doc_id}: " - f"chunk_len={len(chunk_context.chunk_text)}, " - f"before_len={len(chunk_context.before_context)}, " - f"after_len={len(chunk_context.after_context)}" + "Fetched chunk context for %s_%s: chunk_len=%s, before_len=%s, after_len=%s", + doc_type, + doc_id, + len(chunk_context.chunk_text), + len(chunk_context.before_context), + len(chunk_context.after_context), ) # For PDF files, also fetch the chunk bbox from Qdrant so the client @@ -688,7 +712,7 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse: status_code=400, ) except Exception as e: - logger.error(f"Chunk context error: {e}", exc_info=True) + logger.error("Chunk context error: %s", e, exc_info=True) return JSONResponse( {"success": False, "error": str(e)}, status_code=500, diff --git a/nextcloud_mcp_server/client/__init__.py b/nextcloud_mcp_server/client/__init__.py index cf49be3f..ce41f47d 100644 --- a/nextcloud_mcp_server/client/__init__.py +++ b/nextcloud_mcp_server/client/__init__.py @@ -167,7 +167,7 @@ class NextcloudClient: """ from ..auth import BearerAuth # noqa: PLC0415 - logger.info(f"Creating NC Client for user '{username}' using OAuth token") + logger.info("Creating NC Client for user '%s' using OAuth token", username) return cls( base_url=base_url, username=username, diff --git a/nextcloud_mcp_server/client/base.py b/nextcloud_mcp_server/client/base.py index 8a538899..9e4bf74f 100644 --- a/nextcloud_mcp_server/client/base.py +++ b/nextcloud_mcp_server/client/base.py @@ -43,7 +43,8 @@ def retry_on_429(func): # error we wait a couple of seconds and do a retry if e.response.status_code == codes.TOO_MANY_REQUESTS: logger.warning( - f"429 Client Error: Too Many Requests, Number of attempts: {retries}" + "429 Client Error: Too Many Requests, Number of attempts: %s", + retries, ) # Record retry metric (extract app name from args if available) if len(args) > 0 and hasattr(args[0], "app_name"): @@ -53,17 +54,26 @@ def retry_on_429(func): # 404 errors are often expected (e.g., checking if attachments exist) # Log as debug instead of warning logger.debug( - f"HTTPStatusError {e.response.status_code}: {e}, Number of attempts: {retries}" + "HTTPStatusError %s: %s, Number of attempts: %s", + e.response.status_code, + e, + retries, ) raise else: logger.warning( - f"HTTPStatusError {e.response.status_code}: {e}, Number of attempts: {retries}" + "HTTPStatusError %s: %s, Number of attempts: %s", + e.response.status_code, + e, + retries, ) raise except RequestError as e: logger.warning( - f"RequestError {e.request.url}: {e}, Number of attempts: {retries}" + "RequestError %s: %s, Number of attempts: %s", + e.request.url, + e, + retries, ) raise @@ -127,7 +137,7 @@ class BaseNextcloudClient(ABC): Response object """ url = self._resolve_url(url) - logger.debug(f"Making {method} request to {url}") + logger.debug("Making %s request to %s", method, url) # Start timer for metrics start_time = time.time() diff --git a/nextcloud_mcp_server/client/calendar.py b/nextcloud_mcp_server/client/calendar.py index 613af16e..54723646 100644 --- a/nextcloud_mcp_server/client/calendar.py +++ b/nextcloud_mcp_server/client/calendar.py @@ -131,23 +131,32 @@ class CalendarClient: max_attempts: Maximum polling attempts (default: 40) initial_delay_ms: Initial delay between attempts in ms (default: 100ms) """ - logger.info(f"Waiting for calendar '{calendar_name}' to propagate...") + logger.info("Waiting for calendar '%s' to propagate...", calendar_name) delay_ms = initial_delay_ms for attempt in range(max_attempts): try: logger.debug( - f"Attempt {attempt + 1}/{max_attempts} to find calendar '{calendar_name}'..." + "Attempt %s/%s to find calendar '%s'...", + attempt + 1, + max_attempts, + calendar_name, ) calendars = await self.list_calendars() if any(cal["name"] == calendar_name for cal in calendars): logger.info( - f"Calendar '{calendar_name}' became available after {attempt + 1} attempts" + "Calendar '%s' became available after %s attempts", + calendar_name, + attempt + 1, ) return except Exception as e: logger.warning( - f"Attempt {attempt + 1}/{max_attempts} to verify calendar '{calendar_name}' failed: {e}" + "Attempt %s/%s to verify calendar '%s' failed: %s", + attempt + 1, + max_attempts, + calendar_name, + e, ) if attempt < max_attempts - 1: @@ -156,7 +165,9 @@ class CalendarClient: delay_ms = min(delay_ms * 2, 2000) logger.error( - f"Calendar '{calendar_name}' did not become available after {max_attempts} attempts." + "Calendar '%s' did not become available after %s attempts.", + calendar_name, + max_attempts, ) # ============= Calendar Operations ============= @@ -243,7 +254,7 @@ class CalendarClient: } ) - logger.debug(f"Found {len(result)} calendars") + logger.debug("Found %s calendars", len(result)) return result async def create_calendar( @@ -285,7 +296,7 @@ class CalendarClient: f"Failed to create calendar '{calendar_name}': HTTP {response.status}" ) - logger.debug(f"Created calendar: {calendar_name}") + logger.debug("Created calendar: %s", calendar_name) # Wait for calendar to be queryable (Nextcloud eventual consistency) await self._wait_for_calendar_propagation(calendar_name) @@ -306,7 +317,7 @@ class CalendarClient: ) await self._dav_client.delete(calendar_url) - logger.debug(f"Deleted calendar: {calendar_name}") + logger.debug("Deleted calendar: %s", calendar_name) return {"status_code": 204} # ============= Event Operations ============= @@ -467,7 +478,7 @@ class CalendarClient: # caldav v3's _async_put raises PutError on HTTP failure event = await calendar.save_event(ical=ical_content) # type: ignore[misc] # dual-mode - logger.debug(f"Created event {event_uid}") + logger.debug("Created event %s", event_uid) return { "uid": event_uid, @@ -498,7 +509,7 @@ class CalendarClient: await _maybe_await(event.save()) - logger.debug(f"Updated event {event_uid}") + logger.debug("Updated event %s", event_uid) return { "uid": event_uid, "href": str(event.url), @@ -515,10 +526,10 @@ class CalendarClient: calendar, event_uid, cdav.CompFilter("VEVENT") ) await _maybe_await(event.delete()) - logger.debug(f"Deleted event {event_uid}") + logger.debug("Deleted event %s", event_uid) return {"status_code": 204} except caldav_error.NotFoundError as e: - logger.debug(f"Event {event_uid} not found: {e}") + logger.debug("Event %s not found: %s", event_uid, e) return {"status_code": 404} async def get_event( @@ -539,7 +550,7 @@ class CalendarClient: event_data["href"] = str(event.url) event_data["etag"] = "" - logger.debug(f"Retrieved event {event_uid}") + logger.debug("Retrieved event %s", event_uid) return event_data, "" async def search_events_across_calendars( @@ -573,14 +584,14 @@ class CalendarClient: all_events.extend(events) except Exception as e: logger.warning( - f"Error getting events from calendar {calendar['name']}: {e}" + "Error getting events from calendar %s: %s", calendar["name"], e ) continue return all_events except Exception as e: - logger.error(f"Error searching events across calendars: {e}") + logger.error("Error searching events across calendars: %s", e) raise # ============= Todo/Task Operations (NEW) ============= @@ -611,7 +622,7 @@ class CalendarClient: if not filters or self._todo_matches_filters(todo_dict, filters): result.append(todo_dict) - logger.debug(f"Found {len(result)} todos") + logger.debug("Found %s todos", len(result)) return result async def create_todo( @@ -626,7 +637,7 @@ class CalendarClient: # caldav v3's _async_put raises PutError on HTTP failure todo = await calendar.save_todo(ical=ical_content) # type: ignore[misc] # dual-mode - logger.debug(f"Created todo {todo_uid}") + logger.debug("Created todo %s", todo_uid) return { "uid": todo_uid, @@ -653,7 +664,7 @@ class CalendarClient: await _maybe_await(todo.load(only_if_unloaded=True)) logger.debug( - f"Loaded todo {todo_uid}, current data length: {len(todo.data)}" # type: ignore + "Loaded todo %s, current data length: %s", todo_uid, len(todo.data) ) # Merge updates into existing iCal data @@ -662,14 +673,14 @@ class CalendarClient: todo_data, todo_uid, ) - logger.debug(f"Merged iCal data length: {len(updated_ical)}") - logger.debug(f"Updated iCal content:\n{updated_ical}") + logger.debug("Merged iCal data length: %s", len(updated_ical)) + logger.debug("Updated iCal content:\\n%s", updated_ical) todo.data = updated_ical await _maybe_await(todo.save()) - logger.debug(f"Updated todo {todo_uid}") + logger.debug("Updated todo %s", todo_uid) return { "uid": todo_uid, "href": str(todo.url), @@ -677,7 +688,7 @@ class CalendarClient: "status_code": 200, } except Exception as e: - logger.error(f"Error updating todo {todo_uid}: {e}", exc_info=True) + logger.error("Error updating todo %s: %s", todo_uid, e, exc_info=True) raise async def delete_todo(self, calendar_name: str, todo_uid: str) -> dict[str, Any]: @@ -689,10 +700,10 @@ class CalendarClient: calendar, todo_uid, cdav.CompFilter("VTODO") ) await _maybe_await(todo.delete()) - logger.debug(f"Deleted todo {todo_uid}") + logger.debug("Deleted todo %s", todo_uid) return {"status_code": 204} except caldav_error.NotFoundError as e: - logger.debug(f"Todo {todo_uid} not found: {e}") + logger.debug("Todo %s not found: %s", todo_uid, e) return {"status_code": 404} async def search_todos_across_calendars( @@ -717,14 +728,14 @@ class CalendarClient: all_todos.extend(todos) except Exception as e: logger.warning( - f"Error getting todos from calendar {calendar['name']}: {e}" + "Error getting todos from calendar %s: %s", calendar["name"], e ) continue return all_todos except Exception as e: - logger.error(f"Error searching todos across calendars: {e}") + logger.error("Error searching todos across calendars: %s", e) raise # ============= Helper Methods - Event iCalendar ============= @@ -935,7 +946,7 @@ class CalendarClient: return self._extract_vevent_data(component) return None except Exception as e: - logger.error(f"Error parsing iCalendar event: {e}") + logger.error("Error parsing iCalendar event: %s", e) return None def _merge_ical_properties( @@ -1060,7 +1071,7 @@ class CalendarClient: return cal.to_ical().decode("utf-8") except Exception as e: - logger.error(f"Error merging iCal properties: {e}") + logger.error("Error merging iCal properties: %s", e) return self._create_ical_event(event_data, event_uid) # ============= Helper Methods - Todo iCalendar ============= @@ -1184,7 +1195,7 @@ class CalendarClient: return None except Exception as e: - logger.error(f"Error parsing iCalendar todo: {e}") + logger.error("Error parsing iCalendar todo: %s", e) return None def _merge_ical_todo_properties( @@ -1193,7 +1204,7 @@ class CalendarClient: """Merge new todo data into existing raw iCal while preserving all properties.""" try: logger.debug( - f"Merging todo properties for {todo_uid}: {list(todo_data.keys())}" + "Merging todo properties for %s: %s", todo_uid, list(todo_data.keys()) ) cal = Calendar.from_ical(raw_ical) @@ -1207,13 +1218,13 @@ class CalendarClient: if "status" in todo_data: status_value = todo_data["status"].upper() component["STATUS"] = status_value - logger.debug(f"Set STATUS to {status_value}") + logger.debug("Set STATUS to %s", status_value) if "priority" in todo_data: component["PRIORITY"] = todo_data["priority"] if "percent_complete" in todo_data: percent_value = todo_data["percent_complete"] component["PERCENT-COMPLETE"] = percent_value - logger.debug(f"Set PERCENT-COMPLETE to {percent_value}") + logger.debug("Set PERCENT-COMPLETE to %s", percent_value) # Handle due date if "due" in todo_data: @@ -1221,7 +1232,7 @@ class CalendarClient: if due_str: due_dt = self._ensure_timezone_aware(due_str) component["DUE"] = vDDDTypes(due_dt) - logger.debug(f"Set DUE to {due_dt}") + logger.debug("Set DUE to %s", due_dt) # Handle start date if "dtstart" in todo_data: @@ -1229,7 +1240,7 @@ class CalendarClient: if dtstart_str: dtstart_dt = self._ensure_timezone_aware(dtstart_str) component["DTSTART"] = vDDDTypes(dtstart_dt) - logger.debug(f"Set DTSTART to {dtstart_dt}") + logger.debug("Set DTSTART to %s", dtstart_dt) # Handle completed date if "completed" in todo_data: @@ -1237,7 +1248,7 @@ class CalendarClient: if completed_str: completed_dt = self._ensure_timezone_aware(completed_str) component["COMPLETED"] = vDDDTypes(completed_dt) - logger.debug(f"Set COMPLETED to {completed_dt}") + logger.debug("Set COMPLETED to %s", completed_dt) # Handle categories if "categories" in todo_data: @@ -1246,7 +1257,7 @@ class CalendarClient: component["CATEGORIES"] = [ c.strip() for c in categories_str.split(",") ] - logger.debug(f"Set CATEGORIES to {categories_str}") + logger.debug("Set CATEGORIES to %s", categories_str) # Update timestamps now = dt.datetime.now(dt.UTC) @@ -1258,7 +1269,7 @@ class CalendarClient: return cal.to_ical().decode("utf-8") except Exception as e: - logger.error(f"Error merging iCal todo properties: {e}", exc_info=True) + logger.error("Error merging iCal todo properties: %s", e, exc_info=True) return self._create_ical_todo(todo_data, todo_uid) # ============= Helper Methods - Filtering ============= @@ -1290,7 +1301,7 @@ class CalendarClient: return categories_obj.to_ical().decode("utf-8") return str(categories_obj) except Exception as e: - logger.warning(f"Error extracting categories: {e}") + logger.warning("Error extracting categories: %s", e) return str(categories_obj) def _apply_event_filters( @@ -1437,7 +1448,7 @@ class CalendarClient: } except Exception as e: - logger.error(f"Error in bulk update: {e}") + logger.error("Error in bulk update: %s", e) raise async def find_availability( diff --git a/nextcloud_mcp_server/client/contacts.py b/nextcloud_mcp_server/client/contacts.py index a9a8e457..8badded9 100644 --- a/nextcloud_mcp_server/client/contacts.py +++ b/nextcloud_mcp_server/client/contacts.py @@ -89,7 +89,7 @@ class ContactsClient(BaseNextcloudClient): } ) - logger.debug(f"Found {len(addressbooks)} addressbooks") + logger.debug("Found %s addressbooks", len(addressbooks)) return addressbooks async def create_addressbook(self, *, name: str, display_name: str): @@ -166,7 +166,7 @@ class ContactsClient(BaseNextcloudClient): except Exception: # Fall back to creating new vCard if we can't get existing logger.warning( - f"Could not fetch existing vCard for {uid}, creating new" + "Could not fetch existing vCard for %s, creating new", uid ) raw_vcard_content = "" @@ -283,7 +283,7 @@ class ContactsClient(BaseNextcloudClient): } ) - logger.debug(f"Found {len(contacts)} contacts") + logger.debug("Found %s contacts", len(contacts)) return contacts async def _get_raw_vcard(self, addressbook: str, uid: str) -> tuple[str, str]: @@ -296,7 +296,7 @@ class ContactsClient(BaseNextcloudClient): etag = response.headers.get("etag", "") return response.text, etag except Exception as e: - logger.error(f"Error getting raw vCard for {uid}: {e}") + logger.error("Error getting raw vCard for %s: %s", uid, e) raise def _merge_vcard_properties( @@ -428,7 +428,7 @@ class ContactsClient(BaseNextcloudClient): return "\n".join(updated_lines) except Exception as e: - logger.error(f"Error merging vCard properties: {e}") + logger.error("Error merging vCard properties: %s", e) # Fallback to creating basic vCard matching Nextcloud format basic_vcard = f"""BEGIN:VCARD VERSION:3.0 diff --git a/nextcloud_mcp_server/client/cookbook.py b/nextcloud_mcp_server/client/cookbook.py index 1b2926d1..26066786 100644 --- a/nextcloud_mcp_server/client/cookbook.py +++ b/nextcloud_mcp_server/client/cookbook.py @@ -130,7 +130,7 @@ class CookbookClient(BaseNextcloudClient): Returns: Full imported recipe data """ - logger.info(f"Importing recipe from URL: {url}") + logger.info("Importing recipe from URL: %s", url) response = await self._make_request( "POST", "/apps/cookbook/api/v1/import", diff --git a/nextcloud_mcp_server/client/groups.py b/nextcloud_mcp_server/client/groups.py index e8549c35..669dcca3 100644 --- a/nextcloud_mcp_server/client/groups.py +++ b/nextcloud_mcp_server/client/groups.py @@ -67,7 +67,7 @@ class GroupsClient(BaseNextcloudClient): headers={"OCS-APIRequest": "true", "Accept": "application/json"}, ) response.raise_for_status() - logger.info(f"Created group: {groupid}") + logger.info("Created group: %s", groupid) @retry_on_429 async def delete_group(self, groupid: str) -> None: @@ -85,7 +85,7 @@ class GroupsClient(BaseNextcloudClient): headers={"OCS-APIRequest": "true", "Accept": "application/json"}, ) response.raise_for_status() - logger.info(f"Deleted group: {groupid}") + logger.info("Deleted group: %s", groupid) @retry_on_429 async def get_group_members(self, groupid: str) -> List[str]: @@ -150,4 +150,4 @@ class GroupsClient(BaseNextcloudClient): headers={"OCS-APIRequest": "true", "Accept": "application/json"}, ) response.raise_for_status() - logger.info(f"Updated group {groupid} displayname to: {displayname}") + logger.info("Updated group %s displayname to: %s", groupid, displayname) diff --git a/nextcloud_mcp_server/client/notes.py b/nextcloud_mcp_server/client/notes.py index 4bd53e3c..0c0582e6 100644 --- a/nextcloud_mcp_server/client/notes.py +++ b/nextcloud_mcp_server/client/notes.py @@ -93,13 +93,14 @@ class NotesClient(BaseNextcloudClient): for note in response_data: note_id = note.get("id") if note_id is None: - logger.warning(f"Skipping note without ID: {note}") + logger.warning("Skipping note without ID: %s", note) continue # Skip duplicates (API returns all IDs in last chunk for deletion detection) if note_id in seen_ids: logger.debug( - f"Skipping duplicate note {note_id} (pruned version in last chunk)" + "Skipping duplicate note %s (pruned version in last chunk)", + note_id, ) continue @@ -152,10 +153,10 @@ class NotesClient(BaseNextcloudClient): if category is not None: old_note = await self.get_note(note_id) old_category = old_note.get("category", "") - logger.info(f"Current category for note {note_id}: '{old_category}'") + logger.info("Current category for note %s: '%s'", note_id, old_category) except Exception as e: logger.warning( - f"Could not fetch current note {note_id} details before update: {e}" + "Could not fetch current note %s details before update: %s", note_id, e ) old_note = None @@ -169,7 +170,7 @@ class NotesClient(BaseNextcloudClient): body["category"] = category logger.info( - f"Attempting to update note {note_id} with etag {etag}. Body: {body}" + "Attempting to update note %s with etag %s. Body: %s", note_id, etag, body ) response = await self._make_request( @@ -180,7 +181,7 @@ class NotesClient(BaseNextcloudClient): ) logger.info( - f"Update response for note {note_id}: Status {response.status_code}" + "Update response for note %s: Status %s", note_id, response.status_code ) updated_note = _expect_note_object(response.json(), operation="update_note") @@ -191,7 +192,9 @@ class NotesClient(BaseNextcloudClient): and old_note.get("category", "") != category ): logger.info( - f"Category changed from '{old_note.get('category', '')}' to '{category}' - cleaning up old attachment directory" + "Category changed from '%s' to '%s' - cleaning up old attachment directory", + old_note.get("category", ""), + category, ) try: webdav_client = WebDAVClient(self._client, self.username) @@ -200,7 +203,9 @@ class NotesClient(BaseNextcloudClient): ) except Exception as e: logger.error( - f"Error cleaning up old attachment directory for note {note_id}: {e}" + "Error cleaning up old attachment directory for note %s: %s", + note_id, + e, ) return updated_note @@ -220,20 +225,23 @@ class NotesClient(BaseNextcloudClient): potential_categories.append("") # Empty category logger.info( - f"Note {note_id} has category: '{category}', will check attachment directories in: {potential_categories}" + "Note %s has category: '%s', will check attachment directories in: %s", + note_id, + category, + potential_categories, ) except Exception as e: logger.warning( - f"Could not fetch note {note_id} details before deletion: {e}" + "Could not fetch note %s details before deletion: %s", note_id, e ) potential_categories = ["", "Unknown"] # Try common categories # Delete the note via API - logger.info(f"Deleting note {note_id} via API") + logger.info("Deleting note %s via API", note_id) response = await self._make_request( "DELETE", f"/apps/notes/api/v1/notes/{note_id}" ) - logger.info(f"Note {note_id} deleted successfully via API") + logger.info("Note %s deleted successfully via API", note_id) json_response = response.json() # Clean up attachment directories @@ -245,16 +253,16 @@ class NotesClient(BaseNextcloudClient): await webdav_client.cleanup_note_attachments(note_id, cat) except Exception as e: logger.warning( - f"Failed to cleanup attachments for category '{cat}': {e}" + "Failed to cleanup attachments for category '%s': %s", cat, e ) except Exception as e: - logger.warning(f"Error during attachment cleanup: {e}") + logger.warning("Error during attachment cleanup: %s", e) return json_response async def append_content(self, note_id: int, content: str) -> Dict[str, Any]: """Append content to an existing note with a separator.""" - logger.info(f"Appending content to note {note_id}") + logger.info("Appending content to note %s", note_id) # Get current note current_note = await self.get_note(note_id) @@ -270,7 +278,9 @@ class NotesClient(BaseNextcloudClient): new_content = content # No separator needed for empty notes logger.info( - f"Combining existing content ({len(existing_content)} chars) with new content ({len(content)} chars)" + "Combining existing content (%s chars) with new content (%s chars)", + len(existing_content), + len(content), ) # Update with combined content diff --git a/nextcloud_mcp_server/client/sharing.py b/nextcloud_mcp_server/client/sharing.py index 07ec45ae..f04c9f9b 100644 --- a/nextcloud_mcp_server/client/sharing.py +++ b/nextcloud_mcp_server/client/sharing.py @@ -72,8 +72,12 @@ class SharingClient(BaseNextcloudClient): ) logger.info( - f"Created share {share_data['id']}: {path} -> {share_with} " - f"(type={share_type}, permissions={permissions})" + "Created share %s: %s -> %s (type=%s, permissions=%s)", + share_data["id"], + path, + share_with, + share_type, + permissions, ) return share_data @@ -99,7 +103,7 @@ class SharingClient(BaseNextcloudClient): f"OCS API error: {data['ocs']['meta'].get('message', 'Unknown error')}" ) - logger.info(f"Deleted share {share_id}") + logger.info("Deleted share %s", share_id) @retry_on_429 async def get_share(self, share_id: int) -> dict[str, Any]: @@ -206,5 +210,5 @@ class SharingClient(BaseNextcloudClient): f"OCS API error: {result['ocs']['meta'].get('message', 'Unknown error')}" ) - logger.info(f"Updated share {share_id}") + logger.info("Updated share %s", share_id) return result["ocs"]["data"] diff --git a/nextcloud_mcp_server/client/webdav.py b/nextcloud_mcp_server/client/webdav.py index f932e1ad..f2068187 100644 --- a/nextcloud_mcp_server/client/webdav.py +++ b/nextcloud_mcp_server/client/webdav.py @@ -28,7 +28,7 @@ class WebDAVClient(BaseNextcloudClient): path_with_slash = path webdav_path = f"{self._get_webdav_base_path()}/{path_with_slash.lstrip('/')}" - logger.debug(f"Deleting WebDAV resource: {webdav_path}") + logger.debug("Deleting WebDAV resource: %s", webdav_path) headers = {"OCS-APIRequest": "true"} try: @@ -39,28 +39,30 @@ class WebDAVClient(BaseNextcloudClient): "PROPFIND", webdav_path, headers=propfind_headers ) logger.debug( - f"Resource exists check status: {propfind_resp.status_code}" + "Resource exists check status: %s", propfind_resp.status_code ) except HTTPStatusError as e: if e.response.status_code == 404: - logger.debug(f"Resource '{path}' doesn't exist, no deletion needed") + logger.debug( + "Resource '%s' doesn't exist, no deletion needed", path + ) return {"status_code": 404} # For other errors, continue with deletion attempt # Proceed with deletion response = await self._make_request("DELETE", webdav_path, headers=headers) - logger.debug(f"Successfully deleted WebDAV resource '{path}'") + logger.debug("Successfully deleted WebDAV resource '%s'", path) return {"status_code": response.status_code} except HTTPStatusError as e: if e.response.status_code == 404: - logger.debug(f"Resource '{path}' not found, no deletion needed") + logger.debug("Resource '%s' not found, no deletion needed", path) return {"status_code": 404} else: - logger.error(f"HTTP error deleting WebDAV resource '{path}': {e}") + logger.error("HTTP error deleting WebDAV resource '%s': %s", path, e) raise e except Exception as e: - logger.error(f"Unexpected error deleting WebDAV resource '{path}': {e}") + logger.error("Unexpected error deleting WebDAV resource '%s': %s", path, e) raise e async def cleanup_old_attachment_directory( @@ -72,13 +74,15 @@ class WebDAVClient(BaseNextcloudClient): f"Notes/{old_category_path_part}.attachments.{note_id}/" ) - logger.debug(f"Cleaning up old attachment directory: {old_attachment_dir_path}") + logger.debug( + "Cleaning up old attachment directory: %s", old_attachment_dir_path + ) try: delete_result = await self.delete_resource(path=old_attachment_dir_path) - logger.debug(f"Cleanup result: {delete_result}") + logger.debug("Cleanup result: %s", delete_result) return delete_result except Exception as e: - logger.error(f"Error during cleanup of old attachment directory: {e}") + logger.error("Error during cleanup of old attachment directory: %s", e) raise e async def cleanup_note_attachments( @@ -89,14 +93,14 @@ class WebDAVClient(BaseNextcloudClient): attachment_dir_path = f"Notes/{cat_path_part}.attachments.{note_id}/" logger.debug( - f"Cleaning up attachments for note {note_id} in category '{category}'" + "Cleaning up attachments for note %s in category '%s'", note_id, category ) try: delete_result = await self.delete_resource(path=attachment_dir_path) - logger.debug(f"Cleanup result for note {note_id}: {delete_result}") + logger.debug("Cleanup result for note %s: %s", note_id, delete_result) return delete_result except Exception as e: - logger.error(f"Failed cleaning up attachments for note {note_id}: {e}") + logger.error("Failed cleaning up attachments for note %s: %s", note_id, e) raise e async def add_note_attachment( @@ -118,7 +122,7 @@ class WebDAVClient(BaseNextcloudClient): parent_dir_path = f"{webdav_base}/{parent_dir_webdav_rel_path}" attachment_path = f"{parent_dir_path}/{filename}" - logger.debug(f"Uploading attachment '{filename}' for note {note_id}") + logger.debug("Uploading attachment '%s' for note %s", filename, note_id) if not mime_type: mime_type, _ = mimetypes.guess_type(filename) @@ -143,7 +147,8 @@ class WebDAVClient(BaseNextcloudClient): ) elif notes_dir_response.status_code >= 400: logger.error( - f"Error accessing WebDAV Notes directory: {notes_dir_response.status_code}" + "Error accessing WebDAV Notes directory: %s", + notes_dir_response.status_code, ) notes_dir_response.raise_for_status() @@ -156,7 +161,8 @@ class WebDAVClient(BaseNextcloudClient): # MKCOL should return 201 Created or 405 Method Not Allowed (if directory already exists) if mkcol_response.status_code not in [201, 405]: logger.error( - f"Unexpected status code {mkcol_response.status_code} when creating attachments directory" + "Unexpected status code %s when creating attachments directory", + mkcol_response.status_code, ) mkcol_response.raise_for_status() @@ -166,18 +172,24 @@ class WebDAVClient(BaseNextcloudClient): ) response.raise_for_status() logger.debug( - f"Successfully uploaded attachment '{filename}' to note {note_id}" + "Successfully uploaded attachment '%s' to note %s", filename, note_id ) return {"status_code": response.status_code} except HTTPStatusError as e: logger.error( - f"HTTP error uploading attachment '{filename}' to note {note_id}: {e}" + "HTTP error uploading attachment '%s' to note %s: %s", + filename, + note_id, + e, ) raise e except Exception as e: logger.error( - f"Unexpected error uploading attachment '{filename}' to note {note_id}: {e}" + "Unexpected error uploading attachment '%s' to note %s: %s", + filename, + note_id, + e, ) raise e @@ -190,7 +202,7 @@ class WebDAVClient(BaseNextcloudClient): attachment_dir_segment = f".attachments.{note_id}" attachment_path = f"{webdav_base}/Notes/{category_path_part}{attachment_dir_segment}/{filename}" - logger.debug(f"Fetching attachment '{filename}' for note {note_id}") + logger.debug("Fetching attachment '%s' for note %s", filename, note_id) try: response = await self._make_request("GET", attachment_path) @@ -200,21 +212,29 @@ class WebDAVClient(BaseNextcloudClient): mime_type = response.headers.get("content-type", "application/octet-stream") logger.debug( - f"Successfully fetched attachment '{filename}' ({len(content)} bytes)" + "Successfully fetched attachment '%s' (%s bytes)", + filename, + len(content), ) return content, mime_type except HTTPStatusError as e: if e.response.status_code == 404: - logger.debug(f"Attachment '{filename}' not found for note {note_id}") + logger.debug("Attachment '%s' not found for note %s", filename, note_id) else: logger.error( - f"HTTP error fetching attachment '{filename}' for note {note_id}: {e}" + "HTTP error fetching attachment '%s' for note %s: %s", + filename, + note_id, + e, ) raise e except Exception as e: logger.error( - f"Unexpected error fetching attachment '{filename}' for note {note_id}: {e}" + "Unexpected error fetching attachment '%s' for note %s: %s", + filename, + note_id, + e, ) raise e @@ -224,7 +244,7 @@ class WebDAVClient(BaseNextcloudClient): if not webdav_path.endswith("/"): webdav_path += "/" - logger.debug(f"Listing directory: {path}") + logger.debug("Listing directory: %s", path) propfind_body = """ @@ -308,21 +328,21 @@ class WebDAVClient(BaseNextcloudClient): } ) - logger.debug(f"Found {len(items)} items in directory: {path}") + logger.debug("Found %s items in directory: %s", len(items), path) return items except HTTPStatusError as e: - logger.error(f"HTTP error listing directory '{webdav_path}': {e}") + logger.error("HTTP error listing directory '%s': %s", webdav_path, e) raise e except Exception as e: - logger.error(f"Unexpected error listing directory '{webdav_path}': {e}") + logger.error("Unexpected error listing directory '%s': %s", webdav_path, e) raise e async def read_file(self, path: str) -> Tuple[bytes, str]: """Read a file's content via WebDAV GET.""" webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}" - logger.debug(f"Reading file: {path}") + logger.debug("Reading file: %s", path) try: response = await self._make_request("GET", webdav_path) @@ -333,14 +353,14 @@ class WebDAVClient(BaseNextcloudClient): "content-type", "application/octet-stream" ) - logger.debug(f"Successfully read file '{path}' ({len(content)} bytes)") + logger.debug("Successfully read file '%s' (%s bytes)", path, len(content)) return content, content_type except HTTPStatusError as e: - logger.error(f"HTTP error reading file '{path}': {e}") + logger.error("HTTP error reading file '%s': %s", path, e) raise e except Exception as e: - logger.error(f"Unexpected error reading file '{path}': {e}") + logger.error("Unexpected error reading file '%s': %s", path, e) raise e async def write_file( @@ -349,7 +369,7 @@ class WebDAVClient(BaseNextcloudClient): """Write content to a file via WebDAV PUT.""" webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}" - logger.debug(f"Writing file: {path}") + logger.debug("Writing file: %s", path) if not content_type: content_type, _ = mimetypes.guess_type(path) @@ -364,14 +384,14 @@ class WebDAVClient(BaseNextcloudClient): ) response.raise_for_status() - logger.debug(f"Successfully wrote file '{path}'") + logger.debug("Successfully wrote file '%s'", path) return {"status_code": response.status_code} except HTTPStatusError as e: - logger.error(f"HTTP error writing file '{path}': {e}") + logger.error("HTTP error writing file '%s': %s", path, e) raise e except Exception as e: - logger.error(f"Unexpected error writing file '{path}': {e}") + logger.error("Unexpected error writing file '%s': %s", path, e) raise e async def create_directory( @@ -382,7 +402,7 @@ class WebDAVClient(BaseNextcloudClient): if not webdav_path.endswith("/"): webdav_path += "/" - logger.debug(f"Creating directory: {path}") + logger.debug("Creating directory: %s", path) headers = {"OCS-APIRequest": "true"} @@ -390,13 +410,13 @@ class WebDAVClient(BaseNextcloudClient): response = await self._make_request("MKCOL", webdav_path, headers=headers) response.raise_for_status() - logger.debug(f"Successfully created directory '{path}'") + logger.debug("Successfully created directory '%s'", path) return {"status_code": response.status_code} except HTTPStatusError as e: # Method Not Allowed - directory already exists if e.response.status_code == 405: - logger.debug(f"Directory '{path}' already exists") + logger.debug("Directory '%s' already exists", path) return {"status_code": 405, "message": "Directory already exists"} # File Conflict - parent directory does not exist @@ -406,20 +426,21 @@ class WebDAVClient(BaseNextcloudClient): if len(path_parts) > 1: parent_dir = "/".join(path_parts[:-1]) logger.debug( - f"Parent directory '{parent_dir}' doesn't exist, creating recursively" + "Parent directory '%s' doesn't exist, creating recursively", + parent_dir, ) await self.create_directory(parent_dir, recursive) # Now try to create the original directory again return await self.create_directory(path, recursive) else: # This shouldn't happen for single-level directories under root - logger.error(f"409 conflict for single-level directory '{path}'") + logger.error("409 conflict for single-level directory '%s'", path) raise e - logger.error(f"HTTP error creating directory '{path}': {e}") + logger.error("HTTP error creating directory '%s': %s", path, e) raise e except Exception as e: - logger.error(f"Unexpected error creating directory '{path}': {e}") + logger.error("Unexpected error creating directory '%s': %s", path, e) raise e async def move_resource( @@ -446,7 +467,7 @@ class WebDAVClient(BaseNextcloudClient): elif not source_path.endswith("/") and destination_path.endswith("/"): source_webdav_path += "/" - logger.debug(f"Moving resource from '{source_path}' to '{destination_path}'") + logger.debug("Moving resource from '%s' to '%s'", source_path, destination_path) headers = { "OCS-APIRequest": "true", @@ -461,17 +482,20 @@ class WebDAVClient(BaseNextcloudClient): response.raise_for_status() logger.debug( - f"Successfully moved resource from '{source_path}' to '{destination_path}'" + "Successfully moved resource from '%s' to '%s'", + source_path, + destination_path, ) return {"status_code": response.status_code} except HTTPStatusError as e: if e.response.status_code == 404: - logger.debug(f"Source resource '{source_path}' not found") + logger.debug("Source resource '%s' not found", source_path) return {"status_code": 404, "message": "Source resource not found"} elif e.response.status_code == 412: logger.debug( - f"Destination '{destination_path}' already exists and overwrite is false" + "Destination '%s' already exists and overwrite is false", + destination_path, ) return { "status_code": 412, @@ -479,7 +503,8 @@ class WebDAVClient(BaseNextcloudClient): } elif e.response.status_code == 409: logger.debug( - f"Parent directory of destination '{destination_path}' doesn't exist" + "Parent directory of destination '%s' doesn't exist", + destination_path, ) return { "status_code": 409, @@ -487,12 +512,18 @@ class WebDAVClient(BaseNextcloudClient): } else: logger.error( - f"HTTP error moving resource from '{source_path}' to '{destination_path}': {e}" + "HTTP error moving resource from '%s' to '%s': %s", + source_path, + destination_path, + e, ) raise e except Exception as e: logger.error( - f"Unexpected error moving resource from '{source_path}' to '{destination_path}': {e}" + "Unexpected error moving resource from '%s' to '%s': %s", + source_path, + destination_path, + e, ) raise e @@ -520,7 +551,9 @@ class WebDAVClient(BaseNextcloudClient): elif not source_path.endswith("/") and destination_path.endswith("/"): source_webdav_path += "/" - logger.debug(f"Copying resource from '{source_path}' to '{destination_path}'") + logger.debug( + "Copying resource from '%s' to '%s'", source_path, destination_path + ) headers = { "OCS-APIRequest": "true", @@ -535,17 +568,20 @@ class WebDAVClient(BaseNextcloudClient): response.raise_for_status() logger.debug( - f"Successfully copied resource from '{source_path}' to '{destination_path}'" + "Successfully copied resource from '%s' to '%s'", + source_path, + destination_path, ) return {"status_code": response.status_code} except HTTPStatusError as e: if e.response.status_code == 404: - logger.debug(f"Source resource '{source_path}' not found") + logger.debug("Source resource '%s' not found", source_path) return {"status_code": 404, "message": "Source resource not found"} elif e.response.status_code == 412: logger.debug( - f"Destination '{destination_path}' already exists and overwrite is false" + "Destination '%s' already exists and overwrite is false", + destination_path, ) return { "status_code": 412, @@ -553,7 +589,8 @@ class WebDAVClient(BaseNextcloudClient): } elif e.response.status_code == 409: logger.debug( - f"Parent directory of destination '{destination_path}' doesn't exist" + "Parent directory of destination '%s' doesn't exist", + destination_path, ) return { "status_code": 409, @@ -561,12 +598,18 @@ class WebDAVClient(BaseNextcloudClient): } else: logger.error( - f"HTTP error copying resource from '{source_path}' to '{destination_path}': {e}" + "HTTP error copying resource from '%s' to '%s': %s", + source_path, + destination_path, + e, ) raise e except Exception as e: logger.error( - f"Unexpected error copying resource from '{source_path}' to '{destination_path}': {e}" + "Unexpected error copying resource from '%s' to '%s': %s", + source_path, + destination_path, + e, ) raise e @@ -615,7 +658,7 @@ class WebDAVClient(BaseNextcloudClient): headers = {"Content-Type": "text/xml", "OCS-APIRequest": "true"} - logger.debug(f"Searching files in scope: {scope}") + logger.debug("Searching files in scope: %s", scope) try: response = await self._make_request( @@ -626,14 +669,14 @@ class WebDAVClient(BaseNextcloudClient): # Parse the XML response results = self._parse_search_response(response.content, scope) - logger.debug(f"Search returned {len(results)} results") + logger.debug("Search returned %s results", len(results)) return results except HTTPStatusError as e: - logger.error(f"HTTP error during search: {e}") + logger.error("HTTP error during search: %s", e) raise e except Exception as e: - logger.error(f"Unexpected error during search: {e}") + logger.error("Unexpected error during search: %s", e) raise e def _build_search_xml( @@ -1129,7 +1172,7 @@ class WebDAVClient(BaseNextcloudClient): "file_id": file_id, } - logger.debug(f"Retrieved file info for ID {file_id}: {name}") + logger.debug("Retrieved file info for ID %s: %s", file_id, name) return file_info async def get_tag_by_name(self, tag_name: str) -> dict[str, Any] | None: @@ -1449,7 +1492,7 @@ class WebDAVClient(BaseNextcloudClient): "is_directory": is_directory, } - logger.debug(f"Got file info for '{path}': id={file_info['id']}") + logger.debug("Got file info for '%s': id=%s", path, file_info["id"]) return file_info async def create_tag( @@ -1500,7 +1543,7 @@ class WebDAVClient(BaseNextcloudClient): "userAssignable": user_assignable, } - logger.info(f"Created tag '{name}' with ID {tag_info['id']}") + logger.info("Created tag '%s' with ID %s", name, tag_info["id"]) return tag_info async def get_or_create_tag( @@ -1522,7 +1565,7 @@ class WebDAVClient(BaseNextcloudClient): # First try to get existing tag existing_tag = await self.get_tag_by_name(name) if existing_tag: - logger.debug(f"Tag '{name}' already exists with ID {existing_tag['id']}") + logger.debug("Tag '%s' already exists with ID %s", name, existing_tag["id"]) return existing_tag # Create new tag @@ -1558,7 +1601,7 @@ class WebDAVClient(BaseNextcloudClient): # 201 = Created (new assignment), 409 = Conflict (already assigned) if response.status_code in (201, 409): - logger.info(f"Tagged file {file_id} with tag {tag_id}") + logger.info("Tagged file %s with tag %s", file_id, tag_id) return True response.raise_for_status() @@ -1584,7 +1627,7 @@ class WebDAVClient(BaseNextcloudClient): # 204 = No Content (removed), 404 = Not Found (wasn't assigned) if response.status_code in (204, 404): - logger.info(f"Removed tag {tag_id} from file {file_id}") + logger.info("Removed tag %s from file %s", tag_id, file_id) return True response.raise_for_status() diff --git a/nextcloud_mcp_server/config.py b/nextcloud_mcp_server/config.py index 62eca989..3877c484 100644 --- a/nextcloud_mcp_server/config.py +++ b/nextcloud_mcp_server/config.py @@ -603,8 +603,8 @@ class Settings: if self.document_chunk_size < 512: logger.warning( - f"DOCUMENT_CHUNK_SIZE is set to {self.document_chunk_size} characters, which is quite small. " - f"Smaller chunks may lose context. Consider using at least 1024 characters." + "DOCUMENT_CHUNK_SIZE is set to %s characters, which is quite small. Smaller chunks may lose context. Consider using at least 1024 characters.", + self.document_chunk_size, ) # --- ADR-022 follow-up: deployment mode is the single source of truth --- diff --git a/nextcloud_mcp_server/config_validators.py b/nextcloud_mcp_server/config_validators.py index 7434afa9..b13f5050 100644 --- a/nextcloud_mcp_server/config_validators.py +++ b/nextcloud_mcp_server/config_validators.py @@ -216,7 +216,7 @@ def detect_auth_mode(settings: Settings) -> AuthMode: ) explicit_mode = mode_map[mode_str] - logger.info(f"Using explicit deployment mode: {explicit_mode.value}") + logger.info("Using explicit deployment mode: %s", explicit_mode.value) return explicit_mode # Auto-detection (no explicit deployment_mode). @@ -247,7 +247,7 @@ def validate_configuration(settings: Settings) -> tuple[AuthMode, list[str]]: requirements = MODE_REQUIREMENTS[mode] errors: list[str] = [] - logger.debug(f"Validating configuration for mode: {mode.value}") + logger.debug("Validating configuration for mode: %s", mode.value) # Check required variables for var in requirements.required: @@ -333,8 +333,8 @@ def validate_configuration(settings: Settings) -> tuple[AuthMode, list[str]]: # (This is a runtime check, not a config check, so we just warn) if not settings.oidc_client_id or not settings.oidc_client_secret: logger.info( - f"[{mode.value}] OAuth credentials not configured. " - "Will attempt Dynamic Client Registration (DCR) at startup." + "[%s] OAuth credentials not configured. Will attempt Dynamic Client Registration (DCR) at startup.", + mode.value, ) if mode == AuthMode.MULTI_USER_BASIC: @@ -343,9 +343,8 @@ def validate_configuration(settings: Settings) -> tuple[AuthMode, list[str]]: if settings.enable_offline_access: if not settings.oidc_client_id or not settings.oidc_client_secret: logger.info( - f"[{mode.value}] OAuth credentials not configured. " - "Will attempt Dynamic Client Registration (DCR) at startup " - "(required for app password retrieval via Astrolabe)." + "[%s] OAuth credentials not configured. Will attempt Dynamic Client Registration (DCR) at startup (required for app password retrieval via Astrolabe).", + mode.value, ) # Note: Vector sync no longer requires explicit ENABLE_OFFLINE_ACCESS setting diff --git a/nextcloud_mcp_server/context.py b/nextcloud_mcp_server/context.py index e4ad6a3c..d4b4cae3 100644 --- a/nextcloud_mcp_server/context.py +++ b/nextcloud_mcp_server/context.py @@ -139,7 +139,9 @@ def _get_client_from_basic_auth(ctx: Context) -> NextcloudClient: raise ValueError("Invalid BasicAuth credentials - missing username or password") logger.debug( - f"Creating multi-user BasicAuth client for {settings.nextcloud_host} as {username}" + "Creating multi-user BasicAuth client for %s as %s", + settings.nextcloud_host, + username, ) # Create client that passes BasicAuth credentials through to Nextcloud @@ -191,7 +193,7 @@ async def _get_client_from_login_flow( username = app_data.get("username") or user_id - logger.debug(f"Creating Login Flow v2 client for {nextcloud_host} as {username}") + logger.debug("Creating Login Flow v2 client for %s as %s", nextcloud_host, username) return NextcloudClient( base_url=nextcloud_host, diff --git a/nextcloud_mcp_server/document_processors/custom_http.py b/nextcloud_mcp_server/document_processors/custom_http.py index c1f5c40d..008d3fa9 100644 --- a/nextcloud_mcp_server/document_processors/custom_http.py +++ b/nextcloud_mcp_server/document_processors/custom_http.py @@ -55,7 +55,7 @@ class CustomHTTPProcessor(DocumentProcessor): self._name = name self._supported_types = supported_types or set() - logger.info(f"Initialized CustomHTTPProcessor: {name} -> {api_url}") + logger.info("Initialized CustomHTTPProcessor: %s -> %s", name, api_url) @property def name(self) -> str: @@ -114,7 +114,9 @@ class CustomHTTPProcessor(DocumentProcessor): metadata = result.get("metadata", {}) logger.debug( - f"Custom processor '{self.name}' extracted {len(text)} characters" + "Custom processor '%s' extracted %s characters", + self.name, + len(text), ) return ProcessingResult( @@ -125,10 +127,10 @@ class CustomHTTPProcessor(DocumentProcessor): ) except httpx.HTTPError as e: - logger.error(f"Custom processor '{self.name}' HTTP error: {e}") + logger.error("Custom processor '%s' HTTP error: %s", self.name, e) raise ProcessorError(f"API call failed: {str(e)}") from e except Exception as e: - logger.error(f"Custom processor '{self.name}' failed: {e}") + logger.error("Custom processor '%s' failed: %s", self.name, e) raise ProcessorError(f"Processing failed: {str(e)}") from e async def health_check(self) -> bool: @@ -146,5 +148,7 @@ class CustomHTTPProcessor(DocumentProcessor): ) return response.status_code < 500 except Exception as e: - logger.warning(f"Custom processor '{self.name}' health check failed: {e}") + logger.warning( + "Custom processor '%s' health check failed: %s", self.name, e + ) return False diff --git a/nextcloud_mcp_server/document_processors/pymupdf.py b/nextcloud_mcp_server/document_processors/pymupdf.py index e2c29630..b15aafca 100644 --- a/nextcloud_mcp_server/document_processors/pymupdf.py +++ b/nextcloud_mcp_server/document_processors/pymupdf.py @@ -59,7 +59,8 @@ class PyMuPDFProcessor(DocumentProcessor): if self.extract_images: self.image_dir.mkdir(exist_ok=True, parents=True) logger.info( - f"Initialized PyMuPDFProcessor with image extraction to {self.image_dir}" + "Initialized PyMuPDFProcessor with image extraction to %s", + self.image_dir, ) else: logger.info("Initialized PyMuPDFProcessor without image extraction") @@ -175,9 +176,11 @@ class PyMuPDFProcessor(DocumentProcessor): await progress_callback(100, 100, "Processing complete") logger.info( - f"Successfully processed PDF {filename or ''}: " - f"{metadata['page_count']} pages, {len(md_text)} chars, " - f"{metadata.get('image_count', 0)} images" + "Successfully processed PDF %s: %s pages, %s chars, %s images", + filename or "", + metadata["page_count"], + len(md_text), + metadata.get("image_count", 0), ) return ProcessingResult( @@ -250,5 +253,5 @@ class PyMuPDFProcessor(DocumentProcessor): test_doc.close() return True except Exception as e: - logger.error(f"PyMuPDF health check failed: {e}") + logger.error("PyMuPDF health check failed: %s", e) return False diff --git a/nextcloud_mcp_server/document_processors/registry.py b/nextcloud_mcp_server/document_processors/registry.py index 71ef06b6..4bed37fe 100644 --- a/nextcloud_mcp_server/document_processors/registry.py +++ b/nextcloud_mcp_server/document_processors/registry.py @@ -41,7 +41,7 @@ class ProcessorRegistry: name = processor.name if name in self._processors: - logger.warning(f"Processor '{name}' already registered, replacing") + logger.warning("Processor '%s' already registered, replacing", name) self._processors[name] = (processor, priority) @@ -62,8 +62,10 @@ class ProcessorRegistry: self._priority_order.append(name) logger.info( - f"Registered processor: {name} " - f"(priority={priority}, supports={len(processor.supported_mime_types)} types)" + "Registered processor: %s (priority=%s, supports=%s types)", + name, + priority, + len(processor.supported_mime_types), ) def get_processor(self, name: str) -> Optional[DocumentProcessor]: @@ -93,10 +95,10 @@ class ProcessorRegistry: for name in self._priority_order: processor = self._processors[name][0] if processor.supports(content_type): - logger.debug(f"Found processor '{name}' for type '{content_type}'") + logger.debug("Found processor '%s' for type '%s'", name, content_type) return processor - logger.debug(f"No processor found for type '{content_type}'") + logger.debug("No processor found for type '%s'", content_type) return None def list_processors(self) -> list[str]: @@ -150,7 +152,7 @@ class ProcessorRegistry: f"Registered processors: {', '.join(self.list_processors())}" ) - logger.info(f"Processing with '{processor.name}' processor") + logger.info("Processing with '%s' processor", processor.name) # Process return await processor.process( diff --git a/nextcloud_mcp_server/document_processors/tesseract.py b/nextcloud_mcp_server/document_processors/tesseract.py index 890862f0..9f29ef3f 100644 --- a/nextcloud_mcp_server/document_processors/tesseract.py +++ b/nextcloud_mcp_server/document_processors/tesseract.py @@ -71,7 +71,7 @@ class TesseractProcessor(DocumentProcessor): ) self.default_lang = default_lang - logger.info(f"Initialized TesseractProcessor: lang={default_lang}") + logger.info("Initialized TesseractProcessor: lang=%s", default_lang) @property def name(self) -> str: @@ -137,8 +137,9 @@ class TesseractProcessor(DocumentProcessor): } logger.debug( - f"Tesseract OCR completed: {len(text)} chars, " - f"confidence={avg_confidence:.1f}%" + "Tesseract OCR completed: %s chars, confidence=%s%%", + len(text), + format(avg_confidence, ".1f"), ) return ProcessingResult( @@ -149,7 +150,7 @@ class TesseractProcessor(DocumentProcessor): ) except Exception as e: - logger.error(f"Tesseract processing failed: {e}") + logger.error("Tesseract processing failed: %s", e) raise ProcessorError(f"OCR failed: {str(e)}") from e async def health_check(self) -> bool: diff --git a/nextcloud_mcp_server/document_processors/unstructured.py b/nextcloud_mcp_server/document_processors/unstructured.py index 85077a63..d30899d7 100644 --- a/nextcloud_mcp_server/document_processors/unstructured.py +++ b/nextcloud_mcp_server/document_processors/unstructured.py @@ -68,9 +68,11 @@ class UnstructuredProcessor(DocumentProcessor): self.progress_interval = progress_interval logger.info( - f"Initialized UnstructuredProcessor: {api_url}, " - f"strategy={default_strategy}, languages={self.default_languages}, " - f"progress_interval={progress_interval}s" + "Initialized UnstructuredProcessor: %s, strategy=%s, languages=%s, progress_interval=%ss", + api_url, + default_strategy, + self.default_languages, + progress_interval, ) @property @@ -117,9 +119,9 @@ class UnstructuredProcessor(DocumentProcessor): total=None, # Unknown total duration # type: ignore message=message, # type: ignore ) - logger.debug(f"Progress update sent: {elapsed}s elapsed") + logger.debug("Progress update sent: %ss elapsed", elapsed) except Exception as e: - logger.warning(f"Failed to send progress update: {e}") + logger.warning("Failed to send progress update: %s", e) logger.debug("Progress poller stopped") async def _make_api_request( @@ -165,7 +167,9 @@ class UnstructuredProcessor(DocumentProcessor): data["extract_image_block_types"] = ",".join(extract_image_block_types) logger.debug( - f"Processing with Unstructured API: strategy={strategy}, languages={languages}" + "Processing with Unstructured API: strategy=%s, languages=%s", + strategy, + languages, ) try: @@ -202,8 +206,9 @@ class UnstructuredProcessor(DocumentProcessor): } logger.debug( - f"Successfully processed: {len(elements)} elements, " - f"{len(parsed_text)} characters" + "Successfully processed: %s elements, %s characters", + len(elements), + len(parsed_text), ) return ProcessingResult( @@ -214,10 +219,10 @@ class UnstructuredProcessor(DocumentProcessor): ) except httpx.HTTPError as e: - logger.error(f"Unstructured API HTTP error: {e}") + logger.error("Unstructured API HTTP error: %s", e) raise ProcessorError(f"HTTP error: {str(e)}") from e except Exception as e: - logger.error(f"Unstructured API processing failed: {e}") + logger.error("Unstructured API processing failed: %s", e) raise ProcessorError(f"Processing failed: {str(e)}") from e async def process( @@ -306,5 +311,5 @@ class UnstructuredProcessor(DocumentProcessor): response = await client.get(f"{self.api_url}/healthcheck") return response.status_code == 200 except Exception as e: - logger.warning(f"Unstructured health check failed: {e}") + logger.warning("Unstructured health check failed: %s", e) return False diff --git a/nextcloud_mcp_server/embedding/bm25_provider.py b/nextcloud_mcp_server/embedding/bm25_provider.py index 92756d7d..6f161683 100644 --- a/nextcloud_mcp_server/embedding/bm25_provider.py +++ b/nextcloud_mcp_server/embedding/bm25_provider.py @@ -30,11 +30,11 @@ class BM25SparseEmbeddingProvider: model_name: FastEmbed BM25 model name (default: Qdrant/bm25) """ self.model_name = model_name - logger.info(f"Initializing BM25 sparse embedding provider: {model_name}") + logger.info("Initializing BM25 sparse embedding provider: %s", model_name) # Initialize FastEmbed sparse embedding model self.model = SparseTextEmbedding(model_name=model_name) - logger.info(f"BM25 sparse embedding model loaded: {model_name}") + logger.info("BM25 sparse embedding model loaded: %s", model_name) def encode(self, text: str) -> dict[str, Any]: """ diff --git a/nextcloud_mcp_server/embedding/ollama_provider.py b/nextcloud_mcp_server/embedding/ollama_provider.py index 953e3498..f84b918e 100644 --- a/nextcloud_mcp_server/embedding/ollama_provider.py +++ b/nextcloud_mcp_server/embedding/ollama_provider.py @@ -33,7 +33,10 @@ class OllamaEmbeddingProvider(EmbeddingProvider): self.client = httpx.AsyncClient(verify=verify_ssl, timeout=timeout) self._dimension: int | None = None # Will be detected dynamically logger.info( - f"Initialized Ollama provider: {base_url} (model={model}, verify_ssl={verify_ssl})" + "Initialized Ollama provider: %s (model=%s, verify_ssl=%s)", + base_url, + model, + verify_ssl, ) self._check_model_is_loaded(autoload=True) @@ -82,11 +85,13 @@ class OllamaEmbeddingProvider(EmbeddingProvider): instead of relying on hardcoded values. """ if self._dimension is None: - logger.debug(f"Detecting embedding dimension for model {self.model}...") + logger.debug("Detecting embedding dimension for model %s...", self.model) test_embedding = await self.embed("test") self._dimension = len(test_embedding) logger.info( - f"Detected embedding dimension: {self._dimension} for model {self.model}" + "Detected embedding dimension: %s for model %s", + self._dimension, + self.model, ) def get_dimension(self) -> int: diff --git a/nextcloud_mcp_server/migrations.py b/nextcloud_mcp_server/migrations.py index 0964f453..15b5274c 100644 --- a/nextcloud_mcp_server/migrations.py +++ b/nextcloud_mcp_server/migrations.py @@ -52,8 +52,8 @@ def get_alembic_config(database_path: str | Path | None = None) -> Config: url = f"sqlite+aiosqlite:///{db_path}" config.set_main_option("sqlalchemy.url", url) - logger.debug(f"Alembic script location: {script_location}") - logger.debug(f"Database: {db_path}") + logger.debug("Alembic script location: %s", script_location) + logger.debug("Database: %s", db_path) return config @@ -69,7 +69,7 @@ def upgrade_database( revision: Target revision (default: "head" for latest) """ config = get_alembic_config(database_path) - logger.info(f"Upgrading database to revision: {revision}") + logger.info("Upgrading database to revision: %s", revision) command.upgrade(config, revision) logger.info("Database upgrade completed successfully") @@ -85,7 +85,7 @@ def downgrade_database( revision: Target revision (default: "-1" for previous version) """ config = get_alembic_config(database_path) - logger.warning(f"Downgrading database to revision: {revision}") + logger.warning("Downgrading database to revision: %s", revision) command.downgrade(config, revision) logger.info("Database downgrade completed successfully") @@ -107,7 +107,7 @@ def get_current_revision(database_path: str | Path | None = None) -> str | None: db_path = Path(database_path).resolve() if not db_path.exists(): - logger.debug(f"Database does not exist: {db_path}") + logger.debug("Database does not exist: %s", db_path) return None try: @@ -133,7 +133,7 @@ def get_current_revision(database_path: str | Path | None = None) -> str | None: return row[0] if row else None except Exception as e: - logger.error(f"Failed to get current revision: {e}") + logger.error("Failed to get current revision: %s", e) return None @@ -152,7 +152,7 @@ def stamp_database( revision: Revision to stamp (default: "head" for latest) """ config = get_alembic_config(database_path) - logger.info(f"Stamping database with revision: {revision}") + logger.info("Stamping database with revision: %s", revision) command.stamp(config, revision) logger.info("Database stamped successfully") @@ -181,7 +181,7 @@ def create_migration(message: str, autogenerate: bool = False) -> None: and migrations must be written manually. """ config = get_alembic_config() - logger.info(f"Creating new migration: {message}") + logger.info("Creating new migration: %s", message) if autogenerate: logger.warning( diff --git a/nextcloud_mcp_server/observability/logging_config.py b/nextcloud_mcp_server/observability/logging_config.py index 1bde1435..0822de7a 100644 --- a/nextcloud_mcp_server/observability/logging_config.py +++ b/nextcloud_mcp_server/observability/logging_config.py @@ -177,8 +177,10 @@ def setup_logging( configure_component_loggers(log_level) root_logger.info( - f"Logging configured: format={log_format}, level={log_level}, " - f"trace_context={include_trace_context}" + "Logging configured: format=%s, level=%s, trace_context=%s", + log_format, + log_level, + include_trace_context, ) diff --git a/nextcloud_mcp_server/observability/metrics.py b/nextcloud_mcp_server/observability/metrics.py index f33e8415..5dcccaae 100644 --- a/nextcloud_mcp_server/observability/metrics.py +++ b/nextcloud_mcp_server/observability/metrics.py @@ -231,14 +231,15 @@ def setup_metrics(port: int = 9090) -> None: """ try: start_http_server(port) - logger.info(f"Prometheus metrics server started on port {port}") + logger.info("Prometheus metrics server started on port %s", port) except OSError as e: if "Address already in use" in str(e): logger.warning( - f"Metrics port {port} already in use (metrics server likely already running)" + "Metrics port %s already in use (metrics server likely already running)", + port, ) else: - logger.error(f"Failed to start metrics server on port {port}: {e}") + logger.error("Failed to start metrics server on port %s: %s", port, e) raise diff --git a/nextcloud_mcp_server/observability/middleware.py b/nextcloud_mcp_server/observability/middleware.py index 2259d020..24b4e91b 100644 --- a/nextcloud_mcp_server/observability/middleware.py +++ b/nextcloud_mcp_server/observability/middleware.py @@ -127,7 +127,9 @@ class ObservabilityMiddleware(BaseHTTPMiddleware): ) logger.error( - f"Request failed: {method} {path}", + "Request failed: %s %s", + method, + path, exc_info=True, extra={ "method": method, @@ -212,7 +214,10 @@ class ObservabilityMiddleware(BaseHTTPMiddleware): # Log slow requests (>1 second) if duration > 1.0: logger.warning( - f"Slow request: {method} {endpoint} took {duration:.3f}s", + "Slow request: %s %s took %ss", + method, + endpoint, + format(duration, ".3f"), extra={ "method": method, "endpoint": endpoint, diff --git a/nextcloud_mcp_server/observability/tracing.py b/nextcloud_mcp_server/observability/tracing.py index 4904ee69..ba7e08d7 100644 --- a/nextcloud_mcp_server/observability/tracing.py +++ b/nextcloud_mcp_server/observability/tracing.py @@ -73,11 +73,12 @@ def setup_tracing( span_processor = BatchSpanProcessor(otlp_exporter) provider.add_span_processor(span_processor) logger.info( - f"OpenTelemetry tracing enabled with OTLP endpoint: {otlp_endpoint}" + "OpenTelemetry tracing enabled with OTLP endpoint: %s", otlp_endpoint ) except Exception as e: logger.warning( - f"Failed to initialize OTLP exporter: {e}. Continuing without trace export." + "Failed to initialize OTLP exporter: %s. Continuing without trace export.", + e, ) else: logger.info( @@ -93,7 +94,7 @@ def setup_tracing( # Get and store tracer _tracer = trace.get_tracer(__name__) - logger.info(f"OpenTelemetry tracing initialized for service: {service_name}") + logger.info("OpenTelemetry tracing initialized for service: %s", service_name) return _tracer diff --git a/nextcloud_mcp_server/providers/anthropic.py b/nextcloud_mcp_server/providers/anthropic.py index 947abbb2..6af957a2 100644 --- a/nextcloud_mcp_server/providers/anthropic.py +++ b/nextcloud_mcp_server/providers/anthropic.py @@ -30,7 +30,7 @@ class AnthropicProvider(Provider): self.client = AsyncAnthropic(api_key=api_key) self.model = generation_model - logger.info(f"Initialized Anthropic provider (model={self.model})") + logger.info("Initialized Anthropic provider (model=%s)", self.model) @property def supports_embeddings(self) -> bool: diff --git a/nextcloud_mcp_server/providers/bedrock.py b/nextcloud_mcp_server/providers/bedrock.py index 5878e65d..a1b9c179 100644 --- a/nextcloud_mcp_server/providers/bedrock.py +++ b/nextcloud_mcp_server/providers/bedrock.py @@ -75,8 +75,10 @@ class BedrockProvider(Provider): self.client = boto3.client("bedrock-runtime", **client_kwargs) logger.info( - f"Initialized Bedrock provider in region {region_name or 'default'} " - f"(embedding_model={embedding_model}, generation_model={generation_model})" + "Initialized Bedrock provider in region %s (embedding_model=%s, generation_model=%s)", + region_name or "default", + embedding_model, + generation_model, ) @property @@ -115,8 +117,8 @@ class BedrockProvider(Provider): # Unknown model - try Titan format as default else: logger.warning( - f"Unknown embedding model format for {self.embedding_model}, " - "using Titan format as default" + "Unknown embedding model format for %s, using Titan format as default", + self.embedding_model, ) return {"inputText": text} @@ -143,8 +145,8 @@ class BedrockProvider(Provider): # Unknown model - try Titan format as default else: logger.warning( - f"Unknown embedding response format for {self.embedding_model}, " - "trying Titan format" + "Unknown embedding response format for %s, trying Titan format", + self.embedding_model, ) return response.get("embedding", response.get("embeddings", [None])[0]) @@ -183,7 +185,7 @@ class BedrockProvider(Provider): return embedding except (BotoCoreError, ClientError) as e: - logger.error(f"Bedrock embedding error: {e}") + logger.error("Bedrock embedding error: %s", e) raise async def embed_batch(self, texts: list[str]) -> list[list[float]]: @@ -220,13 +222,14 @@ class BedrockProvider(Provider): """ if self._dimension is None and self.supports_embeddings: logger.debug( - f"Detecting embedding dimension for model {self.embedding_model}..." + "Detecting embedding dimension for model %s...", self.embedding_model ) test_embedding = await self.embed("test") self._dimension = len(test_embedding) logger.info( - f"Detected embedding dimension: {self._dimension} " - f"for model {self.embedding_model}" + "Detected embedding dimension: %s for model %s", + self._dimension, + self.embedding_model, ) def get_dimension(self) -> int: @@ -300,8 +303,8 @@ class BedrockProvider(Provider): # Unknown model - try Claude format as default else: logger.warning( - f"Unknown generation model format for {self.generation_model}, " - "using Claude format as default" + "Unknown generation model format for %s, using Claude format as default", + self.generation_model, ) return { "anthropic_version": "bedrock-2023-05-31", @@ -343,8 +346,8 @@ class BedrockProvider(Provider): # Unknown model - try common response fields else: logger.warning( - f"Unknown generation response format for {self.generation_model}, " - "trying common fields" + "Unknown generation response format for %s, trying common fields", + self.generation_model, ) # Try common response field names for field in ["text", "generation", "outputText", "completion"]: @@ -389,7 +392,7 @@ class BedrockProvider(Provider): return text except (BotoCoreError, ClientError) as e: - logger.error(f"Bedrock generation error: {e}") + logger.error("Bedrock generation error: %s", e) raise async def close(self) -> None: diff --git a/nextcloud_mcp_server/providers/ollama.py b/nextcloud_mcp_server/providers/ollama.py index 5fd37d04..1d5705c4 100644 --- a/nextcloud_mcp_server/providers/ollama.py +++ b/nextcloud_mcp_server/providers/ollama.py @@ -46,9 +46,11 @@ class OllamaProvider(Provider): self._dimension: int | None = None # Detected dynamically for embeddings logger.info( - f"Initialized Ollama provider: {base_url} " - f"(embedding_model={embedding_model}, generation_model={generation_model}, " - f"verify_ssl={verify_ssl})" + "Initialized Ollama provider: %s (embedding_model=%s, generation_model=%s, verify_ssl=%s)", + base_url, + embedding_model, + generation_model, + verify_ssl, ) # Pre-check and auto-load models @@ -140,13 +142,14 @@ class OllamaProvider(Provider): """ if self._dimension is None and self.supports_embeddings: logger.debug( - f"Detecting embedding dimension for model {self.embedding_model}..." + "Detecting embedding dimension for model %s...", self.embedding_model ) test_embedding = await self.embed("test") self._dimension = len(test_embedding) logger.info( - f"Detected embedding dimension: {self._dimension} " - f"for model {self.embedding_model}" + "Detected embedding dimension: %s for model %s", + self._dimension, + self.embedding_model, ) def get_dimension(self) -> int: diff --git a/nextcloud_mcp_server/search/algorithms.py b/nextcloud_mcp_server/search/algorithms.py index f89a8933..ed1b1ddc 100644 --- a/nextcloud_mcp_server/search/algorithms.py +++ b/nextcloud_mcp_server/search/algorithms.py @@ -120,11 +120,11 @@ async def get_indexed_doc_types(user_id: str) -> set[str]: if point.payload and point.payload.get("doc_type") } - logger.debug(f"Found indexed document types for user {user_id}: {doc_types}") + logger.debug("Found indexed document types for user %s: %s", user_id, doc_types) return doc_types except Exception as e: - logger.warning(f"Failed to query Qdrant for doc_types: {e}") + logger.warning("Failed to query Qdrant for doc_types: %s", e) return set() diff --git a/nextcloud_mcp_server/search/bm25_hybrid.py b/nextcloud_mcp_server/search/bm25_hybrid.py index a400b957..ddf3b7ba 100644 --- a/nextcloud_mcp_server/search/bm25_hybrid.py +++ b/nextcloud_mcp_server/search/bm25_hybrid.py @@ -100,9 +100,13 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm): score_threshold = kwargs.get("score_threshold", self.score_threshold) logger.info( - f"BM25 hybrid search: query='{query}', user={user_id}, " - f"limit={limit}, score_threshold={score_threshold}, doc_type={doc_type}, " - f"fusion={self.fusion_name}" + "BM25 hybrid search: query='%s', user=%s, limit=%s, score_threshold=%s, doc_type=%s, fusion=%s", + query, + user_id, + limit, + score_threshold, + doc_type, + self.fusion_name, ) # Generate dense embedding for semantic search @@ -112,7 +116,7 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm): dense_embedding = await embedding_service.embed(query) # Store for reuse by callers (e.g., viz_routes PCA visualization) self.query_embedding = dense_embedding - logger.debug(f"Generated dense embedding (dimension={len(dense_embedding)})") + logger.debug("Generated dense embedding (dimension=%s)", len(dense_embedding)) # Generate sparse embedding for BM25 keyword search with trace_operation("search.get_bm25_service"): @@ -120,8 +124,8 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm): with trace_operation("search.sparse_embedding_bm25"): sparse_embedding = await bm25_service.encode_async(query) logger.debug( - f"Generated sparse embedding " - f"({len(sparse_embedding['indices'])} non-zero terms)" + "Generated sparse embedding (%s non-zero terms)", + len(sparse_embedding["indices"]), ) # Build Qdrant filter @@ -189,15 +193,16 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm): raise logger.info( - f"Qdrant {self.fusion_name.upper()} fusion returned {len(search_response.points)} results " - f"(before deduplication)" + "Qdrant %s fusion returned %s results (before deduplication)", + self.fusion_name.upper(), + len(search_response.points), ) if search_response.points: # Log top 3 fusion scores to help with threshold tuning top_scores = [p.score for p in search_response.points[:3]] logger.debug( - f"Top 3 {self.fusion_name.upper()} fusion scores: {top_scores}" + "Top 3 %s fusion scores: %s", self.fusion_name.upper(), top_scores ) # Deduplicate by (doc_id, doc_type, chunk_start, chunk_end) @@ -233,12 +238,12 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm): if len(results) >= limit: break - logger.info(f"Returning {len(results)} unverified results after deduplication") + logger.info("Returning %s unverified results after deduplication", len(results)) if results: result_details = [ f"{r.doc_type}_{r.id} (score={r.score:.3f}, title='{r.title}')" for r in results[:5] # Show top 5 ] - logger.debug(f"Top results: {', '.join(result_details)}") + logger.debug("Top results: %s", ", ".join(result_details)) return results diff --git a/nextcloud_mcp_server/search/context.py b/nextcloud_mcp_server/search/context.py index 11ef544c..aae29405 100644 --- a/nextcloud_mcp_server/search/context.py +++ b/nextcloud_mcp_server/search/context.py @@ -67,20 +67,26 @@ async def _get_chunk_from_qdrant( excerpt = point.payload.get("excerpt") if excerpt: logger.debug( - f"Retrieved chunk from Qdrant for {doc_type} {doc_id}: " - f"{len(excerpt)} chars" + "Retrieved chunk from Qdrant for %s %s: %s chars", + doc_type, + doc_id, + len(excerpt), ) return str(excerpt) logger.debug( - f"Chunk not found in Qdrant for {doc_type} {doc_id}, " - f"chunk [{chunk_start}:{chunk_end}]. Will fall back to document fetch." + "Chunk not found in Qdrant for %s %s, chunk [%s:%s]. Will fall back to document fetch.", + doc_type, + doc_id, + chunk_start, + chunk_end, ) return None except Exception as e: logger.error( - f"Error querying Qdrant for chunk: {e}. Falling back to document fetch.", + "Error querying Qdrant for chunk: %s. Falling back to document fetch.", + e, exc_info=True, ) return None @@ -129,8 +135,11 @@ async def _get_chunk_by_index_from_qdrant( excerpt = point.payload.get("excerpt") if excerpt: logger.debug( - f"Retrieved adjacent chunk {chunk_index} from Qdrant for " - f"{doc_type} {doc_id}: {len(excerpt)} chars" + "Retrieved adjacent chunk %s from Qdrant for %s %s: %s chars", + chunk_index, + doc_type, + doc_id, + len(excerpt), ) return str(excerpt) @@ -138,8 +147,11 @@ async def _get_chunk_by_index_from_qdrant( except Exception as e: logger.debug( - f"Could not retrieve adjacent chunk {chunk_index} for " - f"{doc_type} {doc_id}: {e}" + "Could not retrieve adjacent chunk %s for %s %s: %s", + chunk_index, + doc_type, + doc_id, + e, ) return None @@ -181,19 +193,21 @@ async def _get_deck_metadata_from_qdrant( stack_id = point.payload.get("stack_id") if board_id is not None and stack_id is not None: logger.debug( - f"Retrieved deck metadata for card {card_id}: " - f"board_id={board_id}, stack_id={stack_id}" + "Retrieved deck metadata for card %s: board_id=%s, stack_id=%s", + card_id, + board_id, + stack_id, ) return {"board_id": int(board_id), "stack_id": int(stack_id)} logger.debug( - f"Could not find deck metadata in Qdrant for card {card_id} " - f"(might be legacy data without board_id/stack_id)" + "Could not find deck metadata in Qdrant for card %s (might be legacy data without board_id/stack_id)", + card_id, ) return None except Exception as e: - logger.debug(f"Error querying Qdrant for deck metadata: {e}") + logger.debug("Error querying Qdrant for deck metadata: %s", e) return None @@ -389,8 +403,9 @@ async def get_chunk_with_context( if chunk_text: logger.info( - f"Retrieved chunk from Qdrant cache for {doc_type} {doc_id} " - f"(avoids document re-fetch/re-parse)" + "Retrieved chunk from Qdrant cache for %s %s (avoids document re-fetch/re-parse)", + doc_type, + doc_id, ) # Fetch adjacent chunks for context expansion @@ -495,24 +510,30 @@ async def get_chunk_with_context( return None logger.info( - f"Falling back to document fetch for {doc_type} {doc_id} " - f"(Qdrant cache miss, possibly legacy data)" + "Falling back to document fetch for %s %s (Qdrant cache miss, possibly legacy data)", + doc_type, + doc_id, ) # Fetch full document text (notes, deck cards, news items, etc.) full_text = await _fetch_document_text(nc_client, doc_id, doc_type, user_id) if full_text is None: logger.warning( - f"Could not fetch document text for {doc_type} {doc_id}, " - "skipping context expansion" + "Could not fetch document text for %s %s, skipping context expansion", + doc_type, + doc_id, ) return None # Validate offsets if chunk_start < 0 or chunk_end > len(full_text) or chunk_start >= chunk_end: logger.warning( - f"Invalid chunk offsets for {doc_type} {doc_id}: " - f"start={chunk_start}, end={chunk_end}, doc_len={len(full_text)}" + "Invalid chunk offsets for %s %s: start=%s, end=%s, doc_len=%s", + doc_type, + doc_id, + chunk_start, + chunk_end, + len(full_text), ) return None @@ -650,13 +671,18 @@ async def _fetch_document_text( board_id=board_id, stack_id=stack_id, card_id=int(doc_id) ) logger.debug( - f"Retrieved deck card {doc_id} using metadata " - f"(board_id={board_id}, stack_id={stack_id})" + "Retrieved deck card %s using metadata (board_id=%s, stack_id=%s)", + doc_id, + board_id, + stack_id, ) except Exception as e: logger.warning( - f"Failed to fetch card with metadata (board_id={board_id}, " - f"stack_id={stack_id}, card_id={doc_id}): {e}, falling back to iteration" + "Failed to fetch card with metadata (board_id=%s, stack_id=%s, card_id=%s): %s, falling back to iteration", + board_id, + stack_id, + doc_id, + e, ) # Fallback: Iterate through all boards/stacks (for legacy data or if fast path failed) @@ -671,7 +697,9 @@ async def _fetch_document_text( # Skip deleted boards (soft delete: deletedAt > 0) if board.deletedAt > 0: logger.debug( - f"Skipping deleted board {board.id} while searching for card {doc_id}" + "Skipping deleted board %s while searching for card %s", + board.id, + doc_id, ) continue @@ -686,13 +714,15 @@ async def _fetch_document_text( card = c card_found = True logger.debug( - f"Found deck card {doc_id} in board {board.id}, " - f"stack {stack.id} (fallback iteration)" + "Found deck card %s in board %s, stack %s (fallback iteration)", + doc_id, + board.id, + stack.id, ) break if not card_found: - logger.warning(f"Deck card {doc_id} not found in any board/stack") + logger.warning("Deck card %s not found in any board/stack", doc_id) return None # Type narrowing: card is set if we reach here @@ -705,10 +735,12 @@ async def _fetch_document_text( content_parts.append(card.description) return "\n\n".join(content_parts) else: - logger.warning(f"Unsupported doc_type for context expansion: {doc_type}") + logger.warning("Unsupported doc_type for context expansion: %s", doc_type) return None except Exception as e: - logger.error(f"Error fetching document {doc_type} {doc_id}: {e}", exc_info=True) + logger.error( + "Error fetching document %s %s: %s", doc_type, doc_id, e, exc_info=True + ) return None diff --git a/nextcloud_mcp_server/search/pdf_highlighter.py b/nextcloud_mcp_server/search/pdf_highlighter.py index 18ebafcd..434e0a5e 100644 --- a/nextcloud_mcp_server/search/pdf_highlighter.py +++ b/nextcloud_mcp_server/search/pdf_highlighter.py @@ -118,7 +118,7 @@ class PDFHighlighter: try: shutil.rmtree(temp_dir) except Exception as e: - logger.warning(f"Failed to clean up temp directory {temp_dir}: {e}") + logger.warning("Failed to clean up temp directory %s: %s", temp_dir, e) return full_text, page_boundaries @@ -202,7 +202,7 @@ class PDFHighlighter: try: page_words = page.get_text("words") except Exception as e: - logger.error(f"Failed to extract words from page: {e}") + logger.error("Failed to extract words from page: %s", e) return 0 if not page_words: @@ -225,8 +225,12 @@ class PDFHighlighter: ) ] logger.debug( - f"Filtered to {len(page_words)} words in region " - f"({rx0:.0f}, {ry0:.0f}, {rx1:.0f}, {ry1:.0f})" + "Filtered to %s words in region (%s, %s, %s, %s)", + len(page_words), + format(rx0, ".0f"), + format(ry0, ".0f"), + format(rx1, ".0f"), + format(ry1, ".0f"), ) if not page_words: @@ -286,17 +290,19 @@ class PDFHighlighter: if len(current_matches) >= len(chunk_words) * 0.5: matches = current_matches logger.debug( - f"Found match at position {start_pos}: " - f"{len(matches)}/{len(chunk_words)} words" + "Found match at position %s: %s/%s words", + start_pos, + len(matches), + len(chunk_words), ) break # Take FIRST match, not best/longest if not matches: - logger.debug(f"No word matches found (chunk has {len(chunk_words)} words)") + logger.debug("No word matches found (chunk has %s words)", len(chunk_words)) return 0 logger.debug( - f"Matched {len(matches)} words out of {len(chunk_words)} chunk words" + "Matched %s words out of %s chunk words", len(matches), len(chunk_words) ) # Build rectangles from matched words @@ -324,8 +330,9 @@ class PDFHighlighter: # A chunk should be mostly contiguous text if large_gaps > len(matches) * 0.3: # More than 30% have gaps logger.debug( - f"Rejecting scattered matches: {large_gaps} large gaps " - f"out of {len(matches)} matches" + "Rejecting scattered matches: %s large gaps out of %s matches", + large_gaps, + len(matches), ) return 0 @@ -512,12 +519,12 @@ class PDFHighlighter: rects = page.search_for(phrase.strip()) if rects: anchor_rect = rects[0] # Use first match - logger.debug(f"Found chunk anchor using phrase: '{phrase[:30]}...'") + logger.debug("Found chunk anchor using phrase: '%s...'", phrase[:30]) break if not anchor_rect: page_num = page.number + 1 if page.number is not None else "unknown" - logger.warning(f"Could not find chunk text on page {page_num}") + logger.warning("Could not find chunk text on page %s", page_num) return 0 # Calculate chunk height based on character count @@ -558,8 +565,10 @@ class PDFHighlighter: fill_shape.commit() logger.debug( - f"Added bounding box at y={chunk_rect.y0:.0f}-{chunk_rect.y1:.0f} " - f"(estimated {estimated_lines:.1f} lines)" + "Added bounding box at y=%s-%s (estimated %s lines)", + format(chunk_rect.y0, ".0f"), + format(chunk_rect.y1, ".0f"), + format(estimated_lines, ".1f"), ) return 1 @@ -626,7 +635,9 @@ class PDFHighlighter: # Log if page differs from stored metadata if stored_page_number and stored_page_number != page_num: logger.info( - f"Chunk primarily on page {page_num}, metadata says {stored_page_number}" + "Chunk primarily on page %s, metadata says %s", + page_num, + stored_page_number, ) # Extract page text @@ -644,8 +655,12 @@ class PDFHighlighter: page_text_length = page_end - page_start logger.debug( - f"Extracted {len(chunk_text)} chars on page {page_num} " - f"(offsets {page_relative_start}-{page_relative_end} of {page_text_length})" + "Extracted %s chars on page %s (offsets %s-%s of %s)", + len(chunk_text), + page_num, + page_relative_start, + page_relative_end, + page_text_length, ) # Get page and add highlights @@ -672,13 +687,15 @@ class PDFHighlighter: doc.close() logger.info( - f"Generated {len(png_bytes):,} byte image with {highlight_count} highlights" + "Generated %s byte image with %s highlights", + format(len(png_bytes), ","), + highlight_count, ) return (png_bytes, page_num, highlight_count) except Exception as e: - logger.error(f"Error highlighting chunk: {e}", exc_info=True) + logger.error("Error highlighting chunk: %s", e, exc_info=True) return None finally: @@ -688,7 +705,9 @@ class PDFHighlighter: shutil.rmtree(temp_pdf_path.parent) except Exception as e: logger.warning( - f"Failed to delete temp directory {temp_pdf_path.parent}: {e}" + "Failed to delete temp directory %s: %s", + temp_pdf_path.parent, + e, ) @staticmethod @@ -791,11 +810,11 @@ class PDFHighlighter: ) results[chunk_index] = ([normalized], page_num) - logger.info(f"Computed bboxes for {len(results)}/{len(chunks)} chunks") + logger.info("Computed bboxes for %s/%s chunks", len(results), len(chunks)) return results except Exception as e: - logger.error(f"Error computing chunk bboxes: {e}", exc_info=True) + logger.error("Error computing chunk bboxes: %s", e, exc_info=True) return results finally: @@ -805,7 +824,7 @@ class PDFHighlighter: try: shutil.rmtree(temp_pdf_path.parent) except Exception as e: - logger.warning(f"Failed to clean up temp dir: {e}") + logger.warning("Failed to clean up temp dir: %s", e) @staticmethod def highlight_chunks_batch( @@ -853,8 +872,9 @@ class PDFHighlighter: doc = pymupdf.open(temp_pdf_path) logger.debug( - f"Batch highlighting: {len(chunks)} chunks, " - f"{len(page_boundaries)} pages" + "Batch highlighting: %s chunks, %s pages", + len(chunks), + len(page_boundaries), ) # Group chunks by their target page for efficient rendering @@ -873,7 +893,7 @@ class PDFHighlighter: ) if not chunk_page_info: - logger.warning(f"Chunk {chunk_index}: not found on any page") + logger.warning("Chunk %s: not found on any page", chunk_index) continue page_num = chunk_page_info["page_num"] @@ -881,8 +901,10 @@ class PDFHighlighter: # Log if page differs from stored metadata if stored_page_num and stored_page_num != page_num: logger.debug( - f"Chunk {chunk_index}: found on page {page_num}, " - f"metadata says {stored_page_num}" + "Chunk %s: found on page %s, metadata says %s", + chunk_index, + page_num, + stored_page_num, ) # Extract page-relative portion of chunk text @@ -905,7 +927,7 @@ class PDFHighlighter: ) logger.debug( - f"Chunks distributed across {len(chunks_by_page)} unique pages" + "Chunks distributed across %s unique pages", len(chunks_by_page) ) # OPTIMIZATION: Render each page ONCE, then draw highlights using PIL @@ -929,7 +951,9 @@ class PDFHighlighter: page_rect = page.rect logger.debug( - f"Page {page_num}: rendered once, processing {len(page_chunks)} chunks" + "Page %s: rendered once, processing %s chunks", + page_num, + len(page_chunks), ) for ( @@ -949,7 +973,7 @@ class PDFHighlighter: ) if bbox is None: - logger.warning(f"Chunk {chunk_index}: could not find bbox") + logger.warning("Chunk %s: could not find bbox", chunk_index) continue # Copy base image for this chunk @@ -985,24 +1009,27 @@ class PDFHighlighter: results[chunk_index] = (png_bytes, page_num, 1) logger.debug( - f"Chunk {chunk_index}: {len(png_bytes):,} bytes, " - f"page {page_num}, bbox {pil_bbox}" + "Chunk %s: %s bytes, page %s, bbox %s", + chunk_index, + format(len(png_bytes), ","), + page_num, + pil_bbox, ) except Exception as e: - logger.error(f"Chunk {chunk_index}: error - {e}") + logger.error("Chunk %s: error - %s", chunk_index, e) continue doc.close() logger.info( - f"Batch highlighted {len(results)}/{len(chunks)} chunks successfully" + "Batch highlighted %s/%s chunks successfully", len(results), len(chunks) ) return results except Exception as e: - logger.error(f"Error in batch highlighting: {e}", exc_info=True) + logger.error("Error in batch highlighting: %s", e, exc_info=True) return results finally: @@ -1011,4 +1038,4 @@ class PDFHighlighter: try: shutil.rmtree(temp_pdf_path.parent) except Exception as e: - logger.warning(f"Failed to clean up temp dir: {e}") + logger.warning("Failed to clean up temp dir: %s", e) diff --git a/nextcloud_mcp_server/search/semantic.py b/nextcloud_mcp_server/search/semantic.py index cb15eade..54465c03 100644 --- a/nextcloud_mcp_server/search/semantic.py +++ b/nextcloud_mcp_server/search/semantic.py @@ -77,8 +77,12 @@ class SemanticSearchAlgorithm(SearchAlgorithm): score_threshold = kwargs.get("score_threshold", self.score_threshold) logger.info( - f"Semantic search: query='{query}', user={user_id}, " - f"limit={limit}, score_threshold={score_threshold}, doc_type={doc_type}" + "Semantic search: query='%s', user=%s, limit=%s, score_threshold=%s, doc_type=%s", + query, + user_id, + limit, + score_threshold, + doc_type, ) # Generate embedding for query @@ -87,7 +91,7 @@ class SemanticSearchAlgorithm(SearchAlgorithm): # Store for reuse by callers (e.g., viz_routes PCA visualization) self.query_embedding = query_embedding logger.debug( - f"Generated embedding for query (dimension={len(query_embedding)})" + "Generated embedding for query (dimension=%s)", len(query_embedding) ) # Build Qdrant filter @@ -127,14 +131,14 @@ class SemanticSearchAlgorithm(SearchAlgorithm): raise logger.info( - f"Qdrant returned {len(search_response.points)} results " - f"(before deduplication)" + "Qdrant returned %s results (before deduplication)", + len(search_response.points), ) if search_response.points: # Log top 3 scores to help with threshold tuning top_scores = [p.score for p in search_response.points[:3]] - logger.debug(f"Top 3 similarity scores: {top_scores}") + logger.debug("Top 3 similarity scores: %s", top_scores) # Deduplicate by (doc_id, doc_type, chunk_start, chunk_end) # This allows multiple chunks from same doc, but removes duplicate chunks @@ -155,12 +159,12 @@ class SemanticSearchAlgorithm(SearchAlgorithm): if len(results) >= limit: break - logger.info(f"Returning {len(results)} unverified results after deduplication") + logger.info("Returning %s unverified results after deduplication", len(results)) if results: result_details = [ f"{r.doc_type}_{r.id} (score={r.score:.3f}, title='{r.title}')" for r in results[:5] # Show top 5 ] - logger.debug(f"Top results: {', '.join(result_details)}") + logger.debug("Top results: %s", ", ".join(result_details)) return results diff --git a/nextcloud_mcp_server/server/auth_tools.py b/nextcloud_mcp_server/server/auth_tools.py index 305b5950..262e254f 100644 --- a/nextcloud_mcp_server/server/auth_tools.py +++ b/nextcloud_mcp_server/server/auth_tools.py @@ -116,7 +116,7 @@ def register_auth_tools(mcp: FastMCP) -> None: ) init_response = await flow_client.initiate() except Exception as e: - logger.error(f"Failed to initiate Login Flow v2: {e}") + logger.error("Failed to initiate Login Flow v2: %s", e) return ProvisionAccessResponse( status="error", message=f"Failed to start login flow: {e}", @@ -227,7 +227,7 @@ def register_auth_tools(mcp: FastMCP) -> None: try: session = await storage.get_login_flow_session(user_id) except Exception as e: - logger.error(f"Failed to check login flow session for {user_id}: {e}") + logger.error("Failed to check login flow session for %s: %s", user_id, e) return ProvisionStatusResponse( status="error", message=f"Failed to check login flow session: {e}", @@ -264,7 +264,7 @@ def register_auth_tools(mcp: FastMCP) -> None: poll_token=session["poll_token"], ) except Exception as e: - logger.error(f"Failed to poll Login Flow v2: {e}") + logger.error("Failed to poll Login Flow v2: %s", e) return ProvisionStatusResponse( status="error", message=f"Failed to check login status: {e}", @@ -434,7 +434,7 @@ def register_auth_tools(mcp: FastMCP) -> None: ) init_response = await flow_client.initiate() except Exception as e: - logger.error(f"Failed to initiate Login Flow v2 for scope update: {e}") + logger.error("Failed to initiate Login Flow v2 for scope update: %s", e) return UpdateScopesResponse( status="error", message=f"Failed to start re-provisioning flow: {e}", diff --git a/nextcloud_mcp_server/server/calendar.py b/nextcloud_mcp_server/server/calendar.py index b955ebb9..852c3b90 100644 --- a/nextcloud_mcp_server/server/calendar.py +++ b/nextcloud_mcp_server/server/calendar.py @@ -215,7 +215,7 @@ def configure_calendar_tools(mcp: FastMCP): try: start_datetime = dt.datetime.fromisoformat(start_date) except ValueError: - logger.warning(f"Invalid start_date format: {start_date}") + logger.warning("Invalid start_date format: %s", start_date) if end_date: try: @@ -228,7 +228,7 @@ def configure_calendar_tools(mcp: FastMCP): try: end_datetime = dt.datetime.fromisoformat(end_date) except ValueError: - logger.warning(f"Invalid end_date format: {end_date}") + logger.warning("Invalid end_date format: %s", end_date) # Build filters dictionary filters = {} @@ -519,7 +519,7 @@ def configure_calendar_tools(mcp: FastMCP): all_events.extend(cal_events) except Exception as e: logger.warning( - f"Error getting events from calendar {calendar['name']}: {e}" + "Error getting events from calendar %s: %s", calendar["name"], e ) continue @@ -593,7 +593,7 @@ def configure_calendar_tools(mcp: FastMCP): try: start_datetime = dt.datetime.strptime(date_range_start, "%Y-%m-%d") except ValueError: - logger.warning(f"Invalid date_range_start format: {date_range_start}") + logger.warning("Invalid date_range_start format: %s", date_range_start) if date_range_end: try: @@ -601,7 +601,7 @@ def configure_calendar_tools(mcp: FastMCP): hour=23, minute=59, second=59 ) except ValueError: - logger.warning(f"Invalid date_range_end format: {date_range_end}") + logger.warning("Invalid date_range_end format: %s", date_range_end) # Build constraints constraints = { @@ -686,7 +686,7 @@ def configure_calendar_tools(mcp: FastMCP): try: start_datetime = dt.datetime.strptime(start_date, "%Y-%m-%d") except ValueError: - logger.warning(f"Invalid start_date format: {start_date}") + logger.warning("Invalid start_date format: %s", start_date) if end_date: try: @@ -694,7 +694,7 @@ def configure_calendar_tools(mcp: FastMCP): hour=23, minute=59, second=59 ) except ValueError: - logger.warning(f"Invalid end_date format: {end_date}") + logger.warning("Invalid end_date format: %s", end_date) # Build filter criteria filter_criteria = {} diff --git a/nextcloud_mcp_server/server/oauth_tools.py b/nextcloud_mcp_server/server/oauth_tools.py index 48cfae62..3d18e5e3 100644 --- a/nextcloud_mcp_server/server/oauth_tools.py +++ b/nextcloud_mcp_server/server/oauth_tools.py @@ -266,7 +266,7 @@ async def _provision_nextcloud_access(ctx: Context, user_id: str) -> Provisionin ) except Exception as e: - logger.error(f"Failed to initiate provisioning: {e}") + logger.error("Failed to initiate provisioning: %s", e) return ProvisioningResult( success=False, message=f"Failed to initiate provisioning: {str(e)}", @@ -337,7 +337,7 @@ async def _revoke_nextcloud_access(ctx: Context, user_id: str) -> RevocationResu ) except Exception as e: - logger.error(f"Failed to revoke access: {e}") + logger.error("Failed to revoke access: %s", e) return RevocationResult( success=False, message=f"Failed to revoke access: {str(e)}", @@ -542,7 +542,7 @@ async def _check_logged_in(ctx: Context, user_id: str) -> str: return "Login cancelled by user." except Exception as e: - logger.error(f"Failed to check login status: {e}") + logger.error("Failed to check login status: %s", e) return f"Error checking login status: {str(e)}" diff --git a/nextcloud_mcp_server/utils/document_parser.py b/nextcloud_mcp_server/utils/document_parser.py index 887e96c7..3c0d3aed 100644 --- a/nextcloud_mcp_server/utils/document_parser.py +++ b/nextcloud_mcp_server/utils/document_parser.py @@ -72,7 +72,7 @@ async def parse_document( registry = get_registry() - logger.debug(f"Parsing document of type '{content_type}'") + logger.debug("Parsing document of type '%s'", content_type) try: # Process using registry (auto-selects processor based on MIME type) @@ -83,12 +83,14 @@ async def parse_document( progress_callback=progress_callback, ) - logger.info(f"Successfully parsed document with '{result.processor}' processor") + logger.info( + "Successfully parsed document with '%s' processor", result.processor + ) return result.text, result.metadata except ProcessorError as e: - logger.error(f"Document processing failed: {e}") + logger.error("Document processing failed: %s", e) # Fallback to base64 with error metadata parsed_text = f"Document could not be parsed. Base64 content: {base64.b64encode(content).decode('ascii')[:200]}..." metadata = { diff --git a/nextcloud_mcp_server/vector/document_chunker.py b/nextcloud_mcp_server/vector/document_chunker.py index 2aa80266..6c4fb168 100644 --- a/nextcloud_mcp_server/vector/document_chunker.py +++ b/nextcloud_mcp_server/vector/document_chunker.py @@ -91,7 +91,9 @@ class DocumentChunker: ] logger.debug( - f"Chunked document into {len(chunks)} chunks " - f"(chunk_size={self.chunk_size}, overlap={self.overlap})" + "Chunked document into %s chunks (chunk_size=%s, overlap=%s)", + len(chunks), + self.chunk_size, + self.overlap, ) return chunks diff --git a/nextcloud_mcp_server/vector/html_processor.py b/nextcloud_mcp_server/vector/html_processor.py index 727f0c68..f930f628 100644 --- a/nextcloud_mcp_server/vector/html_processor.py +++ b/nextcloud_mcp_server/vector/html_processor.py @@ -42,7 +42,7 @@ def html_to_markdown(html_content: str | None) -> str: ) return markdown.strip() except Exception as e: - logger.warning(f"Failed to convert HTML to Markdown: {e}") + logger.warning("Failed to convert HTML to Markdown: %s", e) # Fallback: strip all HTML tags as a last resort text = re.sub(r"<[^>]+>", " ", html_content) diff --git a/nextcloud_mcp_server/vector/oauth_sync.py b/nextcloud_mcp_server/vector/oauth_sync.py index 7883c4db..73abcd58 100644 --- a/nextcloud_mcp_server/vector/oauth_sync.py +++ b/nextcloud_mcp_server/vector/oauth_sync.py @@ -104,7 +104,7 @@ async def get_user_client_basic_auth( f"User must configure background sync in Astrolabe personal settings." ) - logger.info(f"Using app password for background sync: {user_id}") + logger.info("Using app password for background sync: %s", user_id) return NextcloudClient( base_url=nextcloud_host, username=user_id, @@ -141,7 +141,7 @@ async def get_user_client_oauth( f"User must complete the OAuth provisioning flow." ) - logger.info(f"Using OAuth refresh token for background sync: {user_id}") + logger.info("Using OAuth refresh token for background sync: %s", user_id) return NextcloudClient.from_token( base_url=nextcloud_host, token=token, @@ -208,7 +208,7 @@ async def user_scanner_task( task_status: Status object for signaling task readiness """ mode_label = "BasicAuth" if use_basic_auth else "OAuth" - logger.info(f"[{mode_label}] Scanner started for user: {user_id}") + logger.info("[%s] Scanner started for user: %s", mode_label, user_id) settings = get_settings() max_consecutive_errors = 5 @@ -221,12 +221,14 @@ async def user_scanner_task( ) try: await nc_client.capabilities() # Lightweight OCS call to validate creds - logger.info(f"[{mode_label}] Credentials validated for {user_id}") + logger.info("[%s] Credentials validated for %s", mode_label, user_id) except HTTPStatusError as e: if e.response.status_code in (401, 403): logger.warning( - f"[{mode_label}] Credential validation failed for {user_id} " - f"(HTTP {e.response.status_code}), not starting scan loop" + "[%s] Credential validation failed for %s (HTTP %s), not starting scan loop", + mode_label, + user_id, + e.response.status_code, ) return raise @@ -234,13 +236,15 @@ async def user_scanner_task( await nc_client.close() except NotProvisionedError: logger.warning( - f"[{mode_label}] User {user_id} not provisioned, not starting scan loop" + "[%s] User %s not provisioned, not starting scan loop", mode_label, user_id ) return except Exception as e: logger.warning( - f"[{mode_label}] Pre-validation failed for {user_id}: {e}. " - f"Proceeding to scan loop (has its own error handling)." + "[%s] Pre-validation failed for %s: %s. Proceeding to scan loop (has its own error handling).", + mode_label, + user_id, + e, ) consecutive_errors = 0 @@ -264,7 +268,9 @@ async def user_scanner_task( except NotProvisionedError: logger.warning( - f"[{mode_label}] User {user_id} no longer provisioned, stopping scanner" + "[%s] User %s no longer provisioned, stopping scanner", + mode_label, + user_id, ) break @@ -272,16 +278,19 @@ async def user_scanner_task( status_code = e.response.status_code if status_code in (401, 403): logger.warning( - f"[{mode_label}] Scanner auth failed for {user_id} " - f"(HTTP {status_code}), stopping scanner. " - f"User may need to re-provision credentials." + "[%s] Scanner auth failed for %s (HTTP %s), stopping scanner. User may need to re-provision credentials.", + mode_label, + user_id, + status_code, ) break elif status_code == 429: retry_after = min(int(e.response.headers.get("Retry-After", "60")), 300) logger.warning( - f"[{mode_label}] Scanner rate-limited for {user_id}, " - f"backing off {retry_after}s" + "[%s] Scanner rate-limited for %s, backing off %ss", + mode_label, + user_id, + retry_after, ) try: with anyio.move_on_after(retry_after): @@ -294,16 +303,24 @@ async def user_scanner_task( else: consecutive_errors += 1 logger.error( - f"[{mode_label}] Scanner HTTP error for {user_id}: {e} " - f"({consecutive_errors}/{max_consecutive_errors})", + "[%s] Scanner HTTP error for %s: %s (%s/%s)", + mode_label, + user_id, + e, + consecutive_errors, + max_consecutive_errors, exc_info=True, ) except Exception as e: consecutive_errors += 1 logger.error( - f"[{mode_label}] Scanner error for {user_id}: {e} " - f"({consecutive_errors}/{max_consecutive_errors})", + "[%s] Scanner error for %s: %s (%s/%s)", + mode_label, + user_id, + e, + consecutive_errors, + max_consecutive_errors, exc_info=True, ) @@ -313,8 +330,10 @@ async def user_scanner_task( if consecutive_errors >= max_consecutive_errors: logger.error( - f"[{mode_label}] Scanner for {user_id} hit {max_consecutive_errors} " - f"consecutive errors, stopping scanner" + "[%s] Scanner for %s hit %s consecutive errors, stopping scanner", + mode_label, + user_id, + max_consecutive_errors, ) break @@ -325,7 +344,7 @@ async def user_scanner_task( except anyio.get_cancelled_exc_class(): break - logger.info(f"[{mode_label}] Scanner stopped for user: {user_id}") + logger.info("[%s] Scanner stopped for user: %s", mode_label, user_id) async def multi_user_processor_task( @@ -352,7 +371,7 @@ async def multi_user_processor_task( task_status: Status object for signaling task readiness """ mode_label = "BasicAuth" if use_basic_auth else "OAuth" - logger.info(f"[{mode_label}] Processor {worker_id} started") + logger.info("[%s] Processor %s started", mode_label, worker_id) task_status.started() while not shutdown_event.is_set(): @@ -378,34 +397,47 @@ async def multi_user_processor_task( continue except anyio.EndOfStream: - logger.info(f"[{mode_label}] Processor {worker_id}: Stream closed, exiting") + logger.info( + "[%s] Processor %s: Stream closed, exiting", mode_label, worker_id + ) break except NotProvisionedError: if doc_task: logger.warning( - f"[{mode_label}] User {doc_task.user_id} not provisioned, " - f"skipping {doc_task.doc_type}_{doc_task.doc_id}" + "[%s] User %s not provisioned, skipping %s_%s", + mode_label, + doc_task.user_id, + doc_task.doc_type, + doc_task.doc_id, ) continue except Exception as e: if doc_task: logger.error( - f"[{mode_label}] Processor {worker_id} error processing " - f"{doc_task.doc_type}_{doc_task.doc_id}: {e}", + "[%s] Processor %s error processing %s_%s: %s", + mode_label, + worker_id, + doc_task.doc_type, + doc_task.doc_id, + e, exc_info=True, ) else: logger.error( - f"[{mode_label}] Processor {worker_id} error: {e}", exc_info=True + "[%s] Processor %s error: %s", + mode_label, + worker_id, + e, + exc_info=True, ) finally: if nc_client: await nc_client.close() - logger.info(f"[{mode_label}] Processor {worker_id} stopped") + logger.info("[%s] Processor %s stopped", mode_label, worker_id) # Backward compatibility alias @@ -482,7 +514,7 @@ async def user_manager_task( mode_label = "BasicAuth" if use_basic_auth else "OAuth" logger.info( - f"[{mode_label}] User manager started (poll interval: {poll_interval}s)" + "[%s] User manager started (poll interval: %ss)", mode_label, poll_interval ) task_status.started() @@ -503,7 +535,9 @@ async def user_manager_task( new_users = provisioned_users - active_users for user_id in new_users: logger.info( - f"[{mode_label}] Starting scanner for newly provisioned user: {user_id}" + "[%s] Starting scanner for newly provisioned user: %s", + mode_label, + user_id, ) cancel_scope = anyio.CancelScope() user_states[user_id] = UserSyncState( @@ -529,7 +563,7 @@ async def user_manager_task( revoked_users = active_users - provisioned_users for user_id in revoked_users: logger.info( - f"[{mode_label}] Stopping scanner for revoked user: {user_id}" + "[%s] Stopping scanner for revoked user: %s", mode_label, user_id ) state = user_states.get(user_id) if state: @@ -537,12 +571,16 @@ async def user_manager_task( # Note: state will be removed by _run_user_scanner_with_scope on exit if new_users: - logger.info(f"[{mode_label}] Started {len(new_users)} new scanner(s)") + logger.info( + "[%s] Started %s new scanner(s)", mode_label, len(new_users) + ) if revoked_users: - logger.info(f"[{mode_label}] Stopped {len(revoked_users)} scanner(s)") + logger.info( + "[%s] Stopped %s scanner(s)", mode_label, len(revoked_users) + ) except Exception as e: - logger.error(f"[{mode_label}] User manager error: {e}", exc_info=True) + logger.error("[%s] User manager error: %s", mode_label, e, exc_info=True) # Sleep until next poll try: @@ -553,9 +591,11 @@ async def user_manager_task( # Cancel all remaining scanners on shutdown logger.info( - f"[{mode_label}] User manager shutting down, cancelling {len(user_states)} scanner(s)" + "[%s] User manager shutting down, cancelling %s scanner(s)", + mode_label, + len(user_states), ) for state in list(user_states.values()): state.cancel_scope.cancel() - logger.info(f"[{mode_label}] User manager stopped") + logger.info("[%s] User manager stopped", mode_label) diff --git a/nextcloud_mcp_server/vector/pca.py b/nextcloud_mcp_server/vector/pca.py index 7f6b4022..3986ac89 100644 --- a/nextcloud_mcp_server/vector/pca.py +++ b/nextcloud_mcp_server/vector/pca.py @@ -92,9 +92,11 @@ class PCA: self.explained_variance_ratio_ = np.zeros(self.n_components) logger.debug( - f"PCA fit: {n_samples} samples, {n_features} features → " - f"{self.n_components} components, " - f"explained variance: {self.explained_variance_ratio_}" + "PCA fit: %s samples, %s features → %s components, explained variance: %s", + n_samples, + n_features, + self.n_components, + self.explained_variance_ratio_, ) return self diff --git a/nextcloud_mcp_server/vector/placeholder.py b/nextcloud_mcp_server/vector/placeholder.py index 4950c2fc..3b23bdd1 100644 --- a/nextcloud_mcp_server/vector/placeholder.py +++ b/nextcloud_mcp_server/vector/placeholder.py @@ -122,13 +122,19 @@ async def write_placeholder_point( ) logger.debug( - f"Wrote placeholder for {doc_type}_{doc_id} (user={user_id}, " - f"modified_at={modified_at})" + "Wrote placeholder for %s_%s (user=%s, modified_at=%s)", + doc_type, + doc_id, + user_id, + modified_at, ) except Exception as e: logger.error( - f"Failed to write placeholder for {doc_type}_{doc_id}: {e}", + "Failed to write placeholder for %s_%s: %s", + doc_type, + doc_id, + e, exc_info=True, ) raise @@ -180,7 +186,9 @@ async def query_document_metadata( return None except Exception as e: - logger.warning(f"Error querying document metadata for {doc_type}_{doc_id}: {e}") + logger.warning( + "Error querying document metadata for %s_%s: %s", doc_type, doc_id, e + ) return None @@ -219,11 +227,16 @@ async def delete_placeholder_point( ), ) - logger.debug(f"Deleted placeholder for {doc_type}_{doc_id} (user={user_id})") + logger.debug( + "Deleted placeholder for %s_%s (user=%s)", doc_type, doc_id, user_id + ) except Exception as e: logger.error( - f"Failed to delete placeholder for {doc_type}_{doc_id}: {e}", + "Failed to delete placeholder for %s_%s: %s", + doc_type, + doc_id, + e, exc_info=True, ) raise @@ -271,13 +284,16 @@ async def update_placeholder_status( ) logger.debug( - f"Updated placeholder status for {doc_type}_{doc_id} to '{status}' " - f"(user={user_id})" + "Updated placeholder status for %s_%s to '%s' (user=%s)", + doc_type, + doc_id, + status, + user_id, ) except Exception as e: logger.warning( - f"Failed to update placeholder status for {doc_type}_{doc_id}: {e}" + "Failed to update placeholder status for %s_%s: %s", doc_type, doc_id, e ) # Don't raise - status updates are non-critical diff --git a/nextcloud_mcp_server/vector/processor.py b/nextcloud_mcp_server/vector/processor.py index f3acc8ff..6f88793e 100644 --- a/nextcloud_mcp_server/vector/processor.py +++ b/nextcloud_mcp_server/vector/processor.py @@ -100,7 +100,7 @@ async def processor_task( user_id: User being processed task_status: Status object for signaling task readiness """ - logger.info(f"Processor {worker_id} started") + logger.info("Processor %s started", worker_id) # Signal that the task has started and is ready task_status.started() @@ -130,18 +130,21 @@ async def processor_task( except anyio.EndOfStream: # Scanner finished and closed stream, exit gracefully - logger.info(f"Processor {worker_id}: Scanner finished, exiting") + logger.info("Processor %s: Scanner finished, exiting", worker_id) break except Exception as e: logger.error( - f"Processor {worker_id} error processing " - f"{doc_task.doc_type}_{doc_task.doc_id}: {e}", + "Processor %s error processing %s_%s: %s", + worker_id, + doc_task.doc_type, + doc_task.doc_id, + e, exc_info=True, ) # Continue to next document (no task_done() needed with streams) - logger.info(f"Processor {worker_id} stopped") + logger.info("Processor %s stopped", worker_id) async def process_document(doc_task: DocumentTask, nc_client: NextcloudClient): @@ -157,8 +160,11 @@ async def process_document(doc_task: DocumentTask, nc_client: NextcloudClient): start_time = time.time() logger.debug( - f"Processing {doc_task.doc_type}_{doc_task.doc_id} " - f"for {doc_task.user_id} ({doc_task.operation})" + "Processing %s_%s for %s (%s)", + doc_task.doc_type, + doc_task.doc_id, + doc_task.user_id, + doc_task.operation, ) with trace_operation( @@ -197,7 +203,10 @@ async def process_document(doc_task: DocumentTask, nc_client: NextcloudClient): ), ) logger.info( - f"Deleted {doc_task.doc_type}_{doc_task.doc_id} for {doc_task.user_id}" + "Deleted %s_%s for %s", + doc_task.doc_type, + doc_task.doc_id, + doc_task.user_id, ) # Record successful deletion metrics @@ -223,15 +232,22 @@ async def process_document(doc_task: DocumentTask, nc_client: NextcloudClient): except (HTTPStatusError, Exception) as e: if attempt < max_retries - 1: logger.warning( - f"Retry {attempt + 1}/{max_retries} for " - f"{doc_task.doc_type}_{doc_task.doc_id}: {e}" + "Retry %s/%s for %s_%s: %s", + attempt + 1, + max_retries, + doc_task.doc_type, + doc_task.doc_id, + e, ) await anyio.sleep(retry_delay) retry_delay *= 2 # Exponential backoff else: logger.error( - f"Failed to index {doc_task.doc_type}_{doc_task.doc_id} " - f"after {max_retries} retries: {e}" + "Failed to index %s_%s after %s retries: %s", + doc_task.doc_type, + doc_task.doc_id, + max_retries, + e, ) # Record failed processing metrics duration = time.time() - start_time @@ -348,7 +364,11 @@ async def _index_document( break except Exception as e: logger.warning( - f"Failed to fetch card with metadata (board_id={board_id}, stack_id={stack_id}, card_id={doc_task.doc_id}): {e}, falling back to iteration" + "Failed to fetch card with metadata (board_id=%s, stack_id=%s, card_id=%s): %s, falling back to iteration", + board_id, + stack_id, + doc_task.doc_id, + e, ) # Fallback: Iterate through all boards/stacks (for legacy data or if fast path failed) @@ -454,27 +474,32 @@ async def _index_document( if "page_boundaries" in file_metadata: page_boundaries = file_metadata["page_boundaries"] logger.info( - f"Page boundaries for {file_path}: " - f"{len(page_boundaries)} pages, text length: {len(content)}" + "Page boundaries for %s: %s pages, text length: %s", + file_path, + len(page_boundaries), + len(content), ) # Log first 3 page boundaries for debugging for boundary in page_boundaries[:3]: logger.debug( - f" Page {boundary['page']}: " - f"offsets [{boundary['start_offset']}:{boundary['end_offset']}]" + " Page %s: offsets [%s:%s]", + boundary["page"], + boundary["start_offset"], + boundary["end_offset"], ) # Verify last boundary matches text length if page_boundaries: last_boundary = page_boundaries[-1] if last_boundary["end_offset"] != len(content): logger.warning( - f"Text length mismatch: content={len(content)}, " - f"last_boundary_end={last_boundary['end_offset']}" + "Text length mismatch: content=%s, last_boundary_end=%s", + len(content), + last_boundary["end_offset"], ) else: - logger.debug(f"No page_boundaries in metadata for {file_path}") + logger.debug("No page_boundaries in metadata for %s", file_path) except Exception as e: - logger.error(f"Failed to process file {file_path}: {e}") + logger.error("Failed to process file %s: %s", file_path, e) raise # Tokenize and chunk (using configured chunk size and overlap) @@ -509,26 +534,32 @@ async def _index_document( # Diagnostic: Verify page number assignment assigned_count = sum(1 for c in chunks if c.page_number is not None) logger.info( - f"Assigned page numbers to {assigned_count}/{len(chunks)} chunks " - f"for {file_path}" + "Assigned page numbers to %s/%s chunks for %s", + assigned_count, + len(chunks), + file_path, ) # Log first 3 chunks to see their page assignments for i, chunk in enumerate(chunks[:3]): logger.debug( - f" Chunk {i}: page={chunk.page_number}, " - f"offsets=[{chunk.start_offset}:{chunk.end_offset}]" + " Chunk %s: page=%s, offsets=[%s:%s]", + i, + chunk.page_number, + chunk.start_offset, + chunk.end_offset, ) # Warning if NO page numbers were assigned if assigned_count == 0: logger.warning( - f"NO page numbers assigned! " - f"Text length: {len(content)}, " - f"Chunks: {len(chunks)}, " - f"Chunk offset range: [{chunks[0].start_offset}:{chunks[-1].end_offset}], " - f"Page boundaries: {len(page_boundaries_list)} pages, " - f"First boundary: {page_boundaries_list[0] if page_boundaries_list else 'None'}" + "NO page numbers assigned! Text length: %s, Chunks: %s, Chunk offset range: [%s:%s], Page boundaries: %s pages, First boundary: %s", + len(content), + len(chunks), + chunks[0].start_offset, + chunks[-1].end_offset, + len(page_boundaries_list), + page_boundaries_list[0] if page_boundaries_list else "None", ) # Extract chunk texts for embedding @@ -603,7 +634,7 @@ async def _index_document( page_boundaries_list = cast(list[dict[str, Any]], page_boundaries) - logger.info(f"Computing chunk bboxes for {len(chunk_data)} PDF chunks") + logger.info("Computing chunk bboxes for %s PDF chunks", len(chunk_data)) batch_results = await anyio.to_thread.run_sync( # type: ignore[attr-defined] lambda: PDFHighlighter.compute_chunk_bboxes_batch( @@ -617,7 +648,9 @@ async def _index_document( for chunk_index, (bboxes, _) in batch_results.items(): chunk_bboxes[chunk_index] = bboxes - logger.info(f"Computed bboxes for {len(chunk_bboxes)}/{len(chunks)} chunks") + logger.info( + "Computed bboxes for %s/%s chunks", len(chunk_bboxes), len(chunks) + ) # Run all embedding/highlighting operations in parallel # - Dense embeddings: I/O bound (API call) @@ -753,7 +786,10 @@ async def _index_document( except Exception as e: # Log but don't fail indexing if placeholder deletion fails logger.warning( - f"Failed to delete placeholder for {doc_task.doc_type}_{doc_task.doc_id}: {e}" + "Failed to delete placeholder for %s_%s: %s", + doc_task.doc_type, + doc_task.doc_id, + e, ) # Upsert to Qdrant in batches. Now that we no longer embed PNG payloads, @@ -779,10 +815,15 @@ async def _index_document( ) if batch_end < len(points): logger.debug( - f"Upserted batch {batch_start // BATCH_SIZE + 1}/{(len(points) + BATCH_SIZE - 1) // BATCH_SIZE}" + "Upserted batch %s/%s", + batch_start // BATCH_SIZE + 1, + (len(points) + BATCH_SIZE - 1) // BATCH_SIZE, ) logger.info( - f"Indexed {doc_task.doc_type}_{doc_task.doc_id} for {doc_task.user_id} " - f"({len(chunks)} chunks)" + "Indexed %s_%s for %s (%s chunks)", + doc_task.doc_type, + doc_task.doc_id, + doc_task.user_id, + len(chunks), ) diff --git a/nextcloud_mcp_server/vector/qdrant_client.py b/nextcloud_mcp_server/vector/qdrant_client.py index e1e745af..9f54d960 100644 --- a/nextcloud_mcp_server/vector/qdrant_client.py +++ b/nextcloud_mcp_server/vector/qdrant_client.py @@ -534,7 +534,7 @@ async def get_qdrant_client() -> AsyncQdrantClient: # Detect mode and initialize client accordingly if settings.qdrant_url: # Network mode - logger.info(f"Using Qdrant network mode: {settings.qdrant_url}") + logger.info("Using Qdrant network mode: %s", settings.qdrant_url) provisional = AsyncQdrantClient( url=settings.qdrant_url, api_key=settings.qdrant_api_key, @@ -548,7 +548,7 @@ async def get_qdrant_client() -> AsyncQdrantClient: else: # Persistent local mode - use path parameter logger.info( - f"Using Qdrant persistent mode: {settings.qdrant_location}" + "Using Qdrant persistent mode: %s", settings.qdrant_location ) provisional = AsyncQdrantClient(path=settings.qdrant_location) else: @@ -580,14 +580,14 @@ async def get_qdrant_client() -> AsyncQdrantClient: # `get_collection()`) is the only existence-probe permitted on a # collection-scoped JWT — it returns 200 with the collection # detail on hit and 404 on miss. - logger.debug(f"Fetching collection '{collection_name}' details...") + logger.debug("Fetching collection '%s' details...", collection_name) collection_info = None try: collection_info = await provisional.get_collection(collection_name) except UnexpectedResponse as exc: if exc.status_code != 404: raise - logger.debug(f"Collection '{collection_name}' not found (404).") + logger.debug("Collection '%s' not found (404).", collection_name) except ValueError as exc: # Local/in-memory qdrant_client raises ValueError(f"Collection # {name} not found") instead of UnexpectedResponse — see @@ -602,12 +602,12 @@ async def get_qdrant_client() -> AsyncQdrantClient: # multi-user-basic CI jobs all exercise this path. if "not found" not in str(exc): raise - logger.debug(f"Collection '{collection_name}' not found (local mode).") + logger.debug("Collection '%s' not found (local mode).", collection_name) if collection_info is not None: # Collection exists - validate dimensions logger.debug( - f"Collection '{collection_name}' found, validating dimensions..." + "Collection '%s' found, validating dimensions...", collection_name ) # Handle both named vectors (dict) and legacy single vector vectors = collection_info.config.params.vectors @@ -633,8 +633,10 @@ async def get_qdrant_client() -> AsyncQdrantClient: ) logger.info( - f"Using existing Qdrant collection: {collection_name} " - f"(dimension={actual_dimension}, model={settings.get_embedding_model_name()})" + "Using existing Qdrant collection: %s (dimension=%s, model=%s)", + collection_name, + actual_dimension, + settings.get_embedding_model_name(), ) # Existing collections may pre-date the doc_id normalization / @@ -658,8 +660,10 @@ async def get_qdrant_client() -> AsyncQdrantClient: # Collection doesn't exist - create it embedding_model = settings.get_embedding_model_name() logger.info( - f"Collection '{collection_name}' not found, creating with " - f"dimension={expected_dimension}, model={embedding_model}..." + "Collection '%s' not found, creating with dimension=%s, model=%s...", + collection_name, + expected_dimension, + embedding_model, ) await provisional.create_collection( collection_name=collection_name, @@ -678,12 +682,10 @@ async def get_qdrant_client() -> AsyncQdrantClient: }, ) logger.info( - f"Created Qdrant collection: {collection_name}\n" - f" Dense vector dimension: {expected_dimension}\n" - f" Dense embedding model: {embedding_model}\n" - f" Sparse vectors: BM25 (for hybrid search)\n" - f" Distance: COSINE\n" - f"Background sync will index all documents with dense + sparse vectors." + "Created Qdrant collection: %s\\n Dense vector dimension: %s\\n Dense embedding model: %s\\n Sparse vectors: BM25 (for hybrid search)\\n Distance: COSINE\\nBackground sync will index all documents with dense + sparse vectors.", + collection_name, + expected_dimension, + embedding_model, ) # Freshly created collection has no payload schema yet; pass # {} explicitly to skip the otherwise-redundant diff --git a/nextcloud_mcp_server/vector/scanner.py b/nextcloud_mcp_server/vector/scanner.py index c4a2e6a2..d14bbcec 100644 --- a/nextcloud_mcp_server/vector/scanner.py +++ b/nextcloud_mcp_server/vector/scanner.py @@ -155,7 +155,7 @@ async def get_last_indexed_timestamp(user_id: str) -> int | None: ) num_points = len(points) - logger.info(f"Found {num_points} indexed notes in Qdrant for user {user_id}") + logger.info("Found %s indexed notes in Qdrant for user %s", num_points, user_id) if points: timestamps = [ @@ -165,14 +165,16 @@ async def get_last_indexed_timestamp(user_id: str) -> int | None: ] max_timestamp = max(timestamps) if timestamps else 0 logger.info( - f"Max indexed_at: {max_timestamp}, timestamps sample: {timestamps[:3]}" + "Max indexed_at: %s, timestamps sample: %s", + max_timestamp, + timestamps[:3], ) return int(max_timestamp) if max_timestamp > 0 else None - logger.info(f"No indexed notes found for user {user_id}") + logger.info("No indexed notes found for user %s", user_id) return None except Exception as e: - logger.warning(f"Failed to get last indexed timestamp: {e}", exc_info=True) + logger.warning("Failed to get last indexed timestamp: %s", e, exc_info=True) return None @@ -198,7 +200,7 @@ async def scanner_task( user_id: User to scan task_status: Status object for signaling task readiness """ - logger.info(f"Scanner task started for user: {user_id}") + logger.info("Scanner task started for user: %s", user_id) settings = get_settings() # Signal that the task has started and is ready @@ -215,7 +217,7 @@ async def scanner_task( ) except Exception as e: - logger.error(f"Scanner error: {e}", exc_info=True) + logger.error("Scanner error: %s", e, exc_info=True) # Sleep until next interval or wake event try: @@ -247,7 +249,10 @@ async def scan_user_documents( scan_id = random.randint(1000, 9999) logger.info( - f"[SCAN-{scan_id}] Starting scan for user: {user_id}, initial_sync={initial_sync}" + "[SCAN-%s] Starting scan for user: %s, initial_sync=%s", + scan_id, + user_id, + initial_sync, ) with trace_operation( @@ -266,7 +271,9 @@ async def scan_user_documents( ) if prune_before: logger.info( - f"[SCAN-{scan_id}] Using pruneBefore={prune_before} to optimize data transfer" + "[SCAN-%s] Using pruneBefore=%s to optimize data transfer", + scan_id, + prune_before, ) # For deletion tracking, get all doc_ids in Qdrant (for incremental sync) @@ -303,7 +310,7 @@ async def scan_user_documents( if point.payload is not None and "doc_id" in point.payload } - logger.debug(f"Found {len(indexed_doc_ids)} indexed documents in Qdrant") + logger.debug("Found %s indexed documents in Qdrant", len(indexed_doc_ids)) # Stream notes from Nextcloud and process immediately note_count = 0 @@ -341,7 +348,8 @@ async def scan_user_documents( doc_key = (user_id, doc_id) if doc_key in _potentially_deleted: logger.debug( - f"Document {doc_id} reappeared, removing from deletion grace period" + "Document %s reappeared, removing from deletion grace period", + doc_id, ) del _potentially_deleted[doc_key] @@ -368,14 +376,17 @@ async def scan_user_documents( stale_threshold = get_settings().vector_sync_scan_interval * 5 if placeholder_age > stale_threshold: logger.debug( - f"Found stale placeholder for note {doc_id} " - f"(age={placeholder_age:.1f}s), requeuing" + "Found stale placeholder for note %s (age=%ss), requeuing", + doc_id, + format(placeholder_age, ".1f"), ) needs_indexing = True else: logger.debug( - f"Skipping note {doc_id} with recent placeholder " - f"(age={placeholder_age:.1f}s < {stale_threshold:.1f}s)" + "Skipping note %s with recent placeholder (age=%ss < %ss)", + doc_id, + format(placeholder_age, ".1f"), + format(stale_threshold, ".1f"), ) if needs_indexing: @@ -399,11 +410,11 @@ async def scan_user_documents( queued += 1 # Log and record metrics after streaming - logger.info(f"[SCAN-{scan_id}] Found {note_count} notes for {user_id}") + logger.info("[SCAN-%s] Found %s notes for %s", scan_id, note_count, user_id) record_vector_sync_scan(note_count) if initial_sync: - logger.info(f"Sent {queued} documents for initial sync: {user_id}") + logger.info("Sent %s documents for initial sync: %s", queued, user_id) return # Check for deleted documents (in Qdrant but not in Nextcloud) @@ -426,8 +437,10 @@ async def scan_user_documents( if time_missing >= grace_period: # Grace period elapsed, send for deletion logger.info( - f"Document {doc_id} missing for {time_missing:.1f}s " - f"(>{grace_period:.1f}s grace period), sending deletion" + "Document %s missing for %ss (>%ss grace period), sending deletion", + doc_id, + format(time_missing, ".1f"), + format(grace_period, ".1f"), ) await send_stream.send( DocumentTask( @@ -443,13 +456,16 @@ async def scan_user_documents( del _potentially_deleted[doc_key] else: logger.debug( - f"Document {doc_id} still missing " - f"({time_missing:.1f}s/{grace_period:.1f}s grace period)" + "Document %s still missing (%ss/%ss grace period)", + doc_id, + format(time_missing, ".1f"), + format(grace_period, ".1f"), ) else: # First time missing, add to grace period tracking logger.debug( - f"Document {doc_id} missing for first time, starting grace period" + "Document %s missing for first time, starting grace period", + doc_id, ) _potentially_deleted[doc_key] = current_time @@ -476,7 +492,7 @@ async def scan_user_documents( if point.payload is not None and "doc_id" in point.payload } - logger.debug(f"Found {len(indexed_file_ids)} indexed files in Qdrant") + logger.debug("Found %s indexed files in Qdrant", len(indexed_file_ids)) # Scan for tagged PDF files file_count = 0 @@ -568,7 +584,9 @@ async def scan_user_documents( file_key = (user_id, file_id) if file_key in _potentially_deleted: logger.debug( - f"File {file_path} (ID: {file_id}) reappeared, removing from deletion grace period" + "File %s (ID: %s) reappeared, removing from deletion grace period", + file_path, + file_id, ) del _potentially_deleted[file_key] @@ -595,14 +613,19 @@ async def scan_user_documents( stale_threshold = get_settings().vector_sync_scan_interval * 5 if placeholder_age > stale_threshold: logger.debug( - f"Found stale placeholder for file {file_path} (ID: {file_id}) " - f"(age={placeholder_age:.1f}s), requeuing" + "Found stale placeholder for file %s (ID: %s) (age=%ss), requeuing", + file_path, + file_id, + format(placeholder_age, ".1f"), ) needs_indexing = True else: logger.debug( - f"Skipping file {file_path} (ID: {file_id}) with recent placeholder " - f"(age={placeholder_age:.1f}s < {stale_threshold:.1f}s)" + "Skipping file %s (ID: %s) with recent placeholder (age=%ss < %ss)", + file_path, + file_id, + format(placeholder_age, ".1f"), + format(stale_threshold, ".1f"), ) if needs_indexing: @@ -627,7 +650,7 @@ async def scan_user_documents( file_queued += 1 logger.info( - f"[SCAN-{scan_id}] Found {file_count} tagged PDFs for {user_id}" + "[SCAN-%s] Found %s tagged PDFs for %s", scan_id, file_count, user_id ) record_vector_sync_scan(file_count) @@ -645,8 +668,10 @@ async def scan_user_documents( if time_missing >= grace_period: # Grace period elapsed, send for deletion logger.info( - f"File ID {file_id} missing for {time_missing:.1f}s " - f"(>{grace_period:.1f}s grace period), sending deletion" + "File ID %s missing for %ss (>%ss grace period), sending deletion", + file_id, + format(time_missing, ".1f"), + format(grace_period, ".1f"), ) await send_stream.send( DocumentTask( @@ -662,12 +687,13 @@ async def scan_user_documents( else: # First time missing, add to grace period tracking logger.debug( - f"File ID {file_id} missing for first time, starting grace period" + "File ID %s missing for first time, starting grace period", + file_id, ) _potentially_deleted[file_key] = current_time except Exception as e: - logger.warning(f"Failed to scan tagged files for {user_id}: {e}") + logger.warning("Failed to scan tagged files for %s: %s", user_id, e) queued += file_queued @@ -683,7 +709,7 @@ async def scan_user_documents( ) queued += news_queued except Exception as e: - logger.warning(f"Failed to scan news items for {user_id}: {e}") + logger.warning("Failed to scan news items for %s: %s", user_id, e) # Scan Deck cards deck_queued = 0 @@ -697,14 +723,19 @@ async def scan_user_documents( ) queued += deck_queued except Exception as e: - logger.warning(f"Failed to scan deck cards for {user_id}: {e}") + logger.warning("Failed to scan deck cards for %s: %s", user_id, e) if queued > 0: logger.info( - f"Sent {queued} documents ({file_queued} files, {news_queued} news items, {deck_queued} deck cards) for incremental sync: {user_id}" + "Sent %s documents (%s files, %s news items, %s deck cards) for incremental sync: %s", + queued, + file_queued, + news_queued, + deck_queued, + user_id, ) else: - logger.debug(f"No changes detected for {user_id}") + logger.debug("No changes detected for %s", user_id) async def scan_news_items( @@ -754,7 +785,7 @@ async def scan_news_items( for point in points if point.payload is not None and "doc_id" in point.payload } - logger.debug(f"Found {len(indexed_item_ids)} indexed news items in Qdrant") + logger.debug("Found %s indexed news items in Qdrant", len(indexed_item_ids)) # Fetch all items (News app caps at ~200 per feed via auto-purge) all_items = await nc_client.news.get_items( @@ -762,7 +793,7 @@ async def scan_news_items( type_=NewsItemType.ALL, get_read=True, ) - logger.debug(f"[SCAN-{scan_id}] Found {len(all_items)} news items") + logger.debug("[SCAN-%s] Found %s news items", scan_id, len(all_items)) item_count = len(all_items) nextcloud_item_ids: set[str] = set() @@ -800,7 +831,8 @@ async def scan_news_items( doc_key = (user_id, doc_id) if doc_key in _potentially_deleted: logger.debug( - f"News item {doc_id} reappeared, removing from deletion grace period" + "News item %s reappeared, removing from deletion grace period", + doc_id, ) del _potentially_deleted[doc_key] @@ -820,8 +852,9 @@ async def scan_news_items( stale_threshold = settings.vector_sync_scan_interval * 5 if placeholder_age > stale_threshold: logger.debug( - f"Found stale placeholder for news item {doc_id} " - f"(age={placeholder_age:.1f}s), requeuing" + "Found stale placeholder for news item %s (age=%ss), requeuing", + doc_id, + format(placeholder_age, ".1f"), ) needs_indexing = True @@ -844,7 +877,10 @@ async def scan_news_items( queued += 1 logger.info( - f"[SCAN-{scan_id}] Found {item_count} news items (starred+unread) for {user_id}" + "[SCAN-%s] Found %s news items (starred+unread) for %s", + scan_id, + item_count, + user_id, ) record_vector_sync_scan(item_count) @@ -864,8 +900,10 @@ async def scan_news_items( if time_missing >= grace_period: logger.info( - f"News item {doc_id} missing for {time_missing:.1f}s " - f"(>{grace_period:.1f}s grace period), sending deletion" + "News item %s missing for %ss (>%ss grace period), sending deletion", + doc_id, + format(time_missing, ".1f"), + format(grace_period, ".1f"), ) await send_stream.send( DocumentTask( @@ -880,7 +918,8 @@ async def scan_news_items( del _potentially_deleted[doc_key] else: logger.debug( - f"News item {doc_id} missing for first time, starting grace period" + "News item %s missing for first time, starting grace period", + doc_id, ) _potentially_deleted[doc_key] = current_time @@ -932,11 +971,11 @@ async def scan_deck_cards( for point in points if point.payload is not None and "doc_id" in point.payload } - logger.debug(f"Found {len(indexed_card_ids)} indexed deck cards in Qdrant") + logger.debug("Found %s indexed deck cards in Qdrant", len(indexed_card_ids)) # Fetch all boards boards = await nc_client.deck.get_boards() - logger.debug(f"[SCAN-{scan_id}] Found {len(boards)} deck boards") + logger.debug("[SCAN-%s] Found %s deck boards", scan_id, len(boards)) card_count = 0 nextcloud_card_ids: set[str] = set() @@ -949,7 +988,7 @@ async def scan_deck_cards( # Skip deleted boards (soft delete: deletedAt > 0) if board.deletedAt > 0: - logger.debug(f"[SCAN-{scan_id}] Skipping deleted board {board.id}") + logger.debug("[SCAN-%s] Skipping deleted board %s", scan_id, board.id) continue # Get stacks for this board @@ -998,7 +1037,8 @@ async def scan_deck_cards( doc_key = (user_id, doc_id) if doc_key in _potentially_deleted: logger.debug( - f"Deck card {doc_id} reappeared, removing from deletion grace period" + "Deck card %s reappeared, removing from deletion grace period", + doc_id, ) del _potentially_deleted[doc_key] @@ -1018,8 +1058,9 @@ async def scan_deck_cards( stale_threshold = settings.vector_sync_scan_interval * 5 if placeholder_age > stale_threshold: logger.debug( - f"Found stale placeholder for deck card {doc_id} " - f"(age={placeholder_age:.1f}s), requeuing" + "Found stale placeholder for deck card %s (age=%ss), requeuing", + doc_id, + format(placeholder_age, ".1f"), ) needs_indexing = True @@ -1043,7 +1084,10 @@ async def scan_deck_cards( queued += 1 logger.info( - f"[SCAN-{scan_id}] Found {card_count} deck cards (non-archived) for {user_id}" + "[SCAN-%s] Found %s deck cards (non-archived) for %s", + scan_id, + card_count, + user_id, ) record_vector_sync_scan(card_count) @@ -1062,8 +1106,10 @@ async def scan_deck_cards( if time_missing >= grace_period: logger.info( - f"Deck card {doc_id} missing for {time_missing:.1f}s " - f"(>{grace_period:.1f}s grace period), sending deletion" + "Deck card %s missing for %ss (>%ss grace period), sending deletion", + doc_id, + format(time_missing, ".1f"), + format(grace_period, ".1f"), ) await send_stream.send( DocumentTask( @@ -1078,7 +1124,8 @@ async def scan_deck_cards( del _potentially_deleted[doc_key] else: logger.debug( - f"Deck card {doc_id} missing for first time, starting grace period" + "Deck card %s missing for first time, starting grace period", + doc_id, ) _potentially_deleted[doc_key] = current_time diff --git a/nextcloud_mcp_server/vector/visualization.py b/nextcloud_mcp_server/vector/visualization.py index 3da8d2e6..62068664 100644 --- a/nextcloud_mcp_server/vector/visualization.py +++ b/nextcloud_mcp_server/vector/visualization.py @@ -92,7 +92,7 @@ async def compute_pca_coordinates( if embedding_dim is None: return {"coordinates_3d": [], "query_coords": []} - logger.info(f"Detected embedding dimension: {embedding_dim}") + logger.info("Detected embedding dimension: %s", embedding_dim) # Build chunk vectors array in search_results order (1:1 mapping) chunk_vectors = [] @@ -103,7 +103,7 @@ async def compute_pca_coordinates( else: # Chunk not found in vectors (shouldn't happen) logger.warning( - f"Chunk {chunk_key} not found in fetched vectors, using zero vector" + "Chunk %s not found in fetched vectors, using zero vector", chunk_key ) chunk_vectors.append(np.zeros(embedding_dim)) @@ -129,17 +129,19 @@ async def compute_pca_coordinates( if zero_norm_mask.any(): zero_indices = np.where(zero_norm_mask)[0] logger.warning( - f"Found {zero_norm_mask.sum()} zero-norm vectors at indices " - f"{zero_indices.tolist()}. Replacing with small epsilon to avoid " - "division by zero." + "Found %s zero-norm vectors at indices %s. Replacing with small epsilon to avoid division by zero.", + zero_norm_mask.sum(), + zero_indices.tolist(), ) # Replace zero norms with small epsilon to avoid NaN norms[zero_norm_mask] = 1e-10 all_vectors_normalized = all_vectors / norms logger.info( - f"Normalized vectors: query_norm={norms[-1][0]:.3f}, " - f"doc_norm_range=[{norms[:-1].min():.3f}, {norms[:-1].max():.3f}]" + "Normalized vectors: query_norm=%s, doc_norm_range=[%s, %s]", + format(norms[-1][0], ".3f"), + format(norms[:-1].min(), ".3f"), + format(norms[:-1].max(), ".3f"), ) # Apply PCA dimensionality reduction (768-dim → 3D) @@ -161,9 +163,9 @@ async def compute_pca_coordinates( if nan_mask.any(): nan_rows = np.where(nan_mask.any(axis=1))[0] logger.error( - f"Found NaN values in PCA output at {len(nan_rows)} points: " - f"{nan_rows.tolist()[:10]}. Replacing NaN with 0.0 to prevent " - "JSON serialization error." + "Found NaN values in PCA output at %s points: %s. Replacing NaN with 0.0 to prevent JSON serialization error.", + len(nan_rows), + nan_rows.tolist()[:10], ) # Replace NaN with 0 to allow JSON serialization coords_3d = np.nan_to_num(coords_3d, nan=0.0) @@ -174,9 +176,10 @@ async def compute_pca_coordinates( chunk_coords_3d = coords_3d[:-1] # All but last are chunks logger.info( - f"PCA explained variance: PC1={pca.explained_variance_ratio_[0]:.3f}, " - f"PC2={pca.explained_variance_ratio_[1]:.3f}, " - f"PC3={pca.explained_variance_ratio_[2]:.3f}" + "PCA explained variance: PC1=%s, PC2=%s, PC3=%s", + format(pca.explained_variance_ratio_[0], ".3f"), + format(pca.explained_variance_ratio_[1], ".3f"), + format(pca.explained_variance_ratio_[2], ".3f"), ) # Coordinates already match search_results order (1:1 mapping) diff --git a/pyproject.toml b/pyproject.toml index 19cc0d81..6ac9c6b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,7 +95,7 @@ update_changelog_on_bump = true major_version_zero = true [tool.ruff.lint] -extend-select = ["I", "PLC0415"] +extend-select = ["I", "PLC0415", "G004"] [tool.ruff.lint.per-file-ignores] "tests/**" = ["PLC0415"] diff --git a/tests/client/calendar/test_calendar_operations.py b/tests/client/calendar/test_calendar_operations.py index 0ebd0337..22419c2c 100644 --- a/tests/client/calendar/test_calendar_operations.py +++ b/tests/client/calendar/test_calendar_operations.py @@ -44,29 +44,29 @@ async def temporary_event(nc_client: NextcloudClient, temporary_calendar: str): } try: - logger.info(f"Creating temporary event in calendar: {calendar_name}") + logger.info("Creating temporary event in calendar: %s", calendar_name) result = await nc_client.calendar.create_event(calendar_name, event_data) event_uid = result.get("uid") if not event_uid: pytest.fail("Failed to create temporary event") - logger.info(f"Created temporary event with UID: {event_uid}") + logger.info("Created temporary event with UID: %s", event_uid) yield {"uid": event_uid, "calendar_name": calendar_name, "data": event_data} finally: # Cleanup if event_uid: try: - logger.info(f"Cleaning up temporary event: {event_uid}") + logger.info("Cleaning up temporary event: %s", event_uid) await nc_client.calendar.delete_event(calendar_name, event_uid) - logger.info(f"Successfully deleted temporary event: {event_uid}") + logger.info("Successfully deleted temporary event: %s", event_uid) except HTTPStatusError as e: if e.response.status_code != 404: - logger.error(f"Error deleting temporary event {event_uid}: {e}") + logger.error("Error deleting temporary event %s: %s", event_uid, e) except Exception as e: logger.error( - f"Unexpected error deleting temporary event {event_uid}: {e}" + "Unexpected error deleting temporary event %s: %s", event_uid, e ) @@ -79,7 +79,7 @@ async def test_list_calendars(nc_client: NextcloudClient): if not calendars: pytest.skip("No calendars available - Calendar app may not be enabled") - logger.info(f"Found {len(calendars)} calendars") + logger.info("Found %s calendars", len(calendars)) # Check structure of calendars for calendar in calendars: @@ -90,7 +90,7 @@ async def test_list_calendars(nc_client: NextcloudClient): assert "description" in calendar assert "color" in calendar - logger.info(f"Calendar: {calendar['name']} - {calendar['display_name']}") + logger.info("Calendar: %s - %s", calendar["name"], calendar["display_name"]) async def test_create_and_delete_event( @@ -118,7 +118,7 @@ async def test_create_and_delete_event( assert result["status_code"] in [200, 201, 204] event_uid = result["uid"] - logger.info(f"Created event with UID: {event_uid}") + logger.info("Created event with UID: %s", event_uid) # Verify event was created by retrieving it retrieved_event, etag = await nc_client.calendar.get_event( @@ -132,10 +132,10 @@ async def test_create_and_delete_event( delete_result = await nc_client.calendar.delete_event(calendar_name, event_uid) assert delete_result["status_code"] in [200, 204, 404] - logger.info(f"Successfully deleted event: {event_uid}") + logger.info("Successfully deleted event: %s", event_uid) except Exception as e: - logger.error(f"Test failed: {e}") + logger.error("Test failed: %s", e) raise @@ -157,7 +157,7 @@ async def test_create_all_day_event( try: result = await nc_client.calendar.create_event(calendar_name, event_data) event_uid = result["uid"] - logger.info(f"Created all-day event with UID: {event_uid}") + logger.info("Created all-day event with UID: %s", event_uid) # Verify event retrieved_event, _ = await nc_client.calendar.get_event( @@ -170,7 +170,7 @@ async def test_create_all_day_event( await nc_client.calendar.delete_event(calendar_name, event_uid) except Exception as e: - logger.error(f"All-day event test failed: {e}") + logger.error("All-day event test failed: %s", e) raise @@ -194,7 +194,7 @@ async def test_create_recurring_event( try: result = await nc_client.calendar.create_event(calendar_name, event_data) event_uid = result["uid"] - logger.info(f"Created recurring event with UID: {event_uid}") + logger.info("Created recurring event with UID: %s", event_uid) # Verify event retrieved_event, _ = await nc_client.calendar.get_event( @@ -207,7 +207,7 @@ async def test_create_recurring_event( await nc_client.calendar.delete_event(calendar_name, event_uid) except Exception as e: - logger.error(f"Recurring event test failed: {e}") + logger.error("Recurring event test failed: %s", e) raise @@ -227,7 +227,7 @@ async def test_list_events_in_range(nc_client: NextcloudClient, temporary_event: ) assert isinstance(events, list) - logger.info(f"Found {len(events)} events in date range") + logger.info("Found %s events in date range", len(events)) # Our temporary event should be in the list event_uids = [event.get("uid") for event in events] @@ -266,10 +266,10 @@ async def test_update_event(nc_client: NextcloudClient, temporary_event: dict): assert updated_event["location"] == "Updated Location" assert updated_event["priority"] == 1 - logger.info(f"Successfully updated event: {event_uid}") + logger.info("Successfully updated event: %s", event_uid) except Exception as e: - logger.error(f"Event update test failed: {e}") + logger.error("Event update test failed: %s", e) raise @@ -291,7 +291,7 @@ async def test_update_event_extended_fields( try: result = await nc_client.calendar.create_event(calendar_name, event_data) event_uid = result["uid"] - logger.info(f"Created base event for extended fields test: {event_uid}") + logger.info("Created base event for extended fields test: %s", event_uid) # --- Phase 1: Set all four extended fields --- updated_data = { @@ -343,7 +343,7 @@ async def test_update_event_extended_fields( logger.info("Phase 2 passed: all extended fields cleared correctly") except Exception as e: - logger.error(f"Extended fields update test failed: {e}") + logger.error("Extended fields update test failed: %s", e) raise finally: if event_uid: @@ -374,7 +374,7 @@ async def test_create_event_with_attendees( try: result = await nc_client.calendar.create_event(calendar_name, event_data) event_uid = result["uid"] - logger.info(f"Created event with attendees, UID: {event_uid}") + logger.info("Created event with attendees, UID: %s", event_uid) # Verify event retrieved_event, _ = await nc_client.calendar.get_event( @@ -388,7 +388,7 @@ async def test_create_event_with_attendees( await nc_client.calendar.delete_event(calendar_name, event_uid) except Exception as e: - logger.error(f"Event with attendees test failed: {e}") + logger.error("Event with attendees test failed: %s", e) raise @@ -403,7 +403,7 @@ async def test_get_nonexistent_event( with pytest.raises(Exception, match="not found"): await nc_client.calendar.get_event(calendar_name, fake_uid) - logger.info(f"Correctly raised exception for nonexistent event: {fake_uid}") + logger.info("Correctly raised exception for nonexistent event: %s", fake_uid) async def test_delete_nonexistent_event( @@ -415,7 +415,7 @@ async def test_delete_nonexistent_event( result = await nc_client.calendar.delete_event(calendar_name, fake_uid) assert result["status_code"] == 404 - logger.info(f"Correctly got 404 for deleting nonexistent event: {fake_uid}") + logger.info("Correctly got 404 for deleting nonexistent event: %s", fake_uid) async def test_event_with_url_and_categories( @@ -439,7 +439,7 @@ async def test_event_with_url_and_categories( try: result = await nc_client.calendar.create_event(calendar_name, event_data) event_uid = result["uid"] - logger.info(f"Created event with metadata, UID: {event_uid}") + logger.info("Created event with metadata, UID: %s", event_uid) # Verify event retrieved_event, _ = await nc_client.calendar.get_event( @@ -456,7 +456,7 @@ async def test_event_with_url_and_categories( await nc_client.calendar.delete_event(calendar_name, event_uid) except Exception as e: - logger.error(f"Event with metadata test failed: {e}") + logger.error("Event with metadata test failed: %s", e) raise @@ -485,7 +485,7 @@ async def test_list_events_date_range_filtering( calendar_name, past_event_data ) past_uid = result_past["uid"] - logger.info(f"Created past event: {past_uid}") + logger.info("Created past event: %s", past_uid) # Create Event B: 1 day in the future future_date = datetime.now() + timedelta(days=1) @@ -499,7 +499,7 @@ async def test_list_events_date_range_filtering( calendar_name, future_event_data ) future_uid = result_future["uid"] - logger.info(f"Created future event: {future_uid}") + logger.info("Created future event: %s", future_uid) # Query with date range: today → 7 days ahead now = datetime.now() @@ -526,8 +526,8 @@ async def test_list_events_date_range_filtering( ) logger.info( - f"Date range filtering works: {len(events)} events returned, " - f"past event correctly excluded" + "Date range filtering works: %s events returned, past event correctly excluded", + len(events), ) finally: @@ -537,7 +537,7 @@ async def test_list_events_date_range_filtering( try: await nc_client.calendar.delete_event(calendar_name, uid) except Exception as e: - logger.warning(f"Cleanup failed for event {uid}: {e}") + logger.warning("Cleanup failed for event %s: %s", uid, e) async def test_recurring_event_date_range_expansion( @@ -569,7 +569,7 @@ async def test_recurring_event_date_range_expansion( } result = await nc_client.calendar.create_event(calendar_name, event_data) event_uid = result["uid"] - logger.info(f"Created daily recurring event: {event_uid}") + logger.info("Created daily recurring event: %s", event_uid) # Query with date range: today → 3 days ahead query_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) @@ -619,8 +619,8 @@ async def test_recurring_event_date_range_expansion( ) logger.info( - f"Recurring event expansion works: {len(our_events)} occurrences " - f"returned with unique start dates" + "Recurring event expansion works: %s occurrences returned with unique start dates", + len(our_events), ) finally: @@ -628,7 +628,9 @@ async def test_recurring_event_date_range_expansion( try: await nc_client.calendar.delete_event(calendar_name, event_uid) except Exception as e: - logger.warning(f"Cleanup failed for recurring event {event_uid}: {e}") + logger.warning( + "Cleanup failed for recurring event %s: %s", event_uid, e + ) async def test_calendar_operations_error_handling( diff --git a/tests/client/calendar/test_field_preservation.py b/tests/client/calendar/test_field_preservation.py index 41022089..0736ba9a 100644 --- a/tests/client/calendar/test_field_preservation.py +++ b/tests/client/calendar/test_field_preservation.py @@ -68,7 +68,7 @@ END:VCALENDAR""" event.data = custom_ical await event.save() - logger.info(f"Injected custom iCal properties into event {event_uid}") + logger.info("Injected custom iCal properties into event %s", event_uid) # Reload the event to confirm custom fields are present await event.load() @@ -91,7 +91,7 @@ END:VCALENDAR""" } await nc_client.calendar.update_event(calendar_name, event_uid, update_data) - logger.info(f"Updated event {event_uid} through MCP client") + logger.info("Updated event %s through MCP client", event_uid) # Reload the event to see if custom fields survived await event.load() @@ -117,7 +117,7 @@ END:VCALENDAR""" try: await nc_client.calendar.delete_event(calendar_name, event_uid) except Exception as cleanup_error: - logger.warning(f"Failed to cleanup event {event_uid}: {cleanup_error}") + logger.warning("Failed to cleanup event %s: %s", event_uid, cleanup_error) @pytest.mark.integration @@ -145,7 +145,7 @@ async def test_contact_extended_fields_preservation(nc_client): contact_data=basic_contact_data, ) - logger.info(f"Created basic contact {contact_uid}") + logger.info("Created basic contact %s", contact_uid) # Now inject a rich vCard with extended fields directly via CardDAV extended_vcard = f"""BEGIN:VCARD @@ -182,7 +182,7 @@ END:VCARD""" headers={"Content-Type": "text/vcard; charset=utf-8"}, ) - logger.info(f"Injected extended vCard for contact {contact_uid}") + logger.info("Injected extended vCard for contact %s", contact_uid) # Retrieve the contact to confirm extended fields are present in raw vCard response = await nc_client.contacts._make_request("GET", contact_path) @@ -232,7 +232,7 @@ END:VCARD""" ) logger.info("✓ Contact updated successfully") except Exception as e: - logger.error(f"✗ Failed to update contact: {e}") + logger.error("✗ Failed to update contact: %s", e) raise # Retrieve the contact again to see if extended fields survived @@ -268,9 +268,9 @@ END:VCARD""" all_preserved = True for field_pattern, field_name in extended_field_checks: if field_pattern in updated_addressdata: - logger.info(f"✓ {field_name} preserved") + logger.info("✓ %s preserved", field_name) else: - logger.error(f"✗ {field_name} was lost during update") + logger.error("✗ %s was lost during update", field_name) all_preserved = False # The test should PASS - field preservation should work @@ -286,7 +286,7 @@ END:VCARD""" await nc_client.contacts.delete_addressbook(name=addressbook_name) except Exception as cleanup_error: logger.warning( - f"Failed to cleanup addressbook {addressbook_name}: {cleanup_error}" + "Failed to cleanup addressbook %s: %s", addressbook_name, cleanup_error ) @@ -415,9 +415,9 @@ END:VCALENDAR""" else: lost.append(prop) - logger.info(f"Properties that SURVIVED: {survived}") + logger.info("Properties that SURVIVED: %s", survived) if lost: - logger.error(f"Properties that were LOST: {lost}") + logger.error("Properties that were LOST: %s", lost) # Assert that all extended properties were preserved assert len(lost) == 0, ( @@ -430,4 +430,4 @@ END:VCALENDAR""" try: await nc_client.calendar.delete_event(calendar_name, event_uid) except Exception as cleanup_error: - logger.warning(f"Failed to cleanup event {event_uid}: {cleanup_error}") + logger.warning("Failed to cleanup event %s: %s", event_uid, cleanup_error) diff --git a/tests/client/calendar/test_task_operations.py b/tests/client/calendar/test_task_operations.py index d2f20dc6..8a5b922c 100644 --- a/tests/client/calendar/test_task_operations.py +++ b/tests/client/calendar/test_task_operations.py @@ -33,29 +33,29 @@ async def temporary_todo(nc_client: NextcloudClient, temporary_calendar: str): } try: - logger.info(f"Creating temporary todo in calendar: {calendar_name}") + logger.info("Creating temporary todo in calendar: %s", calendar_name) result = await nc_client.calendar.create_todo(calendar_name, todo_data) todo_uid = result.get("uid") if not todo_uid: pytest.fail("Failed to create temporary todo") - logger.info(f"Created temporary todo with UID: {todo_uid}") + logger.info("Created temporary todo with UID: %s", todo_uid) yield {"uid": todo_uid, "calendar_name": calendar_name, "data": todo_data} finally: # Cleanup if todo_uid: try: - logger.info(f"Cleaning up temporary todo: {todo_uid}") + logger.info("Cleaning up temporary todo: %s", todo_uid) await nc_client.calendar.delete_todo(calendar_name, todo_uid) - logger.info(f"Successfully deleted temporary todo: {todo_uid}") + logger.info("Successfully deleted temporary todo: %s", todo_uid) except HTTPStatusError as e: if e.response.status_code != 404: - logger.error(f"Error deleting temporary todo {todo_uid}: {e}") + logger.error("Error deleting temporary todo %s: %s", todo_uid, e) except Exception as e: logger.error( - f"Unexpected error deleting temporary todo {todo_uid}: {e}" + "Unexpected error deleting temporary todo %s: %s", todo_uid, e ) @@ -85,7 +85,7 @@ async def test_create_and_delete_todo( assert result["status_code"] in [200, 201, 204] todo_uid = result["uid"] - logger.info(f"Created todo with UID: {todo_uid}") + logger.info("Created todo with UID: %s", todo_uid) # Verify todo was created by listing todos todos = await nc_client.calendar.list_todos(calendar_name) @@ -103,10 +103,10 @@ async def test_create_and_delete_todo( delete_result = await nc_client.calendar.delete_todo(calendar_name, todo_uid) assert delete_result["status_code"] in [200, 204, 404] - logger.info(f"Successfully deleted todo: {todo_uid}") + logger.info("Successfully deleted todo: %s", todo_uid) except Exception as e: - logger.error(f"Test failed: {e}") + logger.error("Test failed: %s", e) raise @@ -145,7 +145,7 @@ async def test_list_todos(nc_client: NextcloudClient, temporary_calendar: str): for uid in todo_uids: assert uid in listed_uids - logger.info(f"Found {len(todos)} todos in calendar") + logger.info("Found %s todos in calendar", len(todos)) finally: # Cleanup @@ -187,10 +187,10 @@ async def test_update_todo(nc_client: NextcloudClient, temporary_todo: dict): assert updated_todo["priority"] == 1 assert updated_todo["percent_complete"] == 50 - logger.info(f"Successfully updated todo: {todo_uid}") + logger.info("Successfully updated todo: %s", todo_uid) except Exception as e: - logger.error(f"Todo update test failed: {e}") + logger.error("Todo update test failed: %s", e) raise @@ -213,7 +213,7 @@ async def test_todo_with_dates(nc_client: NextcloudClient, temporary_calendar: s try: result = await nc_client.calendar.create_todo(calendar_name, todo_data) todo_uid = result["uid"] - logger.info(f"Created todo with dates, UID: {todo_uid}") + logger.info("Created todo with dates, UID: %s", todo_uid) # Verify dates todos = await nc_client.calendar.list_todos(calendar_name) @@ -228,7 +228,7 @@ async def test_todo_with_dates(nc_client: NextcloudClient, temporary_calendar: s await nc_client.calendar.delete_todo(calendar_name, todo_uid) except Exception as e: - logger.error(f"Date handling test failed: {e}") + logger.error("Date handling test failed: %s", e) raise @@ -281,7 +281,7 @@ async def test_todo_status_transitions( assert todo["percent_complete"] == 100 assert "completed" in todo - logger.info(f"Successfully transitioned todo through statuses: {todo_uid}") + logger.info("Successfully transitioned todo through statuses: %s", todo_uid) finally: await nc_client.calendar.delete_todo(calendar_name, todo_uid) @@ -315,7 +315,7 @@ async def test_todo_priority_levels( assert todo is not None assert todo["priority"] == expected_priority - logger.info(f"Successfully tested priority levels: {priorities}") + logger.info("Successfully tested priority levels: %s", priorities) finally: # Cleanup @@ -342,7 +342,7 @@ async def test_todo_with_categories( try: result = await nc_client.calendar.create_todo(calendar_name, todo_data) todo_uid = result["uid"] - logger.info(f"Created todo with categories, UID: {todo_uid}") + logger.info("Created todo with categories, UID: %s", todo_uid) # Verify categories todos = await nc_client.calendar.list_todos(calendar_name) @@ -360,7 +360,7 @@ async def test_todo_with_categories( await nc_client.calendar.delete_todo(calendar_name, todo_uid) except Exception as e: - logger.error(f"Categories test failed: {e}") + logger.error("Categories test failed: %s", e) raise @@ -399,7 +399,7 @@ async def test_search_todos_across_calendars( assert todo1["calendar_name"] == cal1_name assert todo2["calendar_name"] == cal2_name - logger.info(f"Found {len(all_todos)} todos across all calendars") + logger.info("Found %s todos across all calendars", len(all_todos)) finally: # Cleanup: Delete only the todos we created (calendars are reused/built-in) @@ -428,7 +428,7 @@ async def test_get_nonexistent_todo( matching_todos = [t for t in todos if t.get("uid") == fake_uid] assert len(matching_todos) == 0 - logger.info(f"Verified nonexistent todo UID: {fake_uid}") + logger.info("Verified nonexistent todo UID: %s", fake_uid) async def test_delete_nonexistent_todo( @@ -440,7 +440,7 @@ async def test_delete_nonexistent_todo( result = await nc_client.calendar.delete_todo(calendar_name, fake_uid) assert result["status_code"] == 404 - logger.info(f"Correctly got 404 for deleting nonexistent todo: {fake_uid}") + logger.info("Correctly got 404 for deleting nonexistent todo: %s", fake_uid) async def test_list_todos_with_filters( @@ -487,7 +487,7 @@ async def test_list_todos_with_filters( our_todo_uids = [t["uid"] for t in all_todos if t["uid"] in created_uids] assert len(our_todo_uids) == 3 - logger.info(f"Successfully created and listed {len(created_uids)} test todos") + logger.info("Successfully created and listed %s test todos", len(created_uids)) finally: # Cleanup diff --git a/tests/client/contacts/test_contacts_operations.py b/tests/client/contacts/test_contacts_operations.py index 85628dc9..9a496a80 100644 --- a/tests/client/contacts/test_contacts_operations.py +++ b/tests/client/contacts/test_contacts_operations.py @@ -22,7 +22,7 @@ async def test_list_addressbooks(nc_client: NextcloudClient): if not addressbooks: pytest.skip("No addressbooks available - Contacts app may not be enabled") - logger.info(f"Found {len(addressbooks)} addressbooks") + logger.info("Found %s addressbooks", len(addressbooks)) # Check structure of addressbooks for addressbook in addressbooks: @@ -31,7 +31,7 @@ async def test_list_addressbooks(nc_client: NextcloudClient): assert "getctag" in addressbook logger.info( - f"Addressbook: {addressbook['name']} - {addressbook['display_name']}" + "Addressbook: %s - %s", addressbook["name"], addressbook["display_name"] ) diff --git a/tests/client/notes/test_attachments.py b/tests/client/notes/test_attachments.py index 3ea4025d..24540e78 100644 --- a/tests/client/notes/test_attachments.py +++ b/tests/client/notes/test_attachments.py @@ -27,14 +27,18 @@ async def test_attachments_add_and_get( note_category = note_data.get("category") # Get category from fixture data logger.info( - f"Attempting to retrieve attachment '{attachment_filename}' added by fixture for note ID: {note_id}" + "Attempting to retrieve attachment '%s' added by fixture for note ID: %s", + attachment_filename, + note_id, ) # Pass category to get_note_attachment retrieved_content, retrieved_mime = await nc_client.webdav.get_note_attachment( note_id=note_id, filename=attachment_filename, category=note_category ) logger.info( - f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes" + "Attachment retrieved. Mime type: %s, Size: %s bytes", + retrieved_mime, + len(retrieved_content), ) assert retrieved_content == attachment_content @@ -55,7 +59,9 @@ async def test_attachments_add_to_note_with_category( note_id = note_data["id"] note_category = note_data["category"] logger.info( - f"Using note ID: {note_id} with category '{note_category}' for attachment test." + "Using note ID: %s with category '%s' for attachment test.", + note_id, + note_category, ) # Add attachment within the test @@ -65,7 +71,7 @@ async def test_attachments_add_to_note_with_category( attachment_mime = "text/plain" logger.info( - f"Attempting to add attachment '{attachment_filename}' to note ID: {note_id}" + "Attempting to add attachment '%s' to note ID: %s", attachment_filename, note_id ) # Pass category to add_note_attachment upload_response = await nc_client.webdav.add_note_attachment( @@ -78,13 +84,17 @@ async def test_attachments_add_to_note_with_category( assert upload_response and "status_code" in upload_response assert upload_response["status_code"] in [201, 204] logger.info( - f"Attachment '{attachment_filename}' added successfully (Status: {upload_response['status_code']})." + "Attachment '%s' added successfully (Status: %s).", + attachment_filename, + upload_response["status_code"], ) time.sleep(1) # Get and Verify Attachment logger.info( - f"Attempting to retrieve attachment '{attachment_filename}' from note ID: {note_id}" + "Attempting to retrieve attachment '%s' from note ID: %s", + attachment_filename, + note_id, ) # Pass category to get_note_attachment retrieved_content, retrieved_mime = await nc_client.webdav.get_note_attachment( @@ -93,7 +103,9 @@ async def test_attachments_add_to_note_with_category( category=note_category, # Pass the note's category ) logger.info( - f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes" + "Attachment retrieved. Mime type: %s, Size: %s bytes", + retrieved_mime, + len(retrieved_content), ) assert retrieved_content == attachment_content @@ -123,24 +135,28 @@ async def test_attachments_cleanup_on_note_delete( # Instead, we will manually delete the note here and verify the attachment is gone. logger.info( - f"Attachment '{attachment_filename}' exists for note {note_id} (added by fixture)." + "Attachment '%s' exists for note %s (added by fixture).", + attachment_filename, + note_id, ) # Manually delete the note - logger.info(f"Manually deleting note ID: {note_id} within the test.") + logger.info("Manually deleting note ID: %s within the test.", note_id) await nc_client.notes.delete_note(note_id=note_id) - logger.info(f"Note ID: {note_id} deleted successfully.") + logger.info("Note ID: %s deleted successfully.", note_id) time.sleep(1) # Verify Note Is Deleted with pytest.raises(HTTPStatusError) as excinfo_note: await nc_client.notes.get_note(note_id=note_id) assert excinfo_note.value.response.status_code == 404 - logger.info(f"Verified note {note_id} deletion (404 received).") + logger.info("Verified note %s deletion (404 received).", note_id) # Verify Attachment Is Deleted (via 404 on GET) logger.info( - f"Verifying attachment '{attachment_filename}' is deleted for note ID: {note_id}" + "Verifying attachment '%s' is deleted for note ID: %s", + attachment_filename, + note_id, ) with pytest.raises(HTTPStatusError) as excinfo_attach: # Pass category to get_note_attachment - although it should fail anyway @@ -154,7 +170,8 @@ async def test_attachments_cleanup_on_note_delete( # Expect 404 because the note itself is gone assert excinfo_attach.value.response.status_code == 404 logger.info( - f"Attachment '{attachment_filename}' correctly not found (404) after note deletion." + "Attachment '%s' correctly not found (404) after note deletion.", + attachment_filename, ) # Directly verify attachment directory doesn't exist using WebDAV PROPFIND @@ -172,7 +189,7 @@ async def test_attachments_cleanup_on_note_delete( status = propfind_resp.status_code if status in [200, 207]: # Successful PROPFIND means directory exists logger.error( - f"Attachment directory still exists! PROPFIND returned {status}" + "Attachment directory still exists! PROPFIND returned %s", status ) assert False, ( f"Expected attachment directory to be gone, but PROPFIND returned {status}!" @@ -205,18 +222,21 @@ async def test_attachments_category_change_handling(nc_client: NextcloudClient): try: # 1. Create note with initial category - logger.info(f"Creating note '{note_title}' in category '{initial_category}'") + logger.info("Creating note '%s' in category '%s'", note_title, initial_category) created_note = await nc_client.notes.create_note( title=note_title, content="Initial content", category=initial_category ) note_id = created_note["id"] etag1 = created_note["etag"] - logger.info(f"Note created with ID: {note_id}, Etag: {etag1}") + logger.info("Note created with ID: %s, Etag: %s", note_id, etag1) time.sleep(1) # 2. Add attachment (passing initial category) logger.info( - f"Adding attachment '{attachment_filename}' to note {note_id} (in {initial_category})" + "Adding attachment '%s' to note %s (in %s)", + attachment_filename, + note_id, + initial_category, ) upload_response = await nc_client.webdav.add_note_attachment( note_id=note_id, @@ -231,7 +251,8 @@ async def test_attachments_category_change_handling(nc_client: NextcloudClient): # 3. Verify attachment retrieval from initial category (passing initial category) logger.info( - f"Verifying attachment retrieval from initial category '{initial_category}'" + "Verifying attachment retrieval from initial category '%s'", + initial_category, ) retrieved_content1, _ = await nc_client.webdav.get_note_attachment( note_id=note_id, filename=attachment_filename, category=initial_category @@ -241,7 +262,10 @@ async def test_attachments_category_change_handling(nc_client: NextcloudClient): # 4. Update note category (with retry for ETag conflicts from background scanner) logger.info( - f"Updating note {note_id} category from '{initial_category}' to '{new_category}'" + "Updating note %s category from '%s' to '%s'", + note_id, + initial_category, + new_category, ) # Retry logic for 412 Precondition Failed (ETag conflict) # This can happen if the background vector scanner touches the note @@ -252,7 +276,10 @@ async def test_attachments_category_change_handling(nc_client: NextcloudClient): current_note_data = await nc_client.notes.get_note(note_id=note_id) current_etag = current_note_data["etag"] logger.info( - f"Update attempt {attempt + 1}/{max_update_attempts}, current etag: {current_etag}" + "Update attempt %s/%s, current etag: %s", + attempt + 1, + max_update_attempts, + current_etag, ) updated_note = await nc_client.notes.update( @@ -264,14 +291,14 @@ async def test_attachments_category_change_handling(nc_client: NextcloudClient): ) etag3 = updated_note["etag"] assert updated_note["category"] == new_category - logger.info(f"Note category updated successfully. New Etag: {etag3}") + logger.info("Note category updated successfully. New Etag: %s", etag3) break # Success, exit retry loop except HTTPStatusError as e: if e.response.status_code == 412 and attempt < max_update_attempts - 1: # ETag conflict (likely from background scanner), retry logger.warning( - f"ETag conflict (412) on attempt {attempt + 1}, retrying..." + "ETag conflict (412) on attempt %s, retrying...", attempt + 1 ) time.sleep(1) # Brief delay before retry continue @@ -283,7 +310,7 @@ async def test_attachments_category_change_handling(nc_client: NextcloudClient): # 5. Verify attachment retrieval from *new* category (passing new category) logger.info( - f"Verifying attachment retrieval from new category '{new_category}'" + "Verifying attachment retrieval from new category '%s'", new_category ) retrieved_content2, _ = await nc_client.webdav.get_note_attachment( note_id=note_id, filename=attachment_filename, category=new_category @@ -305,7 +332,8 @@ async def test_attachments_category_change_handling(nc_client: NextcloudClient): status = propfind_resp.status_code if status in [200, 207]: # Successful PROPFIND means directory exists logger.error( - f"Old attachment directory still exists! PROPFIND returned {status}" + "Old attachment directory still exists! PROPFIND returned %s", + status, ) assert False, ( f"Expected old directory to be gone, but PROPFIND returned {status} - directory still exists!" @@ -333,11 +361,13 @@ async def test_attachments_category_change_handling(nc_client: NextcloudClient): 200, ], f"Expected PROPFIND to return success (207/200), got {status}" logger.info( - f"Verified new attachment directory exists via PROPFIND ({status} received)" + "Verified new attachment directory exists via PROPFIND (%s received)", + status, ) except HTTPStatusError as e: logger.error( - f"New attachment directory not found! PROPFIND failed with {e.response.status_code}" + "New attachment directory not found! PROPFIND failed with %s", + e.response.status_code, ) assert False, ( f"Expected new attachment directory to exist, but PROPFIND failed with {e.response.status_code}" @@ -347,11 +377,13 @@ async def test_attachments_category_change_handling(nc_client: NextcloudClient): # 6. Cleanup: Delete the note (client should use the *final* category for cleanup path) if note_id: logger.info( - f"Cleaning up note ID: {note_id} (last known category: '{new_category}')" + "Cleaning up note ID: %s (last known category: '%s')", + note_id, + new_category, ) try: await nc_client.notes.delete_note(note_id=note_id) - logger.info(f"Note {note_id} deleted.") + logger.info("Note %s deleted.", note_id) time.sleep(1) # Verify note deletion with pytest.raises(HTTPStatusError) as excinfo_note_del: @@ -424,4 +456,4 @@ async def test_attachments_category_change_handling(nc_client: NextcloudClient): "Verified all attachment directories are properly cleaned up." ) except Exception as e: - logger.error(f"Error during cleanup for note {note_id}: {e}") + logger.error("Error during cleanup for note %s: %s", note_id, e) diff --git a/tests/client/notes/test_embedded_images.py b/tests/client/notes/test_embedded_images.py index 506675f8..44154db4 100644 --- a/tests/client/notes/test_embedded_images.py +++ b/tests/client/notes/test_embedded_images.py @@ -37,7 +37,7 @@ def test_image_data() -> tuple[bytes, str]: img.save(img_byte_arr, format="PNG") image_bytes = img_byte_arr.getvalue() suggested_filename = "test_image.png" - logger.info(f"Generated test image data ({len(image_bytes)} bytes).") + logger.info("Generated test image data (%s bytes).", len(image_bytes)) return image_bytes, suggested_filename @@ -61,7 +61,10 @@ async def test_note_with_embedded_image( # 1. Upload the image as an attachment note_category = note_data.get("category") # Get category from fixture data logger.info( - f"Uploading image attachment '{attachment_filename}' to note {note_id} (category: '{note_category or ''}')..." + "Uploading image attachment '%s' to note %s (category: '%s')...", + attachment_filename, + note_id, + note_category or "", ) upload_response = await nc_client.webdav.add_note_attachment( note_id=note_id, @@ -72,7 +75,7 @@ async def test_note_with_embedded_image( ) assert upload_response and upload_response.get("status_code") in [201, 204] logger.info( - f"Image uploaded successfully (Status: {upload_response.get('status_code')})." + "Image uploaded successfully (Status: %s).", upload_response.get("status_code") ) time.sleep(1) # Allow potential processing time @@ -94,11 +97,12 @@ async def test_note_with_embedded_image( 200, ], f"Expected PROPFIND to return success (207/200), got {status}" logger.info( - f"Verified attachment directory exists via PROPFIND ({status} received)" + "Verified attachment directory exists via PROPFIND (%s received)", status ) except HTTPStatusError as e: logger.error( - f"Attachment directory not found! PROPFIND failed with {e.response.status_code}" + "Attachment directory not found! PROPFIND failed with %s", + e.response.status_code, ) assert False, ( f"Expected attachment directory to exist, but PROPFIND failed with {e.response.status_code}" @@ -135,7 +139,9 @@ async def test_note_with_embedded_image( # 4. Verify the image attachment can be retrieved logger.info( - f"Retrieving image attachment '{attachment_filename}' (category: '{note_category or ''}')..." + "Retrieving image attachment '%s' (category: '%s')...", + attachment_filename, + note_category or "", ) # Pass category to get_note_attachment retrieved_img_content, mime_type = await nc_client.webdav.get_note_attachment( @@ -149,17 +155,17 @@ async def test_note_with_embedded_image( # 5. Manually trigger deletion to verify cleanup (instead of waiting for fixture teardown) logger.info( - f"Manually deleting note ID: {note_id} to verify proper attachment cleanup" + "Manually deleting note ID: %s to verify proper attachment cleanup", note_id ) await nc_client.notes.delete_note(note_id=note_id) - logger.info(f"Note ID: {note_id} deleted successfully.") + logger.info("Note ID: %s deleted successfully.", note_id) time.sleep(1) # 6. Verify note is deleted with pytest.raises(HTTPStatusError) as excinfo_note: await nc_client.notes.get_note(note_id=note_id) assert excinfo_note.value.response.status_code == 404 - logger.info(f"Verified note {note_id} deletion (404 received).") + logger.info("Verified note %s deletion (404 received).", note_id) # 7. Verify attachment directory is deleted via WebDAV PROPFIND logger.info("Directly verifying attachment directory doesn't exist via PROPFIND") @@ -170,7 +176,7 @@ async def test_note_with_embedded_image( status = propfind_resp.status_code if status in [200, 207]: # Successful PROPFIND means directory exists logger.error( - f"Attachment directory still exists! PROPFIND returned {status}" + "Attachment directory still exists! PROPFIND returned %s", status ) assert False, ( f"Expected attachment directory to be gone, but PROPFIND returned {status}!" diff --git a/tests/client/test_sharing_api.py b/tests/client/test_sharing_api.py index 321b535d..20e6502f 100644 --- a/tests/client/test_sharing_api.py +++ b/tests/client/test_sharing_api.py @@ -39,7 +39,7 @@ async def test_create_and_delete_share(nc_client): assert share_data is not None assert "id" in share_data share_id = share_data["id"] - logger.info(f"Created share: {share_id}") + logger.info("Created share: %s", share_id) # Get share info share_info = await nc_client.sharing.get_share(share_id) @@ -56,7 +56,7 @@ async def test_create_and_delete_share(nc_client): # Cleanup if share_id: await nc_client.sharing.delete_share(share_id) - logger.info(f"Deleted share: {share_id}") + logger.info("Deleted share: %s", share_id) await nc_client.webdav.delete_resource(file_path) diff --git a/tests/client/webdav/test_webdav_cleanup.py b/tests/client/webdav/test_webdav_cleanup.py index 84152d7d..747e17c3 100644 --- a/tests/client/webdav/test_webdav_cleanup.py +++ b/tests/client/webdav/test_webdav_cleanup.py @@ -29,18 +29,21 @@ async def test_category_change_cleans_up_old_attachments_directory( try: # 1. Create note with initial category - logger.info(f"Creating note '{note_title}' in category '{initial_category}'") + logger.info("Creating note '%s' in category '%s'", note_title, initial_category) created_note = await nc_client.notes.create_note( title=note_title, content="Initial content", category=initial_category ) note_id = created_note["id"] etag1 = created_note["etag"] - logger.info(f"Note created with ID: {note_id}, Etag: {etag1}") + logger.info("Note created with ID: %s, Etag: %s", note_id, etag1) time.sleep(1) # 2. Add attachment (passing initial category) logger.info( - f"Adding attachment '{attachment_filename}' to note {note_id} (in {initial_category})" + "Adding attachment '%s' to note %s (in %s)", + attachment_filename, + note_id, + initial_category, ) upload_response = await nc_client.webdav.add_note_attachment( note_id=note_id, @@ -55,7 +58,8 @@ async def test_category_change_cleans_up_old_attachments_directory( # 3. Verify attachment retrieval from initial category logger.info( - f"Verifying attachment retrieval from initial category '{initial_category}'" + "Verifying attachment retrieval from initial category '%s'", + initial_category, ) retrieved_content1, _ = await nc_client.webdav.get_note_attachment( note_id=note_id, filename=attachment_filename, category=initial_category @@ -65,13 +69,16 @@ async def test_category_change_cleans_up_old_attachments_directory( # 4. Construct and check the WebDAV path for the initial category's attachment directory initial_webdav_path = f"Notes/{initial_category}/.attachments.{note_id}" - logger.info(f"Initial WebDAV path for attachments: {initial_webdav_path}") + logger.info("Initial WebDAV path for attachments: %s", initial_webdav_path) # Here we would check if the directory exists, but the WebDAV client doesn't directly # expose directory listing functionality, so we'll infer from attachment retrieval success # 5. Update note category logger.info( - f"Updating note {note_id} category from '{initial_category}' to '{new_category}'" + "Updating note %s category from '%s' to '%s'", + note_id, + initial_category, + new_category, ) current_note_data = await nc_client.notes.get_note(note_id=note_id) current_etag = current_note_data["etag"] @@ -84,12 +91,12 @@ async def test_category_change_cleans_up_old_attachments_directory( ) etag3 = updated_note["etag"] assert updated_note["category"] == new_category - logger.info(f"Note category updated successfully. New Etag: {etag3}") + logger.info("Note category updated successfully. New Etag: %s", etag3) time.sleep(1) # 6. Verify attachment retrieval from new category logger.info( - f"Verifying attachment retrieval from new category '{new_category}'" + "Verifying attachment retrieval from new category '%s'", new_category ) retrieved_content2, _ = await nc_client.webdav.get_note_attachment( note_id=note_id, filename=attachment_filename, category=new_category @@ -99,7 +106,8 @@ async def test_category_change_cleans_up_old_attachments_directory( # 7. Try to retrieve from old category - this should fail logger.info( - f"Trying to retrieve attachment from old category '{initial_category}' - should fail" + "Trying to retrieve attachment from old category '%s' - should fail", + initial_category, ) try: await nc_client.webdav.get_note_attachment( @@ -115,7 +123,8 @@ async def test_category_change_cleans_up_old_attachments_directory( except HTTPStatusError as e: # This is the expected outcome - old directory should be gone logger.info( - f"Correctly got error accessing old category path: {e.response.status_code}" + "Correctly got error accessing old category path: %s", + e.response.status_code, ) assert e.response.status_code == 404, ( f"Expected 404, got {e.response.status_code}" @@ -143,14 +152,16 @@ async def test_category_change_cleans_up_old_attachments_directory( 207, ]: # Success codes indicate the directory exists (a problem) logger.error( - f"Old attachment directory still exists! PROPFIND returned {status}" + "Old attachment directory still exists! PROPFIND returned %s", + status, ) assert False, ( f"Expected old attachment directory to be gone, but it still exists (PROPFIND returned {status})!" ) # If we got another status code (like 404), it's also good - the directory doesn't exist logger.info( - f"Verified old attachment directory does not exist (PROPFIND returned {status})" + "Verified old attachment directory does not exist (PROPFIND returned %s)", + status, ) except HTTPStatusError as e: # 404 is expected - directory should not exist @@ -164,10 +175,10 @@ async def test_category_change_cleans_up_old_attachments_directory( finally: # 8. Cleanup: Delete the note if note_id: - logger.info(f"Cleaning up note ID: {note_id}") + logger.info("Cleaning up note ID: %s", note_id) try: await nc_client.notes.delete_note(note_id=note_id) - logger.info(f"Note {note_id} deleted.") + logger.info("Note %s deleted.", note_id) time.sleep(1) # 9. Verify both old and new attachment paths are gone @@ -209,14 +220,16 @@ async def test_category_change_cleans_up_old_attachments_directory( 207, ]: # Success codes indicate the directory exists (a problem) logger.error( - f"New category attachment directory still exists! PROPFIND returned {status}" + "New category attachment directory still exists! PROPFIND returned %s", + status, ) assert False, ( f"Expected new category attachment directory to be gone, but it still exists (PROPFIND returned {status})!" ) # If we got another status code (like 404), it's also good - the directory doesn't exist logger.info( - f"Verified new category attachment directory does not exist (PROPFIND returned {status})" + "Verified new category attachment directory does not exist (PROPFIND returned %s)", + status, ) except HTTPStatusError as e: assert e.response.status_code == 404, ( @@ -240,14 +253,16 @@ async def test_category_change_cleans_up_old_attachments_directory( 207, ]: # Success codes indicate the directory exists (a problem) logger.error( - f"Old category attachment directory still exists! PROPFIND returned {status}" + "Old category attachment directory still exists! PROPFIND returned %s", + status, ) assert False, ( f"Expected old category attachment directory to be gone, but it still exists (PROPFIND returned {status})!" ) # If we got another status code (like 404), it's also good - the directory doesn't exist logger.info( - f"Verified old category attachment directory does not exist (PROPFIND returned {status})" + "Verified old category attachment directory does not exist (PROPFIND returned %s)", + status, ) except HTTPStatusError as e: assert e.response.status_code == 404, ( @@ -261,4 +276,4 @@ async def test_category_change_cleans_up_old_attachments_directory( "Verified all attachment directories are properly cleaned up." ) except Exception as e: - logger.error(f"Error during cleanup for note {note_id}: {e}") + logger.error("Error during cleanup for note %s: %s", note_id, e) diff --git a/tests/client/webdav/test_webdav_operations.py b/tests/client/webdav/test_webdav_operations.py index 08973181..fafc3cae 100644 --- a/tests/client/webdav/test_webdav_operations.py +++ b/tests/client/webdav/test_webdav_operations.py @@ -33,7 +33,7 @@ async def test_create_and_delete_directory( # Create directory result = await nc_client.webdav.create_directory(test_dir) assert result["status_code"] == 201 # Created - logger.info(f"Created directory: {test_dir}") + logger.info("Created directory: %s", test_dir) # Verify directory exists by listing parent parent_listing = await nc_client.webdav.list_directory(test_base_path) @@ -43,7 +43,7 @@ async def test_create_and_delete_directory( # Delete directory delete_result = await nc_client.webdav.delete_resource(test_dir) assert delete_result["status_code"] in [204, 404] # No Content or Not Found - logger.info(f"Deleted directory: {test_dir}") + logger.info("Deleted directory: %s", test_dir) finally: # Cleanup: ensure directory is deleted @@ -67,13 +67,13 @@ async def test_write_read_delete_file(nc_client: NextcloudClient, test_base_path test_file, test_content.encode("utf-8"), content_type="text/plain" ) assert write_result["status_code"] in [200, 201, 204] # Success codes - logger.info(f"Wrote file: {test_file}") + logger.info("Wrote file: %s", test_file) # Read file back content, content_type = await nc_client.webdav.read_file(test_file) assert content.decode("utf-8") == test_content assert "text/plain" in content_type - logger.info(f"Read file: {test_file}") + logger.info("Read file: %s", test_file) # Verify file appears in directory listing listing = await nc_client.webdav.list_directory(test_base_path) @@ -83,7 +83,7 @@ async def test_write_read_delete_file(nc_client: NextcloudClient, test_base_path # Delete file delete_result = await nc_client.webdav.delete_resource(test_file) assert delete_result["status_code"] in [204, 404] # No Content or Not Found - logger.info(f"Deleted file: {test_file}") + logger.info("Deleted file: %s", test_file) finally: # Cleanup @@ -106,7 +106,7 @@ async def test_list_directory_empty_and_populated( empty_listing = await nc_client.webdav.list_directory(test_base_path) assert isinstance(empty_listing, list) assert len(empty_listing) == 0 - logger.info(f"Empty directory listing: {len(empty_listing)} items") + logger.info("Empty directory listing: %s items", len(empty_listing)) # Add some files and directories await nc_client.webdav.create_directory(f"{test_base_path}/subdir1") @@ -140,7 +140,7 @@ async def test_list_directory_empty_and_populated( assert "content_type" in item assert "last_modified" in item - logger.info(f"Populated directory listing: {len(populated_listing)} items") + logger.info("Populated directory listing: %s items", len(populated_listing)) finally: # Cleanup @@ -162,7 +162,7 @@ async def test_read_nonexistent_file(nc_client: NextcloudClient): await nc_client.webdav.read_file(nonexistent_file) assert exc_info.value.response.status_code == 404 - logger.info(f"Correctly got 404 for nonexistent file: {nonexistent_file}") + logger.info("Correctly got 404 for nonexistent file: %s", nonexistent_file) async def test_delete_nonexistent_resource(nc_client: NextcloudClient): @@ -171,7 +171,7 @@ async def test_delete_nonexistent_resource(nc_client: NextcloudClient): result = await nc_client.webdav.delete_resource(nonexistent_resource) assert result["status_code"] == 404 - logger.info(f"Correctly got 404 for nonexistent resource: {nonexistent_resource}") + logger.info("Correctly got 404 for nonexistent resource: %s", nonexistent_resource) async def test_create_nested_directories( @@ -200,7 +200,7 @@ async def test_create_nested_directories( assert level2_listing[0]["name"] == "level3" assert level2_listing[0]["is_directory"] is True - logger.info(f"Created nested directory structure: {nested_path}") + logger.info("Created nested directory structure: %s", nested_path) finally: # Cleanup - delete from deepest to shallowest @@ -241,7 +241,7 @@ async def test_overwrite_existing_file(nc_client: NextcloudClient, test_base_pat content, _ = await nc_client.webdav.read_file(test_file) assert content.decode("utf-8") == new_content - logger.info(f"Successfully overwrote file: {test_file}") + logger.info("Successfully overwrote file: %s", test_file) finally: # Cleanup @@ -270,4 +270,4 @@ async def test_list_root_directory(nc_client: NextcloudClient): assert "content_type" in item assert "last_modified" in item - logger.info(f"Root directory contains {len(root_listing)} items") + logger.info("Root directory contains %s items", len(root_listing)) diff --git a/tests/client/webdav/test_webdav_search.py b/tests/client/webdav/test_webdav_search.py index 81cd83e9..5a8a0d23 100644 --- a/tests/client/webdav/test_webdav_search.py +++ b/tests/client/webdav/test_webdav_search.py @@ -47,16 +47,16 @@ async def test_search_setup(nc_client: NextcloudClient): for file_path, content, content_type in test_files: await nc_client.webdav.write_file(file_path, content, content_type) - logger.info(f"Created test directory with {len(test_files)} files: {test_dir}") + logger.info("Created test directory with %s files: %s", len(test_files), test_dir) yield test_dir # Cleanup try: await nc_client.webdav.delete_resource(test_dir) - logger.info(f"Cleaned up test directory: {test_dir}") + logger.info("Cleaned up test directory: %s", test_dir) except Exception as e: - logger.warning(f"Failed to cleanup test directory {test_dir}: {e}") + logger.warning("Failed to cleanup test directory %s: %s", test_dir, e) async def test_find_by_name_exact(nc_client: NextcloudClient, test_search_setup: str): @@ -69,7 +69,7 @@ async def test_find_by_name_exact(nc_client: NextcloudClient, test_search_setup: readme_files = [r for r in results if r.get("name") == "readme.md"] assert len(readme_files) >= 1, "Should find readme.md" - logger.info(f"Found {len(results)} files matching 'readme.md'") + logger.info("Found %s files matching 'readme.md'", len(results)) async def test_find_by_name_wildcard_extension( @@ -86,7 +86,7 @@ async def test_find_by_name_wildcard_extension( name = result.get("name", "") assert name.endswith(".txt"), f"Expected .txt file, got {name}" - logger.info(f"Found {len(results)} .txt files") + logger.info("Found %s .txt files", len(results)) async def test_find_by_name_wildcard_prefix( @@ -105,7 +105,7 @@ async def test_find_by_name_wildcard_prefix( f"Expected name to start with 'document', got {name}" ) - logger.info(f"Found {len(results)} files starting with 'document'") + logger.info("Found %s files starting with 'document'", len(results)) async def test_find_by_type_text(nc_client: NextcloudClient, test_search_setup: str): @@ -122,7 +122,7 @@ async def test_find_by_type_text(nc_client: NextcloudClient, test_search_setup: f"Expected text/* type, got {content_type}" ) - logger.info(f"Found {len(results)} text files") + logger.info("Found %s text files", len(results)) async def test_find_by_type_specific( @@ -143,7 +143,7 @@ async def test_find_by_type_specific( f"Expected application/pdf, got {content_type}" ) - logger.info(f"Found {len(results)} PDF files") + logger.info("Found %s PDF files", len(results)) async def test_search_with_limit(nc_client: NextcloudClient, test_search_setup: str): @@ -157,7 +157,7 @@ async def test_search_with_limit(nc_client: NextcloudClient, test_search_setup: assert len(results) <= 2, f"Should return at most 2 results, got {len(results)}" assert len(results) > 0, "Should return at least 1 result" - logger.info(f"Found {len(results)} files with limit=2") + logger.info("Found %s files with limit=2", len(results)) async def test_search_files_combined_filters( @@ -198,7 +198,7 @@ async def test_search_files_combined_filters( f"Expected name to start with 'document', got {name}" ) - logger.info(f"Found {len(results)} files matching combined filters") + logger.info("Found %s files matching combined filters", len(results)) async def test_search_empty_scope(nc_client: NextcloudClient, test_search_setup: str): @@ -210,7 +210,7 @@ async def test_search_empty_scope(nc_client: NextcloudClient, test_search_setup: # Should find at least the one we created assert len(results) >= 1, f"Should find at least 1 file named {unique_name}" - logger.info(f"Found {len(results)} files in root scope") + logger.info("Found %s files in root scope", len(results)) async def test_search_subdirectory(nc_client: NextcloudClient, test_search_setup: str): @@ -226,7 +226,7 @@ async def test_search_subdirectory(nc_client: NextcloudClient, test_search_setup nested_file = results[0] assert "nested.txt" in nested_file.get("name", ""), "Should find nested.txt" - logger.info(f"Found file in subdirectory: {nested_file.get('name')}") + logger.info("Found file in subdirectory: %s", nested_file.get("name")) async def test_search_no_results(nc_client: NextcloudClient, test_search_setup: str): @@ -259,10 +259,10 @@ async def test_search_properties_returned( # Optional properties that may be present optional_props = ["size", "content_type", "last_modified", "etag"] - logger.info(f"Result properties: {list(result.keys())}") + logger.info("Result properties: %s", list(result.keys())) # At least some optional properties should be present present_optional = [prop for prop in optional_props if prop in result] assert len(present_optional) > 0, f"Should have at least one of {optional_props}" - logger.info(f"Search returned properties: {list(result.keys())}") + logger.info("Search returned properties: %s", list(result.keys())) diff --git a/tests/conftest.py b/tests/conftest.py index f131c7e9..eb5fedbe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -93,7 +93,7 @@ async def wait_for_nextcloud( Returns: True if server is ready, False otherwise """ - logger.info(f"Waiting for Nextcloud server at {host} to be ready...") + logger.info("Waiting for Nextcloud server at %s to be ready...", host) async with httpx.AsyncClient(timeout=5.0) as client: for attempt in range(1, max_attempts + 1): @@ -104,20 +104,26 @@ async def wait_for_nextcloud( data = response.json() if data.get("installed"): logger.info( - f"Nextcloud server is ready (version: {data.get('versionstring', 'unknown')})" + "Nextcloud server is ready (version: %s)", + data.get("versionstring", "unknown"), ) return True except (httpx.RequestError, httpx.TimeoutException) as e: - logger.debug(f"Attempt {attempt}/{max_attempts}: {e}") + logger.debug("Attempt %s/%s: %s", attempt, max_attempts, e) if attempt < max_attempts: logger.info( - f"Nextcloud not ready yet, waiting {delay}s... (attempt {attempt}/{max_attempts})" + "Nextcloud not ready yet, waiting %ss... (attempt %s/%s)", + delay, + attempt, + max_attempts, ) await anyio.sleep(delay) logger.error( - f"Nextcloud server at {host} did not become ready after {max_attempts} attempts" + "Nextcloud server at %s did not become ready after %s attempts", + host, + max_attempts, ) return False @@ -163,7 +169,7 @@ async def create_mcp_client_session( requirements are met, as Python guarantees LIFO cleanup order for nested context managers. See: https://github.com/modelcontextprotocol/python-sdk/issues/577 """ - logger.info(f"Creating Streamable HTTP client for {client_name}") + logger.info("Creating Streamable HTTP client for %s", client_name) # Prepare headers - custom headers take precedence over token-based auth if headers is None: @@ -183,11 +189,11 @@ async def create_mcp_client_session( sampling_callback=sampling_callback, ) as session: await session.initialize() - logger.info(f"{client_name} client session initialized successfully") + logger.info("%s client session initialized successfully", client_name) yield session # Cleanup happens automatically in LIFO order - no exception suppression needed - logger.debug(f"{client_name} client session cleaned up successfully") + logger.debug("%s client session cleaned up successfully", client_name) @pytest.fixture(scope="session") @@ -219,7 +225,7 @@ async def nc_client(anyio_backend) -> AsyncGenerator[NextcloudClient, Any]: ) yield client except Exception as e: - logger.error(f"Failed to initialize NextcloudClient session fixture: {e}") + logger.error("Failed to initialize NextcloudClient session fixture: %s", e) pytest.fail(f"Failed to connect to Nextcloud or get capabilities: {e}") finally: await client.close() @@ -352,8 +358,8 @@ async def nc_mcp_oauth_client_with_elicitation( elicitation_triggered["count"] += 1 logger.info("🎯 Elicitation callback invoked!") - logger.info(f" Message: {params.message[:100]}...") - logger.info(f" Schema: {params.schema}") + logger.info(" Message: %s...", params.message[:100]) + logger.info(" Schema: %s", params.schema) # Extract OAuth URL from elicitation message @@ -362,11 +368,11 @@ async def nc_mcp_oauth_client_with_elicitation( if not urls: error_msg = "No URL found in elicitation message" - logger.error(f"❌ {error_msg}") + logger.error("❌ %s", error_msg) return ErrorData(code=-32602, message=error_msg) oauth_url = urls[0] - logger.info(f" Extracted URL: {oauth_url}") + logger.info(" Extracted URL: %s", oauth_url) # Complete OAuth flow with Playwright page = await browser.new_page() @@ -375,7 +381,7 @@ async def nc_mcp_oauth_client_with_elicitation( await page.goto(oauth_url, timeout=60000) current_url = page.url - logger.info(f" Current URL after navigation: {current_url}") + logger.info(" Current URL after navigation: %s", current_url) # Handle login form if present if "/login" in current_url or "/index.php/login" in current_url: @@ -400,21 +406,21 @@ async def nc_mcp_oauth_client_with_elicitation( if "/consent" in page.url: await page.wait_for_load_state("networkidle", timeout=10000) try: - logger.info(f" Current URL before consent: {page.url}") + logger.info(" Current URL before consent: %s", page.url) consent_handled = await _handle_oauth_consent_screen(page, username) if consent_handled: logger.info(" ✓ Consent granted") else: logger.warning(" ⚠ Consent handler returned False") except Exception as e: - logger.warning(f" ⚠ Consent screen handling failed: {e}") + logger.warning(" ⚠ Consent screen handling failed: %s", e) screenshot_path = ( f"/tmp/elicitation_consent_error_{uuid.uuid4()}.png" ) await page.screenshot(path=screenshot_path) - logger.info(f" Screenshot saved: {screenshot_path}") + logger.info(" Screenshot saved: %s", screenshot_path) else: - logger.debug(f" No consent screen (URL: {page.url})") + logger.debug(" No consent screen (URL: %s)", page.url) # Wait for OAuth callback URL to be reached # The MCP server's callback endpoint will handle token exchange @@ -426,30 +432,30 @@ async def nc_mcp_oauth_client_with_elicitation( await anyio.sleep(0.5) current_url = page.url if "/oauth/callback" in current_url or "/user" in current_url: - logger.info(f" ✓ Callback URL reached: {current_url}") + logger.info(" ✓ Callback URL reached: %s", current_url) break else: logger.warning( - f" ⚠ Timeout waiting for callback, final URL: {page.url}" + " ⚠ Timeout waiting for callback, final URL: %s", page.url ) # Wait a bit more to ensure the server processed the callback await anyio.sleep(2) final_url = page.url - logger.info(f" Final URL: {final_url}") + logger.info(" Final URL: %s", final_url) # Return success - user "accepted" the elicitation logger.info("✅ OAuth flow completed, returning accept") return ElicitResult(action="accept", content={"acknowledged": True}) except Exception as e: - logger.error(f"❌ Elicitation OAuth flow failed: {e}") + logger.error("❌ Elicitation OAuth flow failed: %s", e) # Take screenshot for debugging try: screenshot_path = f"/tmp/elicitation_oauth_failure_{uuid.uuid4()}.png" await page.screenshot(path=screenshot_path) - logger.error(f" Screenshot saved: {screenshot_path}") + logger.error(" Screenshot saved: %s", screenshot_path) except Exception: pass @@ -579,7 +585,7 @@ async def temporary_note(nc_client: NextcloudClient): note_category = "TemporaryTesting" created_note_data = None - logger.info(f"Creating temporary note: {note_title}") + logger.info("Creating temporary note: %s", note_title) try: created_note_data = await nc_client.notes.create_note( title=note_title, content=note_content, category=note_category @@ -588,23 +594,27 @@ async def temporary_note(nc_client: NextcloudClient): if not note_id: pytest.fail("Failed to get ID from created temporary note.") - logger.info(f"Temporary note created with ID: {note_id}") + logger.info("Temporary note created with ID: %s", note_id) yield created_note_data # Provide the created note data to the test finally: if note_id: - logger.info(f"Cleaning up temporary note ID: {note_id}") + logger.info("Cleaning up temporary note ID: %s", note_id) try: await nc_client.notes.delete_note(note_id=note_id) - logger.info(f"Successfully deleted temporary note ID: {note_id}") + logger.info("Successfully deleted temporary note ID: %s", note_id) except HTTPStatusError as e: # Ignore 404 if note was already deleted by the test itself if e.response.status_code != 404: - logger.error(f"HTTP error deleting temporary note {note_id}: {e}") + logger.error( + "HTTP error deleting temporary note %s: %s", note_id, e + ) else: - logger.warning(f"Temporary note {note_id} already deleted (404).") + logger.warning("Temporary note %s already deleted (404).", note_id) except Exception as e: - logger.error(f"Unexpected error deleting temporary note {note_id}: {e}") + logger.error( + "Unexpected error deleting temporary note %s: %s", note_id, e + ) @pytest.fixture @@ -617,31 +627,31 @@ async def temporary_note_factory(nc_client: NextcloudClient): async def _create_note(title: str, content: str, category: str = ""): """Create a temporary note with custom title, content, and category.""" - logger.info(f"Creating temporary note via factory: {title}") + logger.info("Creating temporary note via factory: %s", title) note_data = await nc_client.notes.create_note( title=title, content=content, category=category ) note_id = note_data.get("id") if note_id: created_notes.append(note_id) - logger.info(f"Factory created note ID: {note_id}") + logger.info("Factory created note ID: %s", note_id) return note_data yield _create_note # Cleanup all created notes for note_id in created_notes: - logger.info(f"Cleaning up factory-created note ID: {note_id}") + logger.info("Cleaning up factory-created note ID: %s", note_id) try: await nc_client.notes.delete_note(note_id=note_id) - logger.info(f"Successfully deleted factory note ID: {note_id}") + logger.info("Successfully deleted factory note ID: %s", note_id) except HTTPStatusError as e: if e.response.status_code != 404: - logger.error(f"HTTP error deleting factory note {note_id}: {e}") + logger.error("HTTP error deleting factory note %s: %s", note_id, e) else: - logger.warning(f"Factory note {note_id} already deleted (404).") + logger.warning("Factory note %s already deleted (404).", note_id) except Exception as e: - logger.error(f"Unexpected error deleting factory note {note_id}: {e}") + logger.error("Unexpected error deleting factory note %s: %s", note_id, e) @pytest.fixture @@ -663,7 +673,10 @@ async def temporary_note_with_attachment( attachment_mime = "text/plain" logger.info( - f"Adding attachment '{attachment_filename}' to temporary note ID: {note_id} (category: '{note_category or ''}')" + "Adding attachment '%s' to temporary note ID: %s (category: '%s')", + attachment_filename, + note_id, + note_category or "", ) try: # Pass the category to add_note_attachment @@ -678,7 +691,7 @@ async def temporary_note_with_attachment( 201, 204, ], f"Failed to upload attachment: {upload_response}" - logger.info(f"Attachment '{attachment_filename}' added successfully.") + logger.info("Attachment '%s' added successfully.", attachment_filename) yield note_data, attachment_filename, attachment_content @@ -686,7 +699,7 @@ async def temporary_note_with_attachment( # in the temporary_note fixture's finally block (which deletes the .attachments dir) except Exception as e: - logger.error(f"Failed to add attachment in fixture: {e}") + logger.error("Failed to add attachment in fixture: %s", e) pytest.fail(f"Fixture setup failed during attachment upload: {e}") # Note: The temporary_note fixture's finally block will handle note deletion, @@ -700,32 +713,36 @@ async def temporary_addressbook(nc_client: NextcloudClient): Yields the created addressbook dictionary. """ addressbook_name = f"test-addressbook-{uuid.uuid4().hex[:8]}" - logger.info(f"Creating temporary addressbook: {addressbook_name}") + logger.info("Creating temporary addressbook: %s", addressbook_name) try: await nc_client.contacts.create_addressbook( name=addressbook_name, display_name=f"Test Addressbook {addressbook_name}" ) - logger.info(f"Temporary addressbook created: {addressbook_name}") + logger.info("Temporary addressbook created: %s", addressbook_name) yield addressbook_name finally: - logger.info(f"Cleaning up temporary addressbook: {addressbook_name}") + logger.info("Cleaning up temporary addressbook: %s", addressbook_name) try: await nc_client.contacts.delete_addressbook(name=addressbook_name) logger.info( - f"Successfully deleted temporary addressbook: {addressbook_name}" + "Successfully deleted temporary addressbook: %s", addressbook_name ) except HTTPStatusError as e: if e.response.status_code != 404: logger.error( - f"HTTP error deleting temporary addressbook {addressbook_name}: {e}" + "HTTP error deleting temporary addressbook %s: %s", + addressbook_name, + e, ) else: logger.warning( - f"Temporary addressbook {addressbook_name} already deleted (404)." + "Temporary addressbook %s already deleted (404).", addressbook_name ) except Exception as e: logger.error( - f"Unexpected error deleting temporary addressbook {addressbook_name}: {e}" + "Unexpected error deleting temporary addressbook %s: %s", + addressbook_name, + e, ) @@ -742,34 +759,34 @@ async def temporary_contact(nc_client: NextcloudClient, temporary_addressbook: s "email": "john.doe@example.com", "tel": "1234567890", } - logger.info(f"Creating temporary contact in addressbook: {addressbook_name}") + logger.info("Creating temporary contact in addressbook: %s", addressbook_name) try: await nc_client.contacts.create_contact( addressbook=addressbook_name, uid=contact_uid, contact_data=contact_data, ) - logger.info(f"Temporary contact created with UID: {contact_uid}") + logger.info("Temporary contact created with UID: %s", contact_uid) yield contact_uid finally: - logger.info(f"Cleaning up temporary contact: {contact_uid}") + logger.info("Cleaning up temporary contact: %s", contact_uid) try: await nc_client.contacts.delete_contact( addressbook=addressbook_name, uid=contact_uid ) - logger.info(f"Successfully deleted temporary contact: {contact_uid}") + logger.info("Successfully deleted temporary contact: %s", contact_uid) except HTTPStatusError as e: if e.response.status_code != 404: logger.error( - f"HTTP error deleting temporary contact {contact_uid}: {e}" + "HTTP error deleting temporary contact %s: %s", contact_uid, e ) else: logger.warning( - f"Temporary contact {contact_uid} already deleted (404)." + "Temporary contact %s already deleted (404).", contact_uid ) except Exception as e: logger.error( - f"Unexpected error deleting temporary contact {contact_uid}: {e}" + "Unexpected error deleting temporary contact %s: %s", contact_uid, e ) @@ -785,7 +802,7 @@ async def temporary_board(nc_client: NextcloudClient): board_color = "FF0000" # Red color created_board_data = None - logger.info(f"Creating temporary deck board: {board_title}") + logger.info("Creating temporary deck board: %s", board_title) try: created_board = await nc_client.deck.create_board(board_title, board_color) board_id = created_board.id @@ -796,26 +813,30 @@ async def temporary_board(nc_client: NextcloudClient): "archived": getattr(created_board, "archived", False), } - logger.info(f"Temporary board created with ID: {board_id}") + logger.info("Temporary board created with ID: %s", board_id) yield created_board_data finally: if board_id: - logger.info(f"Cleaning up temporary board ID: {board_id}") + logger.info("Cleaning up temporary board ID: %s", board_id) try: await nc_client.deck.delete_board(board_id) - logger.info(f"Successfully deleted temporary board ID: {board_id}") + logger.info("Successfully deleted temporary board ID: %s", board_id) except HTTPStatusError as e: # Ignore 404 if board was already deleted by the test itself if e.response.status_code not in [404, 403]: - logger.error(f"HTTP error deleting temporary board {board_id}: {e}") + logger.error( + "HTTP error deleting temporary board %s: %s", board_id, e + ) else: logger.warning( - f"Temporary board {board_id} already deleted or access denied ({e.response.status_code})." + "Temporary board %s already deleted or access denied (%s).", + board_id, + e.response.status_code, ) except Exception as e: logger.error( - f"Unexpected error deleting temporary board {board_id}: {e}" + "Unexpected error deleting temporary board %s: %s", board_id, e ) @@ -833,7 +854,7 @@ async def temporary_board_with_stack(nc_client: NextcloudClient, temporary_board stack_order = 1 stack = None - logger.info(f"Creating temporary stack in board ID: {board_id}") + logger.info("Creating temporary stack in board ID: %s", board_id) try: stack = await nc_client.deck.create_stack(board_id, stack_title, stack_order) stack_data = { @@ -843,26 +864,30 @@ async def temporary_board_with_stack(nc_client: NextcloudClient, temporary_board "boardId": board_id, } - logger.info(f"Temporary stack created with ID: {stack.id}") + logger.info("Temporary stack created with ID: %s", stack.id) yield (board_data, stack_data) finally: # Clean up - delete stack if stack and hasattr(stack, "id"): - logger.info(f"Cleaning up temporary stack ID: {stack.id}") + logger.info("Cleaning up temporary stack ID: %s", stack.id) try: await nc_client.deck.delete_stack(board_id, stack.id) - logger.info(f"Successfully deleted temporary stack ID: {stack.id}") + logger.info("Successfully deleted temporary stack ID: %s", stack.id) except HTTPStatusError as e: if e.response.status_code not in [404, 403]: - logger.error(f"HTTP error deleting temporary stack {stack.id}: {e}") + logger.error( + "HTTP error deleting temporary stack %s: %s", stack.id, e + ) else: logger.warning( - f"Temporary stack {stack.id} already deleted or access denied ({e.response.status_code})." + "Temporary stack %s already deleted or access denied (%s).", + stack.id, + e.response.status_code, ) except Exception as e: logger.error( - f"Unexpected error deleting temporary stack {stack.id}: {e}" + "Unexpected error deleting temporary stack %s: %s", stack.id, e ) @@ -884,7 +909,7 @@ async def temporary_board_with_card( card = None logger.info( - f"Creating temporary card in stack ID: {stack_id}, board ID: {board_id}" + "Creating temporary card in stack ID: %s, board ID: %s", stack_id, board_id ) try: card = await nc_client.deck.create_card( @@ -898,25 +923,31 @@ async def temporary_board_with_card( "boardId": board_id, } - logger.info(f"Temporary card created with ID: {card.id}") + logger.info("Temporary card created with ID: %s", card.id) yield (board_data, stack_data, card_data) finally: # Clean up - delete card if card and hasattr(card, "id"): - logger.info(f"Cleaning up temporary card ID: {card.id}") + logger.info("Cleaning up temporary card ID: %s", card.id) try: await nc_client.deck.delete_card(board_id, stack_id, card.id) - logger.info(f"Successfully deleted temporary card ID: {card.id}") + logger.info("Successfully deleted temporary card ID: %s", card.id) except HTTPStatusError as e: if e.response.status_code not in [404, 403]: - logger.error(f"HTTP error deleting temporary card {card.id}: {e}") + logger.error( + "HTTP error deleting temporary card %s: %s", card.id, e + ) else: logger.warning( - f"Temporary card {card.id} already deleted or access denied ({e.response.status_code})." + "Temporary card %s already deleted or access denied (%s).", + card.id, + e.response.status_code, ) except Exception as e: - logger.error(f"Unexpected error deleting temporary card {card.id}: {e}") + logger.error( + "Unexpected error deleting temporary card %s: %s", card.id, e + ) @pytest.fixture @@ -929,30 +960,33 @@ async def temporary_conversation(nc_client: NextcloudClient): room_name = f"MCP Test Room {unique_suffix}" token = None - logger.info(f"Creating temporary Talk conversation: {room_name}") + logger.info("Creating temporary Talk conversation: %s", room_name) try: room = await nc_client.talk.create_conversation(room_name=room_name) token = room.token - logger.info(f"Temporary Talk conversation created (token={token})") + logger.info("Temporary Talk conversation created (token=%s)", token) yield {"token": token, "id": room.id, "name": room_name} finally: if token: - logger.info(f"Cleaning up temporary Talk conversation token={token}") + logger.info("Cleaning up temporary Talk conversation token=%s", token) try: await nc_client.talk.delete_conversation(token) - logger.info(f"Successfully deleted Talk conversation token={token}") + logger.info("Successfully deleted Talk conversation token=%s", token) except HTTPStatusError as e: if e.response.status_code not in [404, 403]: - logger.error(f"HTTP error deleting Talk conversation {token}: {e}") + logger.error( + "HTTP error deleting Talk conversation %s: %s", token, e + ) else: logger.warning( - f"Talk conversation {token} already deleted or access denied " - f"({e.response.status_code})." + "Talk conversation %s already deleted or access denied (%s).", + token, + e.response.status_code, ) except Exception as e: logger.error( - f"Unexpected error deleting Talk conversation {token}: {e}" + "Unexpected error deleting Talk conversation %s: %s", token, e ) @@ -975,7 +1009,7 @@ async def shared_calendar(nc_client: NextcloudClient, shared_test_calendar_name: try: # Create a test calendar - logger.info(f"Creating shared test calendar: {calendar_name}") + logger.info("Creating shared test calendar: %s", calendar_name) result = await nc_client.calendar.create_calendar( calendar_name=calendar_name, display_name=f"Shared Test Calendar {calendar_name}", @@ -986,21 +1020,21 @@ async def shared_calendar(nc_client: NextcloudClient, shared_test_calendar_name: if result["status_code"] not in [200, 201]: pytest.skip(f"Failed to create shared test calendar: {result}") - logger.info(f"Created shared test calendar: {calendar_name}") + logger.info("Created shared test calendar: %s", calendar_name) yield calendar_name except Exception as e: - logger.error(f"Error setting up shared test calendar: {e}") + logger.error("Error setting up shared test calendar: %s", e) pytest.skip(f"Shared calendar setup failed: {e}") finally: # Cleanup: Delete the shared calendar at end of session try: - logger.info(f"Cleaning up shared test calendar: {calendar_name}") + logger.info("Cleaning up shared test calendar: %s", calendar_name) await nc_client.calendar.delete_calendar(calendar_name) - logger.info(f"Successfully deleted shared test calendar: {calendar_name}") + logger.info("Successfully deleted shared test calendar: %s", calendar_name) except Exception as e: - logger.error(f"Error deleting shared test calendar {calendar_name}: {e}") + logger.error("Error deleting shared test calendar %s: %s", calendar_name, e) @pytest.fixture(scope="session") @@ -1024,7 +1058,7 @@ async def shared_calendar_2( await anyio.sleep(3) # Increased from 2 to 3 seconds # Create a test calendar - logger.info(f"Creating second shared test calendar: {calendar_name}") + logger.info("Creating second shared test calendar: %s", calendar_name) result = await nc_client.calendar.create_calendar( calendar_name=calendar_name, display_name=f"Shared Test Calendar 2 {calendar_name}", @@ -1035,7 +1069,7 @@ async def shared_calendar_2( if result["status_code"] not in [200, 201]: pytest.skip(f"Failed to create second shared test calendar: {result}") - logger.info(f"Created second shared test calendar: {calendar_name}") + logger.info("Created second shared test calendar: %s", calendar_name) # Verify calendar was created by listing calendars # Add small delay to allow calendar to propagate in the system @@ -1046,7 +1080,9 @@ async def shared_calendar_2( calendar_names = [cal["name"] for cal in calendars] if calendar_name not in calendar_names: logger.warning( - f"Calendar {calendar_name} not found immediately after creation. Available: {calendar_names}" + "Calendar %s not found immediately after creation. Available: %s", + calendar_name, + calendar_names, ) # Try one more time after a longer delay await anyio.sleep(3) # Additional wait for calendar synchronization @@ -1054,32 +1090,34 @@ async def shared_calendar_2( calendar_names = [cal["name"] for cal in calendars] if calendar_name not in calendar_names: logger.error( - f"Calendar {calendar_name} still not found after retries. Available: {calendar_names}" + "Calendar %s still not found after retries. Available: %s", + calendar_name, + calendar_names, ) pytest.fail( f"Failed to create second shared calendar: {calendar_name} not found in listing" ) logger.info( - f"Successfully verified second shared test calendar: {calendar_name}" + "Successfully verified second shared test calendar: %s", calendar_name ) yield calendar_name except Exception as e: - logger.error(f"Error setting up second shared test calendar: {e}") + logger.error("Error setting up second shared test calendar: %s", e) pytest.skip(f"Second shared calendar setup failed: {e}") finally: # Cleanup: Delete the second shared calendar at end of session try: - logger.info(f"Cleaning up second shared test calendar: {calendar_name}") + logger.info("Cleaning up second shared test calendar: %s", calendar_name) await nc_client.calendar.delete_calendar(calendar_name) logger.info( - f"Successfully deleted second shared test calendar: {calendar_name}" + "Successfully deleted second shared test calendar: %s", calendar_name ) except Exception as e: logger.error( - f"Error deleting second shared test calendar {calendar_name}: {e}" + "Error deleting second shared test calendar %s: %s", calendar_name, e ) @@ -1097,16 +1135,16 @@ async def temporary_calendar(shared_calendar: str, nc_client: NextcloudClient): # Cleanup: Delete all todos from this calendar try: - logger.info(f"Cleaning up todos from shared calendar: {calendar_name}") + logger.info("Cleaning up todos from shared calendar: %s", calendar_name) todos = await nc_client.calendar.list_todos(calendar_name) for todo in todos: try: await nc_client.calendar.delete_todo(calendar_name, todo["uid"]) except Exception as e: - logger.warning(f"Error deleting todo {todo['uid']}: {e}") - logger.info(f"Cleaned up {len(todos)} todos from shared calendar") + logger.warning("Error deleting todo %s: %s", todo["uid"], e) + logger.info("Cleaned up %s todos from shared calendar", len(todos)) except Exception as e: - logger.error(f"Error cleaning up todos from calendar {calendar_name}: {e}") + logger.error("Error cleaning up todos from calendar %s: %s", calendar_name, e) @pytest.fixture(scope="session") @@ -1124,7 +1162,7 @@ async def nc_oauth_client( if not all([nextcloud_host, username]): pytest.skip("OAuth client fixture requires NEXTCLOUD_HOST and USERNAME") - logger.info(f"Creating OAuth NextcloudClient (Playwright) for user: {username}") + logger.info("Creating OAuth NextcloudClient (Playwright) for user: %s", username) client = NextcloudClient.from_token( base_url=nextcloud_host, token=playwright_oauth_token, @@ -1139,7 +1177,7 @@ async def nc_oauth_client( ) yield client except Exception as e: - logger.error(f"Failed to initialize OAuth NextcloudClient (Playwright): {e}") + logger.error("Failed to initialize OAuth NextcloudClient (Playwright): %s", e) pytest.fail(f"Failed to connect to Nextcloud with Playwright OAuth token: {e}") finally: await client.close() @@ -1182,13 +1220,15 @@ def oauth_callback_server(): if state: auth_states[state] = code logger.info( - f"OAuth callback received for state={state[:16]}... Code: {code[:20]}..." + "OAuth callback received for state=%s... Code: %s...", + state[:16], + code[:20], ) else: # Fallback for flows without state parameter (legacy interactive flow) auth_states["_default"] = code logger.info( - f"OAuth callback received (no state). Code: {code[:20]}..." + "OAuth callback received (no state). Code: %s...", code[:20] ) self.send_response(200) @@ -1199,7 +1239,7 @@ def oauth_callback_server(): ) else: # Ignore requests without a code (e.g., favicon requests) - logger.debug(f"Ignoring request without auth code: {self.path}") + logger.debug("Ignoring request without auth code: %s", self.path) self.send_response(404) self.end_headers() @@ -1251,7 +1291,7 @@ async def shared_oauth_client_credentials(anyio_backend, oauth_callback_server): auth_states, callback_url = oauth_callback_server logger.info("Setting up shared OAuth client credentials for all test users...") - logger.info(f"Using real callback server at: {callback_url}") + logger.info("Using real callback server at: %s", callback_url) async with httpx.AsyncClient(timeout=30.0) as http_client: # OIDC Discovery @@ -1277,7 +1317,7 @@ async def shared_oauth_client_credentials(anyio_backend, oauth_callback_server): token_type="Bearer", # Opaque tokens for port 8001 ) - logger.info(f"Shared OAuth client ready: {client_info.client_id[:16]}...") + logger.info("Shared OAuth client ready: %s...", client_info.client_id[:16]) logger.info( "This opaque token client with full scopes will be reused for all test user authentications" ) @@ -1293,7 +1333,7 @@ async def shared_oauth_client_credentials(anyio_backend, oauth_callback_server): # Cleanup: Delete OAuth client from Nextcloud using RFC 7592 try: logger.info( - f"Cleaning up shared OAuth client: {client_info.client_id[:16]}..." + "Cleaning up shared OAuth client: %s...", client_info.client_id[:16] ) success = await delete_client( nextcloud_url=nextcloud_host, @@ -1304,15 +1344,19 @@ async def shared_oauth_client_credentials(anyio_backend, oauth_callback_server): ) if success: logger.info( - f"Successfully deleted shared OAuth client: {client_info.client_id[:16]}..." + "Successfully deleted shared OAuth client: %s...", + client_info.client_id[:16], ) else: logger.warning( - f"Failed to delete shared OAuth client: {client_info.client_id[:16]}..." + "Failed to delete shared OAuth client: %s...", + client_info.client_id[:16], ) except Exception as e: logger.warning( - f"Error cleaning up shared OAuth client {client_info.client_id[:16]}...: {e}" + "Error cleaning up shared OAuth client %s...: %s", + client_info.client_id[:16], + e, ) @@ -1340,7 +1384,7 @@ async def shared_jwt_oauth_client_credentials(anyio_backend, oauth_callback_serv auth_states, callback_url = oauth_callback_server logger.info("Setting up shared JWT OAuth client credentials...") - logger.info(f"Using real callback server at: {callback_url}") + logger.info("Using real callback server at: %s", callback_url) async with httpx.AsyncClient(timeout=30.0) as http_client: # OIDC Discovery @@ -1365,7 +1409,7 @@ async def shared_jwt_oauth_client_credentials(anyio_backend, oauth_callback_serv token_type="JWT", # Explicitly set JWT token type ) - logger.info(f"Shared JWT OAuth client ready: {client_info.client_id[:16]}...") + logger.info("Shared JWT OAuth client ready: %s...", client_info.client_id[:16]) logger.info( "This JWT client with full scopes will be reused for JWT MCP server tests" ) @@ -1381,7 +1425,7 @@ async def shared_jwt_oauth_client_credentials(anyio_backend, oauth_callback_serv # Cleanup: Delete OAuth client from Nextcloud using RFC 7592 try: logger.info( - f"Cleaning up shared JWT OAuth client: {client_info.client_id[:16]}..." + "Cleaning up shared JWT OAuth client: %s...", client_info.client_id[:16] ) success = await delete_client( nextcloud_url=nextcloud_host, @@ -1392,15 +1436,19 @@ async def shared_jwt_oauth_client_credentials(anyio_backend, oauth_callback_serv ) if success: logger.info( - f"Successfully deleted shared JWT OAuth client: {client_info.client_id[:16]}..." + "Successfully deleted shared JWT OAuth client: %s...", + client_info.client_id[:16], ) else: logger.warning( - f"Failed to delete shared JWT OAuth client: {client_info.client_id[:16]}..." + "Failed to delete shared JWT OAuth client: %s...", + client_info.client_id[:16], ) except Exception as e: logger.warning( - f"Error cleaning up shared JWT OAuth client {client_info.client_id[:16]}...: {e}" + "Error cleaning up shared JWT OAuth client %s...: %s", + client_info.client_id[:16], + e, ) @@ -1425,13 +1473,13 @@ async def get_mcp_server_resource_metadata(mcp_base_url: str) -> dict: """ async with httpx.AsyncClient(timeout=30.0) as http_client: prm_url = f"{mcp_base_url}/.well-known/oauth-protected-resource" - logger.debug(f"Fetching resource metadata from: {prm_url}") + logger.debug("Fetching resource metadata from: %s", prm_url) response = await http_client.get(prm_url) response.raise_for_status() metadata = response.json() - logger.debug(f"Resource metadata: {metadata}") + logger.debug("Resource metadata: %s", metadata) return metadata @@ -1456,7 +1504,10 @@ async def _create_oauth_client_with_scopes( from nextcloud_mcp_server.auth.client_registration import register_client logger.info( - f"Creating {token_type} OAuth client '{client_name}' with scopes: {allowed_scopes} using DCR" + "Creating %s OAuth client '%s' with scopes: %s using DCR", + token_type, + client_name, + allowed_scopes, ) # Get Nextcloud host and registration endpoint @@ -1486,7 +1537,9 @@ async def _create_oauth_client_with_scopes( ) logger.info( - f"Created OAuth client via DCR: {client_info.client_id[:16]}... with scopes: {allowed_scopes}" + "Created OAuth client via DCR: %s... with scopes: %s", + client_info.client_id[:16], + allowed_scopes, ) if client_info.registration_access_token: logger.info( @@ -1544,7 +1597,7 @@ async def read_only_oauth_client_credentials(anyio_backend, oauth_callback_serve # Cleanup: Delete OAuth client from Nextcloud using RFC 7592 try: logger.info( - f"Cleaning up read-only OAuth client: {client_info.client_id[:16]}..." + "Cleaning up read-only OAuth client: %s...", client_info.client_id[:16] ) success = await delete_client( nextcloud_url=nextcloud_host, @@ -1555,15 +1608,19 @@ async def read_only_oauth_client_credentials(anyio_backend, oauth_callback_serve ) if success: logger.info( - f"Successfully deleted read-only OAuth client: {client_info.client_id[:16]}..." + "Successfully deleted read-only OAuth client: %s...", + client_info.client_id[:16], ) else: logger.warning( - f"Failed to delete read-only OAuth client: {client_info.client_id[:16]}..." + "Failed to delete read-only OAuth client: %s...", + client_info.client_id[:16], ) except Exception as e: logger.warning( - f"Error cleaning up read-only OAuth client {client_info.client_id[:16]}...: {e}" + "Error cleaning up read-only OAuth client %s...: %s", + client_info.client_id[:16], + e, ) @@ -1613,7 +1670,7 @@ async def write_only_oauth_client_credentials(anyio_backend, oauth_callback_serv # Cleanup: Delete OAuth client from Nextcloud using RFC 7592 try: logger.info( - f"Cleaning up write-only OAuth client: {client_info.client_id[:16]}..." + "Cleaning up write-only OAuth client: %s...", client_info.client_id[:16] ) success = await delete_client( nextcloud_url=nextcloud_host, @@ -1624,15 +1681,19 @@ async def write_only_oauth_client_credentials(anyio_backend, oauth_callback_serv ) if success: logger.info( - f"Successfully deleted write-only OAuth client: {client_info.client_id[:16]}..." + "Successfully deleted write-only OAuth client: %s...", + client_info.client_id[:16], ) else: logger.warning( - f"Failed to delete write-only OAuth client: {client_info.client_id[:16]}..." + "Failed to delete write-only OAuth client: %s...", + client_info.client_id[:16], ) except Exception as e: logger.warning( - f"Error cleaning up write-only OAuth client {client_info.client_id[:16]}...: {e}" + "Error cleaning up write-only OAuth client %s...: %s", + client_info.client_id[:16], + e, ) @@ -1682,7 +1743,8 @@ async def full_access_oauth_client_credentials(anyio_backend, oauth_callback_ser # Cleanup: Delete OAuth client from Nextcloud using RFC 7592 try: logger.info( - f"Cleaning up full-access OAuth client: {client_info.client_id[:16]}..." + "Cleaning up full-access OAuth client: %s...", + client_info.client_id[:16], ) success = await delete_client( nextcloud_url=nextcloud_host, @@ -1693,15 +1755,19 @@ async def full_access_oauth_client_credentials(anyio_backend, oauth_callback_ser ) if success: logger.info( - f"Successfully deleted full-access OAuth client: {client_info.client_id[:16]}..." + "Successfully deleted full-access OAuth client: %s...", + client_info.client_id[:16], ) else: logger.warning( - f"Failed to delete full-access OAuth client: {client_info.client_id[:16]}..." + "Failed to delete full-access OAuth client: %s...", + client_info.client_id[:16], ) except Exception as e: logger.warning( - f"Error cleaning up full-access OAuth client {client_info.client_id[:16]}...: {e}" + "Error cleaning up full-access OAuth client %s...: %s", + client_info.client_id[:16], + e, ) @@ -1756,7 +1822,8 @@ async def no_custom_scopes_oauth_client_credentials( # Cleanup: Delete OAuth client from Nextcloud using RFC 7592 try: logger.info( - f"Cleaning up no-custom-scopes OAuth client: {client_info.client_id[:16]}..." + "Cleaning up no-custom-scopes OAuth client: %s...", + client_info.client_id[:16], ) success = await delete_client( nextcloud_url=nextcloud_host, @@ -1767,15 +1834,19 @@ async def no_custom_scopes_oauth_client_credentials( ) if success: logger.info( - f"Successfully deleted no-custom-scopes OAuth client: {client_info.client_id[:16]}..." + "Successfully deleted no-custom-scopes OAuth client: %s...", + client_info.client_id[:16], ) else: logger.warning( - f"Failed to delete no-custom-scopes OAuth client: {client_info.client_id[:16]}..." + "Failed to delete no-custom-scopes OAuth client: %s...", + client_info.client_id[:16], ) except Exception as e: logger.warning( - f"Error cleaning up no-custom-scopes OAuth client {client_info.client_id[:16]}...: {e}" + "Error cleaning up no-custom-scopes OAuth client %s...: %s", + client_info.client_id[:16], + e, ) @@ -1822,9 +1893,9 @@ async def playwright_oauth_token( shared_oauth_client_credentials ) - logger.info(f"Starting Playwright-based OAuth flow for {username}...") - logger.info(f"Using shared OAuth client: {client_id[:16]}...") - logger.info(f"Using real callback server at: {callback_url}") + logger.info("Starting Playwright-based OAuth flow for %s...", username) + logger.info("Using shared OAuth client: %s...", client_id[:16]) + logger.info("Using real callback server at: %s", callback_url) # Fetch MCP server's resource metadata to get correct audience mcp_server_base_url = "http://localhost:8001" @@ -1832,16 +1903,18 @@ async def playwright_oauth_token( resource_metadata = await get_mcp_server_resource_metadata(mcp_server_base_url) resource_id = resource_metadata.get("resource") if resource_id: - logger.info(f"MCP server resource ID (for audience): {resource_id[:16]}...") + logger.info( + "MCP server resource ID (for audience): %s...", resource_id[:16] + ) else: logger.warning("No resource ID in metadata - token may have wrong audience") except Exception as e: - logger.warning(f"Failed to fetch resource metadata: {e}") + logger.warning("Failed to fetch resource metadata: %s", e) resource_id = None # Generate unique state parameter for this OAuth flow state = secrets.token_urlsafe(32) - logger.debug(f"Generated state: {state[:16]}...") + logger.debug("Generated state: %s...", state[:16]) # Construct authorization URL with state and resource parameters auth_url = ( @@ -1856,7 +1929,7 @@ async def playwright_oauth_token( # Add resource parameter (RFC 8707) if available if resource_id: auth_url += f"&resource={quote(resource_id, safe='')}" - logger.debug(f"Added resource parameter to auth URL: {resource_id[:16]}...") + logger.debug("Added resource parameter to auth URL: %s...", resource_id[:16]) # Async browser automation using pytest-playwright's browser fixture context = await browser.new_context(ignore_https_errors=True) @@ -1864,12 +1937,12 @@ async def playwright_oauth_token( try: # Navigate to authorization URL - logger.debug(f"Navigating to: {auth_url}") + logger.debug("Navigating to: %s", auth_url) await page.goto(auth_url, wait_until="networkidle", timeout=60000) # Check if we need to login first current_url = page.url - logger.debug(f"Current URL after navigation: {current_url}") + logger.debug("Current URL after navigation: %s", current_url) # If we're on a login page, fill in credentials if "/login" in current_url or "/index.php/login" in current_url: @@ -1890,7 +1963,7 @@ async def playwright_oauth_token( # Wait for navigation after login await page.wait_for_load_state("networkidle", timeout=60000) current_url = page.url - logger.info(f"After login, current URL: {current_url}") + logger.info("After login, current URL: %s", current_url) # Wait for the OIDC redirect chain to settle before handling consent. # After login, the flow goes: /apps/oidc/redirect (JS page) → JS navigates @@ -1909,7 +1982,7 @@ async def playwright_oauth_token( await page.wait_for_load_state("networkidle", timeout=10000) await _handle_oauth_consent_screen(page, username) else: - logger.debug(f"No consent screen (URL: {page.url})") + logger.debug("No consent screen (URL: %s)", page.url) # Wait for callback server to receive the auth code # Browser will be redirected to localhost:8081 which will capture the code @@ -1921,14 +1994,14 @@ async def playwright_oauth_token( # Take a screenshot for debugging screenshot_path = "/tmp/playwright_oauth_error.png" await page.screenshot(path=screenshot_path) - logger.error(f"Screenshot saved to {screenshot_path}") + logger.error("Screenshot saved to %s", screenshot_path) raise TimeoutError( f"Timeout waiting for OAuth callback (state={state[:16]}...)" ) await anyio.sleep(0.5) auth_code = auth_states[state] - logger.info(f"Successfully received authorization code: {auth_code[:20]}...") + logger.info("Successfully received authorization code: %s...", auth_code[:20]) finally: await context.close() @@ -2006,45 +2079,45 @@ async def _handle_oauth_consent_screen(page, username: str = "user"): consent_div = await page.query_selector("#oidc-consent") if not consent_div: - logger.debug(f"No consent screen found for {username}") + logger.debug("No consent screen found for %s", username) return False - logger.info(f"Consent screen detected for {username}") + logger.info("Consent screen detected for %s", username) # Get consent screen data attributes client_name = await consent_div.get_attribute("data-client-name") scopes_attr = await consent_div.get_attribute("data-scopes") - logger.info(f" Client: {client_name}") - logger.info(f" Requested scopes: {scopes_attr}") + logger.info(" Client: %s", client_name) + logger.info(" Requested scopes: %s", scopes_attr) # Wait for Vue.js to render the Allow button (max 10 seconds) try: await page.wait_for_selector('button:has-text("Allow")', timeout=10000) logger.info(" Allow button rendered by Vue.js") except Exception as e: - logger.warning(f" Timeout waiting for Allow button: {e}") + logger.warning(" Timeout waiting for Allow button: %s", e) # Take a screenshot for debugging screenshot_path = f"/tmp/consent_no_allow_button_{username}.png" await page.screenshot(path=screenshot_path) - logger.error(f" Screenshot saved to {screenshot_path}") + logger.error(" Screenshot saved to %s", screenshot_path) raise # Check all scope checkboxes scope_checkboxes = await page.query_selector_all('input[type="checkbox"]') if scope_checkboxes: - logger.info(f" Found {len(scope_checkboxes)} scope checkboxes") + logger.info(" Found %s scope checkboxes", len(scope_checkboxes)) for i, checkbox in enumerate(scope_checkboxes): # Check if checkbox is not already checked is_checked = await checkbox.is_checked() is_disabled = await checkbox.is_disabled() if not is_checked and not is_disabled: await checkbox.check() - logger.info(f" ✓ Checked scope checkbox {i + 1}") + logger.info(" ✓ Checked scope checkbox %s", i + 1) elif is_checked: - logger.info(f" ✓ Scope checkbox {i + 1} already checked") + logger.info(" ✓ Scope checkbox %s already checked", i + 1) elif is_disabled: logger.info( - f" ⊗ Scope checkbox {i + 1} disabled (required scope)" + " ⊗ Scope checkbox %s disabled (required scope)", i + 1 ) # Click the Allow button to grant consent with retry logic. @@ -2053,7 +2126,7 @@ async def _handle_oauth_consent_screen(page, username: str = "user"): allow_button = page.locator('button:has-text("Allow")') if await allow_button.count() > 0: - logger.info(f" Clicking Allow button to grant consent for {username}...") + logger.info(" Clicking Allow button to grant consent for %s...", username) for attempt in range(3): await allow_button.scroll_into_view_if_needed() @@ -2062,28 +2135,30 @@ async def _handle_oauth_consent_screen(page, username: str = "user"): await page.wait_for_url( lambda url: "/consent" not in url, timeout=10000 ) - logger.info(f" Consent granted for {username}") + logger.info(" Consent granted for %s", username) return True except (TimeoutError, PlaywrightTimeoutError): if attempt == 2: screenshot_path = f"/tmp/consent_click_failed_{username}.png" await page.screenshot(path=screenshot_path) logger.error( - f" Consent click failed after 3 attempts for {username}, " - f"screenshot: {screenshot_path}" + " Consent click failed after 3 attempts for %s, screenshot: %s", + username, + screenshot_path, ) raise logger.warning( - f" Consent click attempt {attempt + 1} didn't navigate, retrying..." + " Consent click attempt %s didn't navigate, retrying...", + attempt + 1, ) raise RuntimeError("consent click retry loop exited unexpectedly") else: - logger.error(f" Allow button not found for {username}") + logger.error(" Allow button not found for %s", username) return False except Exception as e: - logger.error(f"Error handling consent screen for {username}: {e}") + logger.error("Error handling consent screen for %s: %s", username, e) raise @@ -2127,9 +2202,9 @@ async def _get_oauth_token_with_scopes( shared_oauth_client_credentials ) - logger.info(f"Starting Playwright-based OAuth flow with scopes: {scopes}") - logger.info(f"Using shared OAuth client: {client_id[:16]}...") - logger.info(f"Using real callback server at: {callback_url}") + logger.info("Starting Playwright-based OAuth flow with scopes: %s", scopes) + logger.info("Using shared OAuth client: %s...", client_id[:16]) + logger.info("Using real callback server at: %s", callback_url) # If no resource provided, fetch from MCP server metadata if resource is None: @@ -2140,18 +2215,18 @@ async def _get_oauth_token_with_scopes( resource = resource_metadata.get("resource") if resource: logger.info( - f"MCP server resource ID (for audience): {resource[:16]}..." + "MCP server resource ID (for audience): %s...", resource[:16] ) else: logger.warning( "No resource ID in metadata - token may have wrong audience" ) except Exception as e: - logger.warning(f"Failed to fetch resource metadata: {e}") + logger.warning("Failed to fetch resource metadata: %s", e) # Generate unique state parameter for this OAuth flow state = secrets.token_urlsafe(32) - logger.debug(f"Generated state: {state[:16]}...") + logger.debug("Generated state: %s...", state[:16]) # URL-encode scopes scopes_encoded = quote(scopes, safe="") @@ -2169,7 +2244,7 @@ async def _get_oauth_token_with_scopes( # Add resource parameter (RFC 8707) if available if resource: auth_url += f"&resource={quote(resource, safe='')}" - logger.debug(f"Added resource parameter to auth URL: {resource[:16]}...") + logger.debug("Added resource parameter to auth URL: %s...", resource[:16]) # Async browser automation using pytest-playwright's browser fixture context = await browser.new_context(ignore_https_errors=True) @@ -2177,12 +2252,12 @@ async def _get_oauth_token_with_scopes( try: # Navigate to authorization URL - logger.debug(f"Navigating to: {auth_url}") + logger.debug("Navigating to: %s", auth_url) await page.goto(auth_url, wait_until="networkidle", timeout=60000) # Check if we need to login first current_url = page.url - logger.debug(f"Current URL after navigation: {current_url}") + logger.debug("Current URL after navigation: %s", current_url) # If we're on a login page, fill in credentials if "/login" in current_url or "/index.php/login" in current_url: @@ -2203,10 +2278,10 @@ async def _get_oauth_token_with_scopes( # Wait for navigation after login await page.wait_for_load_state("networkidle", timeout=60000) current_url = page.url - logger.info(f"After login, current URL: {current_url}") + logger.info("After login, current URL: %s", current_url) # Wait for the OIDC redirect chain to settle before handling consent. - logger.info(f"Waiting for OIDC redirect chain to settle for {username}...") + logger.info("Waiting for OIDC redirect chain to settle for %s...", username) settle_start = time.time() while time.time() - settle_start < 15: current_url = page.url @@ -2219,10 +2294,10 @@ async def _get_oauth_token_with_scopes( await page.wait_for_load_state("networkidle", timeout=10000) await _handle_oauth_consent_screen(page, username) else: - logger.debug(f"No consent screen for {username} (URL: {page.url})") + logger.debug("No consent screen for %s (URL: %s)", username, page.url) # Wait for callback server to receive the auth code - logger.info(f"Waiting for auth code with state: {state[:16]}...") + logger.info("Waiting for auth code with state: %s...", state[:16]) start_time = time.time() timeout = 30 @@ -2261,7 +2336,7 @@ async def _get_oauth_token_with_scopes( if not access_token: raise ValueError(f"No access_token in response: {token_data}") - logger.info(f"Successfully obtained OAuth access token with scopes: {scopes}") + logger.info("Successfully obtained OAuth access token with scopes: %s", scopes) return access_token @@ -2390,7 +2465,7 @@ async def test_users_setup(anyio_backend, nc_client: NextcloudClient): logger.info("=" * 60) logger.info("EXECUTING test_users_setup FIXTURE (session-scoped)") - logger.info(f"Creating test users: {list(test_user_configs.keys())}") + logger.info("Creating test users: %s", list(test_user_configs.keys())) logger.info("=" * 60) created_users = [] @@ -2416,10 +2491,12 @@ async def test_users_setup(anyio_backend, nc_client: NextcloudClient): logger.info("Editors group ready") else: logger.warning( - f"Group creation returned {response.status_code}: {response.text}" + "Group creation returned %s: %s", + response.status_code, + response.text, ) except Exception as e: - logger.warning(f"Error creating editors group (may already exist): {e}") + logger.warning("Error creating editors group (may already exist): %s", e) # Create each test user (idempotent - check if exists first) for username, config in test_user_configs.items(): @@ -2428,7 +2505,7 @@ async def test_users_setup(anyio_backend, nc_client: NextcloudClient): try: await nc_client.users.get_user_details(username) user_exists = True - logger.info(f"Test user {username} already exists, skipping creation") + logger.info("Test user %s already exists, skipping creation", username) except Exception: # User doesn't exist, proceed with creation pass @@ -2441,23 +2518,23 @@ async def test_users_setup(anyio_backend, nc_client: NextcloudClient): display_name=config["display_name"], email=config["email"], ) - logger.info(f"Created test user: {username}") + logger.info("Created test user: %s", username) created_users.append(username) # Only track users WE created # Add user to groups if specified for group in config["groups"]: try: await nc_client.users.add_user_to_group(username, group) - logger.info(f"Added {username} to group {group}") + logger.info("Added %s to group %s", username, group) except Exception as e: logger.warning( - f"Error adding {username} to group {group}: {e}" + "Error adding %s to group %s: %s", username, group, e ) except Exception as e: - logger.warning(f"Could not create user {username}: {e}") + logger.warning("Could not create user %s: %s", username, e) - logger.info(f"Test users setup complete: {created_users}") + logger.info("Test users setup complete: %s", created_users) yield test_user_configs finally: @@ -2466,9 +2543,9 @@ async def test_users_setup(anyio_backend, nc_client: NextcloudClient): for username in created_users: try: await nc_client.users.delete_user(username) - logger.info(f"Deleted test user: {username}") + logger.info("Deleted test user: %s", username) except Exception as e: - logger.warning(f"Error deleting test user {username}: {e}") + logger.warning("Error deleting test user %s: %s", username, e) # Clean up all app passwords from MCP server to prevent stale scanners import subprocess @@ -2490,8 +2567,9 @@ async def test_users_setup(anyio_backend, nc_client: NextcloudClient): ) if result.returncode != 0: logger.warning( - f"Failed to clean up app passwords (rc={result.returncode}): " - f"{result.stderr}" + "Failed to clean up app passwords (rc=%s): %s", + result.returncode, + result.stderr, ) else: logger.info("Cleaned up all test app passwords") @@ -2531,28 +2609,28 @@ async def _get_oauth_token_for_user( shared_oauth_client_credentials ) - logger.info(f"Getting OAuth token for user: {username}...") - logger.info(f"Using shared OAuth client: {client_id[:16]}...") + logger.info("Getting OAuth token for user: %s...", username) + logger.info("Using shared OAuth client: %s...", client_id[:16]) # Fetch resource identifier from PRM endpoint (RFC 9728) mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8001") prm_url = f"{mcp_server_url}/.well-known/oauth-protected-resource" - logger.debug(f"Fetching PRM metadata from: {prm_url}") + logger.debug("Fetching PRM metadata from: %s", prm_url) async with httpx.AsyncClient() as client: prm_response = await client.get(prm_url, timeout=10) if prm_response.status_code != 200: - logger.warning(f"Failed to fetch PRM metadata: {prm_response.status_code}") + logger.warning("Failed to fetch PRM metadata: %s", prm_response.status_code) # Fallback to default if PRM fetch fails mcp_server_resource = f"{mcp_server_url}/mcp" else: prm_data = prm_response.json() mcp_server_resource = prm_data.get("resource", f"{mcp_server_url}/mcp") - logger.info(f"Using resource from PRM: {mcp_server_resource}") + logger.info("Using resource from PRM: %s", mcp_server_resource) # Generate unique state parameter for this OAuth flow state = secrets.token_urlsafe(32) - logger.debug(f"Generated state for {username}: {state[:16]}...") + logger.debug("Generated state for %s: %s...", username, state[:16]) # Construct authorization URL with state parameter # Include resource parameter discovered from PRM endpoint @@ -2566,8 +2644,8 @@ async def _get_oauth_token_for_user( f"scope=openid%20profile%20email%20notes.read%20notes.write%20calendar.read%20calendar.write%20contacts.read%20contacts.write%20cookbook.read%20cookbook.write%20deck.read%20deck.write%20tables.read%20tables.write%20files.read%20files.write%20sharing.read%20sharing.write" ) - logger.info(f"Performing browser OAuth flow for {username}...") - logger.debug(f"Authorization URL: {auth_url}") + logger.info("Performing browser OAuth flow for %s...", username) + logger.debug("Authorization URL: %s", auth_url) # Browser automation context = await browser.new_context(ignore_https_errors=True) @@ -2579,7 +2657,7 @@ async def _get_oauth_token_for_user( # Login if needed if "/login" in current_url or "/index.php/login" in current_url: - logger.info(f"Logging in as {username}...") + logger.info("Logging in as %s...", username) await page.wait_for_selector('input[name="user"]', timeout=10000) await page.fill('input[name="user"]', username) await page.fill('input[name="password"]', password) @@ -2592,7 +2670,7 @@ async def _get_oauth_token_for_user( # to /authorize → 303 to /consent. networkidle fires after the JS page # loads but before the JS navigation starts, so we must wait for the URL # to reach either the consent page or the callback. - logger.info(f"Waiting for OIDC redirect chain to settle for {username}...") + logger.info("Waiting for OIDC redirect chain to settle for %s...", username) settle_start = time.time() while time.time() - settle_start < 15: current_url = page.url @@ -2601,8 +2679,9 @@ async def _get_oauth_token_for_user( await anyio.sleep(0.5) else: logger.warning( - f"OIDC redirect chain did not settle for {username}, " - f"current URL: {page.url}" + "OIDC redirect chain did not settle for %s, current URL: %s", + username, + page.url, ) # Handle consent screen if present @@ -2610,12 +2689,12 @@ async def _get_oauth_token_for_user( await page.wait_for_load_state("networkidle", timeout=10000) await _handle_oauth_consent_screen(page, username) else: - logger.debug(f"No consent screen for {username} (URL: {page.url})") + logger.debug("No consent screen for %s (URL: %s)", username, page.url) # Wait for callback server to receive the auth code # Browser will be redirected to localhost:8081 which will capture the code logger.info( - f"Waiting for callback server to receive auth code for {username}..." + "Waiting for callback server to receive auth code for %s...", username ) timeout_seconds = 30 start_time = time.time() @@ -2624,20 +2703,20 @@ async def _get_oauth_token_for_user( # Take screenshot for debugging screenshot_path = f"/tmp/playwright_oauth_timeout_{username}.png" await page.screenshot(path=screenshot_path) - logger.error(f"Screenshot saved to {screenshot_path}") + logger.error("Screenshot saved to %s", screenshot_path) raise TimeoutError( f"Timeout waiting for OAuth callback for {username} (state={state[:16]}...)" ) await anyio.sleep(0.5) auth_code = auth_states[state] - logger.info(f"Got auth code for {username}: {auth_code[:20]}...") + logger.info("Got auth code for %s: %s...", username, auth_code[:20]) finally: await context.close() # Exchange code for token - logger.info(f"Exchanging auth code for access token ({username})...") + logger.info("Exchanging auth code for access token (%s)...", username) async with httpx.AsyncClient(timeout=30.0) as http_client: token_response = await http_client.post( token_endpoint, @@ -2657,7 +2736,7 @@ async def _get_oauth_token_for_user( if not access_token: raise ValueError(f"No access_token for {username}: {token_data}") - logger.info(f"Successfully obtained OAuth token for {username}") + logger.info("Successfully obtained OAuth token for %s", username) return access_token @@ -2685,7 +2764,9 @@ async def all_oauth_tokens( start_time = time.time() logger.info("Fetching OAuth tokens for all users in parallel...") - logger.info(f"Using callback server at {callback_url} with state-based correlation") + logger.info( + "Using callback server at %s with state-based correlation", callback_url + ) async def get_token_with_delay(username: str, config: dict, delay: float): """Get token for a user after a small delay to stagger requests.""" @@ -2725,14 +2806,16 @@ async def all_oauth_tokens( for username in results: result = results[username] if isinstance(result, Exception): - logger.error(f"Failed to get OAuth token for {username}: {result}") + logger.error("Failed to get OAuth token for %s: %s", username, result) raise result tokens[username] = result elapsed = time.time() - start_time logger.info( - f"Successfully fetched {len(tokens)} OAuth tokens in parallel " - f"in {elapsed:.1f}s (~{elapsed / len(tokens):.1f}s per user)" + "Successfully fetched %s OAuth tokens in parallel in %ss (~%ss per user)", + len(tokens), + format(elapsed, ".1f"), + format(elapsed / len(tokens), ".1f"), ) return tokens @@ -2852,9 +2935,9 @@ async def test_user(nc_client: NextcloudClient): # Cleanup after test try: await nc_client.users.delete_user(userid) - logger.debug(f"Cleaned up test user: {userid}") + logger.debug("Cleaned up test user: %s", userid) except Exception as e: - logger.warning(f"Failed to cleanup test user {userid}: {e}") + logger.warning("Failed to cleanup test user %s: %s", userid, e) @pytest.fixture @@ -2876,16 +2959,16 @@ async def test_group(nc_client: NextcloudClient): # Create the group await nc_client.groups.create_group(groupid) - logger.debug(f"Created test group: {groupid}") + logger.debug("Created test group: %s", groupid) yield groupid # Cleanup after test try: await nc_client.groups.delete_group(groupid) - logger.debug(f"Cleaned up test group: {groupid}") + logger.debug("Cleaned up test group: %s", groupid) except Exception as e: - logger.warning(f"Failed to cleanup test group {groupid}: {e}") + logger.warning("Failed to cleanup test group %s: %s", groupid, e) @pytest.fixture @@ -2903,7 +2986,7 @@ async def test_user_in_group(nc_client: NextcloudClient, test_user, test_group): # Add user to group await nc_client.users.add_user_to_group(user_config["userid"], groupid) - logger.debug(f"Added user {user_config['userid']} to group {groupid}") + logger.debug("Added user %s to group %s", user_config["userid"], groupid) yield (user_config, groupid) @@ -2950,7 +3033,9 @@ async def configure_astrolabe_for_mcp_server(nc_client): Dict with client_id and client_secret """ logger.info( - f"Configuring Astrolabe for MCP server: {mcp_server_internal_url} (public: {mcp_server_public_url})" + "Configuring Astrolabe for MCP server: %s (public: %s)", + mcp_server_internal_url, + mcp_server_public_url, ) # Configure MCP server URLs in Nextcloud system config @@ -3003,7 +3088,7 @@ async def configure_astrolabe_for_mcp_server(nc_client): f"Expected: {mcp_server_internal_url}, Got: {actual_url}" ) - logger.info(f"✓ MCP server URL configured and verified: {actual_url}") + logger.info("✓ MCP server URL configured and verified: %s", actual_url) # Configure public URL result = subprocess.run( @@ -3031,7 +3116,7 @@ async def configure_astrolabe_for_mcp_server(nc_client): f"stderr: {result.stderr}, stdout: {result.stdout}" ) - logger.info(f"✓ MCP server public URL configured: {mcp_server_public_url}") + logger.info("✓ MCP server public URL configured: %s", mcp_server_public_url) # Remove existing OAuth client if it exists try: @@ -3050,7 +3135,7 @@ async def configure_astrolabe_for_mcp_server(nc_client): check=False, # Don't fail if client doesn't exist capture_output=True, ) - logger.info(f"Removed existing OAuth client: {client_id}") + logger.info("Removed existing OAuth client: %s", client_id) except Exception: pass @@ -3096,7 +3181,7 @@ async def configure_astrolabe_for_mcp_server(nc_client): "Failed to extract client_secret from OAuth client creation" ) - logger.info(f"✓ OAuth client created: {client_id}") + logger.info("✓ OAuth client created: %s", client_id) # Store client credentials in Nextcloud system config subprocess.run( @@ -3136,7 +3221,7 @@ async def configure_astrolabe_for_mcp_server(nc_client): ) logger.info("✓ Client credentials stored in system config") - logger.info(f"Astrolabe configured for MCP server: {mcp_server_public_url}") + logger.info("Astrolabe configured for MCP server: %s", mcp_server_public_url) return {"client_id": client_id, "client_secret": client_secret} diff --git a/tests/integration/provider_fixtures.py b/tests/integration/provider_fixtures.py index f8d90ecd..fd87f1ab 100644 --- a/tests/integration/provider_fixtures.py +++ b/tests/integration/provider_fixtures.py @@ -79,7 +79,7 @@ async def create_generation_provider(provider_name: str) -> Provider: embedding_model=None, # Generation only generation_model=generation_model, ) - logger.info(f"Created OpenAI generation provider: model={generation_model}") + logger.info("Created OpenAI generation provider: model=%s", generation_model) return provider elif provider_name == "ollama": @@ -96,7 +96,7 @@ async def create_generation_provider(provider_name: str) -> Provider: embedding_model=None, # Generation only generation_model=generation_model, ) - logger.info(f"Created Ollama generation provider: model={generation_model}") + logger.info("Created Ollama generation provider: model=%s", generation_model) return provider elif provider_name == "anthropic": @@ -114,7 +114,7 @@ async def create_generation_provider(provider_name: str) -> Provider: api_key=api_key, generation_model=generation_model, ) - logger.info(f"Created Anthropic generation provider: model={generation_model}") + logger.info("Created Anthropic generation provider: model=%s", generation_model) return provider elif provider_name == "bedrock": @@ -133,7 +133,7 @@ async def create_generation_provider(provider_name: str) -> Provider: embedding_model=None, # Generation only generation_model=generation_model, ) - logger.info(f"Created Bedrock generation provider: model={generation_model}") + logger.info("Created Bedrock generation provider: model=%s", generation_model) return provider else: @@ -178,7 +178,7 @@ async def create_embedding_provider(provider_name: str) -> Provider: embedding_model=embedding_model, generation_model=None, # Embeddings only ) - logger.info(f"Created OpenAI embedding provider: model={embedding_model}") + logger.info("Created OpenAI embedding provider: model=%s", embedding_model) return provider elif provider_name == "ollama": @@ -195,7 +195,7 @@ async def create_embedding_provider(provider_name: str) -> Provider: embedding_model=embedding_model, generation_model=None, # Embeddings only ) - logger.info(f"Created Ollama embedding provider: model={embedding_model}") + logger.info("Created Ollama embedding provider: model=%s", embedding_model) return provider elif provider_name == "bedrock": @@ -214,7 +214,7 @@ async def create_embedding_provider(provider_name: str) -> Provider: embedding_model=embedding_model, generation_model=None, # Embeddings only ) - logger.info(f"Created Bedrock embedding provider: model={embedding_model}") + logger.info("Created Bedrock embedding provider: model=%s", embedding_model) return provider else: diff --git a/tests/integration/sampling_support.py b/tests/integration/sampling_support.py index 20989a96..7ffec519 100644 --- a/tests/integration/sampling_support.py +++ b/tests/integration/sampling_support.py @@ -62,7 +62,7 @@ def create_sampling_callback(provider: Provider): params: types.CreateMessageRequestParams, ) -> types.CreateMessageResult | types.ErrorData: """Handle sampling requests using the configured provider.""" - logger.debug(f"Sampling callback invoked with {len(params.messages)} messages") + logger.debug("Sampling callback invoked with %s messages", len(params.messages)) # Extract messages and build prompt messages_text = [] @@ -77,7 +77,7 @@ def create_sampling_callback(provider: Provider): if params.systemPrompt: prompt = f"System: {params.systemPrompt}\n\n{prompt}" - logger.debug(f"Generating response for prompt ({len(prompt)} chars)") + logger.debug("Generating response for prompt (%s chars)", len(prompt)) try: # Generate response using provider @@ -87,7 +87,9 @@ def create_sampling_callback(provider: Provider): max_tokens=params.maxTokens, ) - logger.info(f"Sampling completed: {len(response)} chars from {model_name}") + logger.info( + "Sampling completed: %s chars from %s", len(response), model_name + ) return types.CreateMessageResult( role="assistant", @@ -96,7 +98,7 @@ def create_sampling_callback(provider: Provider): stopReason="endTurn", ) except Exception as e: - logger.error(f"Generation failed ({provider.__class__.__name__}): {e}") + logger.error("Generation failed (%s): %s", provider.__class__.__name__, e) return types.ErrorData( code=types.INTERNAL_ERROR, message=f"Generation failed: {e!s}", diff --git a/tests/integration/test_astrolabe_chunk_context.py b/tests/integration/test_astrolabe_chunk_context.py index 201ca49d..a58e9ae8 100644 --- a/tests/integration/test_astrolabe_chunk_context.py +++ b/tests/integration/test_astrolabe_chunk_context.py @@ -91,8 +91,10 @@ async def _poll_astrolabe_search_for_note( ) if note_result is not None: logger.info( - f"Note {note_id} surfaced in Astrolabe search after {attempts} " - f"attempts (~{attempts * 2}s)" + "Note %s surfaced in Astrolabe search after %s attempts (~%ss)", + note_id, + attempts, + attempts * 2, ) return note_result await anyio.sleep(2) @@ -245,7 +247,7 @@ async def test_chunk_context_endpoint_uses_app_password( "nc_notes_delete_note", {"note_id": note_id} ) except Exception as cleanup_err: - logger.warning(f"Cleanup failed for note {note_id}: {cleanup_err}") + logger.warning("Cleanup failed for note %s: %s", note_id, cleanup_err) await context.close() diff --git a/tests/integration/test_astrolabe_multi_user_background_sync.py b/tests/integration/test_astrolabe_multi_user_background_sync.py index 434c7ffb..e25058e9 100644 --- a/tests/integration/test_astrolabe_multi_user_background_sync.py +++ b/tests/integration/test_astrolabe_multi_user_background_sync.py @@ -40,7 +40,7 @@ async def login_to_nextcloud(page: Page, username: str, password: str): """ nextcloud_url = "http://localhost:8080" - logger.info(f"Logging in to Nextcloud as {username}...") + logger.info("Logging in to Nextcloud as %s...", username) await page.goto(f"{nextcloud_url}/login", wait_until="networkidle") # Fill in login form @@ -68,7 +68,7 @@ async def login_to_nextcloud(page: Page, username: str, password: str): assert "/login" not in current_url, ( f"Login failed for {username}, still on login page" ) - logger.info(f"✓ Successfully logged in as {username}") + logger.info("✓ Successfully logged in as %s", username) async def navigate_to_astrolabe_settings(page: Page): @@ -80,7 +80,7 @@ async def navigate_to_astrolabe_settings(page: Page): nextcloud_url = "http://localhost:8080" settings_url = f"{nextcloud_url}/settings/user/astrolabe" - logger.info(f"Navigating to Astrolabe settings: {settings_url}") + logger.info("Navigating to Astrolabe settings: %s", settings_url) await page.goto(settings_url, wait_until="networkidle", timeout=30000) # Verify we're on the settings page @@ -110,7 +110,7 @@ async def authorize_search_access(page: Page, username: str) -> bool: """ nextcloud_url = "http://localhost:8080" - logger.info(f"Authorizing search access (Step 1) for {username}...") + logger.info("Authorizing search access (Step 1) for %s...", username) # Check if already on Astrolabe settings page, if not navigate there if "/settings/user/astrolabe" not in page.url: @@ -124,7 +124,7 @@ async def authorize_search_access(page: Page, username: str) -> bool: # Check for "Active" badge (fully configured state) active_badge = page.get_by_text("Active", exact=True) if await active_badge.count() > 0 and await active_badge.is_visible(): - logger.info(f"✓ Already fully authorized for {username} (Active badge)") + logger.info("✓ Already fully authorized for %s (Active badge)", username) return True except Exception: pass @@ -136,7 +136,7 @@ async def authorize_search_access(page: Page, username: str) -> bool: step1_parent = step1_section.locator("..") complete_badge = step1_parent.get_by_text("Complete", exact=True) if await complete_badge.count() > 0 and await complete_badge.is_visible(): - logger.info(f"✓ Step 1 already complete for {username}") + logger.info("✓ Step 1 already complete for %s", username) return True except Exception: pass @@ -146,36 +146,38 @@ async def authorize_search_access(page: Page, username: str) -> bool: try: await authorize_button.wait_for(timeout=5000, state="visible") - logger.info(f"Found Authorize button for {username}") + logger.info("Found Authorize button for %s", username) except Exception: # Take screenshot for debugging screenshot_path = f"/tmp/astrolabe_no_authorize_button_{username}.png" await page.screenshot(path=screenshot_path) logger.error( - f"Could not find Authorize button for {username}. Screenshot: {screenshot_path}" + "Could not find Authorize button for %s. Screenshot: %s", + username, + screenshot_path, ) raise ValueError(f"Authorize button not found for {username}") # Click the Authorize button - this will redirect to OAuth provider # Use force=True to bypass stability check which can timeout due to CSS transitions await authorize_button.click(force=True) - logger.info(f"Clicked Authorize button for {username}") + logger.info("Clicked Authorize button for %s", username) # Wait for OAuth redirect to complete await page.wait_for_load_state("networkidle", timeout=30000) - logger.info(f"After networkidle, current URL: {page.url}") + logger.info("After networkidle, current URL: %s", page.url) # Take screenshot to see current state await page.screenshot(path=f"/tmp/astrolabe_after_authorize_{username}.png") - logger.info(f"Screenshot saved: /tmp/astrolabe_after_authorize_{username}.png") + logger.info("Screenshot saved: /tmp/astrolabe_after_authorize_%s.png", username) # Handle OIDC consent screen if present consent_handled = await _handle_oauth_consent_screen(page, username) if consent_handled: - logger.info(f"✓ OAuth consent granted for {username}") + logger.info("✓ OAuth consent granted for %s", username) else: logger.info( - f"No consent screen required for {username} (may be previously authorized)" + "No consent screen required for %s (may be previously authorized)", username ) # Wait for redirect back to Astrolabe settings @@ -184,12 +186,12 @@ async def authorize_search_access(page: Page, username: str) -> bool: await page.wait_for_url( f"**{nextcloud_url}/settings/user/astrolabe**", timeout=30000 ) - logger.info(f"Redirected back to Astrolabe settings for {username}") + logger.info("Redirected back to Astrolabe settings for %s", username) except Exception: # Check if we're already on settings page if "/settings/user/astrolabe" not in page.url: logger.warning( - f"Not redirected to Astrolabe settings, current URL: {page.url}" + "Not redirected to Astrolabe settings, current URL: %s", page.url ) # Navigate manually await page.goto( @@ -205,7 +207,9 @@ async def authorize_search_access(page: Page, username: str) -> bool: # First check if "Active" badge is shown (fully configured state) active_badge = page.get_by_text("Active", exact=True) if await active_badge.count() > 0 and await active_badge.is_visible(): - logger.info(f"✓ OAuth authorization complete for {username} (Active badge)") + logger.info( + "✓ OAuth authorization complete for %s (Active badge)", username + ) return True except Exception: pass @@ -217,7 +221,7 @@ async def authorize_search_access(page: Page, username: str) -> bool: step1_parent = step1_section.locator("..") complete_badge = step1_parent.get_by_text("Complete", exact=True) await complete_badge.wait_for(timeout=5000, state="visible") - logger.info(f"✓ Step 1 OAuth authorization complete for {username}") + logger.info("✓ Step 1 OAuth authorization complete for %s", username) return True except Exception: pass @@ -226,7 +230,9 @@ async def authorize_search_access(page: Page, username: str) -> bool: screenshot_path = f"/tmp/astrolabe_step1_not_complete_{username}.png" await page.screenshot(path=screenshot_path) logger.error( - f"Authorization badge not visible for {username}. Screenshot: {screenshot_path}" + "Authorization badge not visible for %s. Screenshot: %s", + username, + screenshot_path, ) raise ValueError(f"OAuth authorization did not complete for {username}") @@ -244,28 +250,28 @@ async def _handle_oauth_consent_screen(page: Page, username: str) -> bool: True if consent was handled, False if no consent screen was found """ try: - logger.info(f"Checking for consent screen at URL: {page.url}") + logger.info("Checking for consent screen at URL: %s", page.url) # Check if consent screen is present - try multiple selectors # The consent screen may be #oidc-consent or use a different format consent_div = await page.query_selector("#oidc-consent") if consent_div: - logger.info(f"Consent screen detected via #oidc-consent for {username}") + logger.info("Consent screen detected via #oidc-consent for %s", username) # Get consent screen data attributes for logging client_name = await consent_div.get_attribute("data-client-name") scopes_attr = await consent_div.get_attribute("data-scopes") - logger.info(f" Client: {client_name}") - logger.info(f" Requested scopes: {scopes_attr}") + logger.info(" Client: %s", client_name) + logger.info(" Requested scopes: %s", scopes_attr) else: # Check for Allow button directly (different consent screen format) allow_button = page.locator('button:has-text("Allow")') if await allow_button.count() > 0: - logger.info(f"Consent screen detected via Allow button for {username}") + logger.info("Consent screen detected via Allow button for %s", username) else: - logger.info(f"No consent screen found for {username} at {page.url}") + logger.info("No consent screen found for %s at %s", username, page.url) await page.screenshot(path=f"/tmp/no_consent_screen_{username}.png") - logger.info(f"Screenshot: /tmp/no_consent_screen_{username}.png") + logger.info("Screenshot: /tmp/no_consent_screen_%s.png", username) return False # Wait for Vue.js to render the Allow button @@ -275,19 +281,19 @@ async def _handle_oauth_consent_screen(page: Page, username: str) -> bool: except Exception as e: screenshot_path = f"/tmp/consent_no_allow_button_{username}.png" await page.screenshot(path=screenshot_path) - logger.error(f" Timeout waiting for Allow button: {e}") + logger.error(" Timeout waiting for Allow button: %s", e) raise # Check all scope checkboxes scope_checkboxes = await page.query_selector_all('input[type="checkbox"]') if scope_checkboxes: - logger.info(f" Found {len(scope_checkboxes)} scope checkboxes") + logger.info(" Found %s scope checkboxes", len(scope_checkboxes)) for i, checkbox in enumerate(scope_checkboxes): is_checked = await checkbox.is_checked() is_disabled = await checkbox.is_disabled() if not is_checked and not is_disabled: await checkbox.check() - logger.info(f" ✓ Checked scope checkbox {i + 1}") + logger.info(" ✓ Checked scope checkbox %s", i + 1) # Click the Allow button using JavaScript (handles viewport issues) allow_button_locator = page.locator('button:has-text("Allow")') @@ -295,16 +301,16 @@ async def _handle_oauth_consent_screen(page: Page, username: str) -> bool: # Debug: take screenshot before clicking Allow await page.screenshot(path=f"/tmp/consent_before_allow_{username}.png") logger.info( - f" Screenshot before Allow: /tmp/consent_before_allow_{username}.png" + " Screenshot before Allow: /tmp/consent_before_allow_%s.png", username ) button_count = await allow_button_locator.count() - logger.info(f" Found {button_count} Allow button(s)") + logger.info(" Found %s Allow button(s)", button_count) if button_count > 0: current_url = page.url - logger.info(f" Current URL: {current_url}") - logger.info(f" Clicking Allow button for {username}...") + logger.info(" Current URL: %s", current_url) + logger.info(" Clicking Allow button for %s...", username) # Use JavaScript click to handle consent buttons (proven pattern from conftest.py) # This is more reliable than Playwright's click for Vue.js rendered buttons @@ -327,10 +333,10 @@ async def _handle_oauth_consent_screen(page: Page, username: str) -> bool: lambda url: url != current_url, timeout=30000, ) - logger.info(f" URL changed to: {page.url}") + logger.info(" URL changed to: %s", page.url) except Exception as wait_error: # If URL didn't change, check console for errors - logger.warning(f" URL didn't change after click: {wait_error}") + logger.warning(" URL didn't change after click: %s", wait_error) await page.screenshot(path=f"/tmp/consent_after_allow_{username}.png") # Try alternative: manually POST consent and navigate @@ -356,21 +362,21 @@ async def _handle_oauth_consent_screen(page: Page, username: str) -> bool: } """ ) - logger.info(f" Manual consent returned URL: {redirect_url}") + logger.info(" Manual consent returned URL: %s", redirect_url) await page.goto(redirect_url, wait_until="networkidle") except Exception as manual_error: - logger.error(f" Manual consent also failed: {manual_error}") + logger.error(" Manual consent also failed: %s", manual_error) raise await page.screenshot(path=f"/tmp/consent_after_allow_{username}.png") - logger.info(f" Consent granted for {username}") + logger.info(" Consent granted for %s", username) return True else: - logger.error(f" Allow button not found for {username}") + logger.error(" Allow button not found for %s", username) return False except Exception as e: - logger.error(f"Error handling consent screen for {username}: {e}") + logger.error("Error handling consent screen for %s: %s", username, e) raise @@ -387,7 +393,7 @@ async def generate_app_password( Returns: The generated app password string """ - logger.info(f"Generating app password for {username}...") + logger.info("Generating app password for %s...", username) nextcloud_url = "http://localhost:8080" @@ -398,7 +404,7 @@ async def generate_app_password( # Fill the app password input field (selector confirmed via Playwright MCP) app_password_input = page.locator('input[placeholder="App name"]') await app_password_input.fill(app_name) - logger.info(f"Entered app name: {app_name}") + logger.info("Entered app name: %s", app_name) # Wait for Vue.js to react and enable the button (needs 1 second, not 0.5) await anyio.sleep(1.0) @@ -427,7 +433,7 @@ async def generate_app_password( # Debug screenshot after clicking create await page.screenshot(path=f"/tmp/app_password_after_create_{username}.png") logger.info( - f"Screenshot after create: /tmp/app_password_after_create_{username}.png" + "Screenshot after create: /tmp/app_password_after_create_%s.png", username ) # Find the Login input field which should have the username value @@ -440,7 +446,7 @@ async def generate_app_password( # Get all visible input elements all_inputs = await page.locator('input[type="text"]').all() - logger.info(f"Found {len(all_inputs)} text input elements") + logger.info("Found %s text input elements", len(all_inputs)) # Check each input to find the one with the app password for idx, input_elem in enumerate(all_inputs): @@ -449,15 +455,18 @@ async def generate_app_password( if value and "-" in value and len(value) > 20: app_password = value.strip() logger.info( - f"Found app password in input {idx}: '{app_password}' (length: {len(app_password)})" + "Found app password in input %s: '%s' (length: %s)", + idx, + app_password, + len(app_password), ) break except Exception as e: - logger.debug(f"Could not get value from input {idx}: {e}") + logger.debug("Could not get value from input %s: %s", idx, e) continue except Exception as e: - logger.error(f"Failed to find app password dialog or extract password: {e}") + logger.error("Failed to find app password dialog or extract password: %s", e) if not app_password: # Take screenshot for debugging @@ -474,9 +483,9 @@ async def generate_app_password( app_password, ): logger.error( - f"Extracted password does not match expected format: '{app_password}'" + "Extracted password does not match expected format: '%s'", app_password ) - logger.error(f"Password repr: {repr(app_password)}") + logger.error("Password repr: %s", repr(app_password)) screenshot_path = f"/tmp/app_password_invalid_format_{username}.png" await page.screenshot(path=screenshot_path) raise ValueError( @@ -484,7 +493,9 @@ async def generate_app_password( ) logger.info( - f"✓ Generated app password for {username}: {app_password[:10]}... (validated)" + "✓ Generated app password for %s: %s... (validated)", + username, + app_password[:10], ) # Close dialog with Escape key (bypasses CSS layout issues with h2 intercepting clicks) @@ -509,7 +520,7 @@ async def enable_background_sync_via_app_password( Returns: True if background sync was enabled successfully """ - logger.info(f"Enabling background sync via app password for {username}...") + logger.info("Enabling background sync via app password for %s...", username) nextcloud_url = "http://localhost:8080" @@ -524,7 +535,7 @@ async def enable_background_sync_via_app_password( def log_response(resp): response_info = f"{resp.status} {resp.url}" network_responses.append(response_info) - logger.info(f"Response: {response_info}") + logger.info("Response: %s", response_info) def log_console(msg): console_messages.append(f"[{msg.type}] {msg.text}") @@ -546,7 +557,7 @@ async def enable_background_sync_via_app_password( # First check for overall "Active" badge (both steps complete) active_text = page.get_by_text("Active", exact=True) if await active_text.is_visible(timeout=2000): - logger.info(f"✓ Background sync already active for {username}") + logger.info("✓ Background sync already active for %s", username) return True except Exception: pass @@ -558,7 +569,7 @@ async def enable_background_sync_via_app_password( step2_parent = step2_section.locator("..") complete_badge = step2_parent.get_by_text("Complete", exact=True) if await complete_badge.count() > 0 and await complete_badge.is_visible(): - logger.info(f"✓ Step 2 (app password) already complete for {username}") + logger.info("✓ Step 2 (app password) already complete for %s", username) return True except Exception: pass @@ -580,7 +591,7 @@ async def enable_background_sync_via_app_password( # Enter the app password await app_password_input.fill(app_password) - logger.info(f"Entered app password for {username}") + logger.info("Entered app password for %s", username) # Wait a moment for any validation to complete await anyio.sleep(0.5) @@ -588,14 +599,14 @@ async def enable_background_sync_via_app_password( # Take screenshot before clicking Save to check for warnings screenshot_path = f"/tmp/before_save_{username}.png" await page.screenshot(path=screenshot_path) - logger.info(f"Screenshot taken before Save: {screenshot_path}") + logger.info("Screenshot taken before Save: %s", screenshot_path) # Find and click the Save button save_button = page.get_by_role("button", name="Save") # Check if Save button is enabled is_disabled = await save_button.is_disabled() - logger.info(f"Save button disabled state: {is_disabled}") + logger.info("Save button disabled state: %s", is_disabled) await save_button.click() logger.info("Clicked Save button") @@ -604,24 +615,24 @@ async def enable_background_sync_via_app_password( await anyio.sleep(0.5) # Log network requests after clicking Save - logger.info(f"Network requests after Save for {username}:") + logger.info("Network requests after Save for %s:", username) for req in network_requests[-10:]: # Last 10 requests - logger.info(f" {req}") + logger.info(" %s", req) # Log network responses after clicking Save - logger.info(f"Network responses after Save for {username}:") + logger.info("Network responses after Save for %s:", username) for resp in network_responses[-10:]: # Last 10 responses - logger.info(f" {resp}") + logger.info(" %s", resp) # Check specifically for the credentials POST response credentials_responses = [ r for r in network_responses if "background-sync/credentials" in r ] if credentials_responses: - logger.info(f"Credentials endpoint response: {credentials_responses[-1]}") + logger.info("Credentials endpoint response: %s", credentials_responses[-1]) if "200" not in credentials_responses[-1]: logger.error( - f"Credentials POST did not return 200 OK: {credentials_responses[-1]}" + "Credentials POST did not return 200 OK: %s", credentials_responses[-1] ) else: logger.warning("No response found for credentials endpoint!") @@ -633,16 +644,16 @@ async def enable_background_sync_via_app_password( # Log any console messages if console_messages: - logger.info(f"Console messages for {username}:") + logger.info("Console messages for %s:", username) for msg in console_messages: - logger.info(f" {msg}") + logger.info(" %s", msg) # Check for error notifications (toast messages) try: error_toast = page.locator(".toastify.toast-error, .toast-error") if await error_toast.count() > 0: error_text = await error_toast.first.text_content() - logger.error(f"Error notification for {username}: {error_text}") + logger.error("Error notification for %s: %s", username, error_text) except Exception: pass @@ -653,7 +664,7 @@ async def enable_background_sync_via_app_password( if await active_text.count() > 0: await active_text.wait_for(timeout=5000, state="visible") logger.info( - f"✓ Background sync enabled for {username} - Active badge visible" + "✓ Background sync enabled for %s - Active badge visible", username ) return True except Exception: @@ -667,7 +678,8 @@ async def enable_background_sync_via_app_password( complete_badge = step2_parent.get_by_text("Complete", exact=True) await complete_badge.wait_for(timeout=5000, state="visible") logger.info( - f"✓ Step 2 (app password) enabled for {username} - Complete badge visible" + "✓ Step 2 (app password) enabled for %s - Complete badge visible", + username, ) return True except Exception: @@ -677,8 +689,9 @@ async def enable_background_sync_via_app_password( screenshot_path = f"/tmp/astrolabe_after_password_{username}.png" await page.screenshot(path=screenshot_path) logger.error( - f"Neither Active nor Complete badge appeared for {username}. " - f"Screenshot: {screenshot_path}" + "Neither Active nor Complete badge appeared for %s. Screenshot: %s", + username, + screenshot_path, ) raise ValueError(f"Background sync setup did not complete for {username}") @@ -702,7 +715,7 @@ async def complete_astrolabe_authorization( Returns: Dict with {"step1": bool, "step2": bool, "app_password": str | None} """ - logger.info(f"Starting full Astrolabe authorization for {username}...") + logger.info("Starting full Astrolabe authorization for %s...", username) result = {"step1": False, "step2": False, "app_password": None} @@ -712,9 +725,9 @@ async def complete_astrolabe_authorization( # Step 1: OAuth authorization try: result["step1"] = await authorize_search_access(page, username) - logger.info(f"✓ Step 1 complete for {username}") + logger.info("✓ Step 1 complete for %s", username) except Exception as e: - logger.error(f"Step 1 failed for {username}: {e}") + logger.error("Step 1 failed for %s: %s", username, e) raise # Navigate back to settings if needed (OAuth might have redirected elsewhere) @@ -728,7 +741,7 @@ async def complete_astrolabe_authorization( step2_parent = step2_section.locator("..") complete_badge = step2_parent.get_by_text("Complete", exact=True) if await complete_badge.count() > 0 and await complete_badge.is_visible(): - logger.info(f"✓ Step 2 already complete for {username}") + logger.info("✓ Step 2 already complete for %s", username) result["step2"] = True return result except Exception: @@ -738,7 +751,7 @@ async def complete_astrolabe_authorization( try: active_text = page.get_by_text("Active", exact=True) if await active_text.count() > 0 and await active_text.is_visible(): - logger.info(f"✓ Authorization already fully active for {username}") + logger.info("✓ Authorization already fully active for %s", username) result["step2"] = True return result except Exception: @@ -752,12 +765,12 @@ async def complete_astrolabe_authorization( result["step2"] = await enable_background_sync_via_app_password( page, username, app_password ) - logger.info(f"✓ Step 2 complete for {username}") + logger.info("✓ Step 2 complete for %s", username) except Exception as e: - logger.error(f"Step 2 failed for {username}: {e}") + logger.error("Step 2 failed for %s: %s", username, e) raise - logger.info(f"✓ Full Astrolabe authorization complete for {username}") + logger.info("✓ Full Astrolabe authorization complete for %s", username) return result @@ -773,7 +786,7 @@ async def verify_app_password_created(username: str) -> bool: Returns: True if background sync app password exists """ - logger.info(f"Verifying background sync app password for {username}...") + logger.info("Verifying background sync app password for %s...", username) # Query the database to check for background sync credentials # Astrolabe stores app passwords in oc_preferences, not oc_authtoken @@ -809,7 +822,7 @@ async def verify_app_password_created(username: str) -> bool: ) output = result.stdout - logger.debug(f"Background sync credentials query result:\n{output}") + logger.debug("Background sync credentials query result:\\n%s", output) # Check if background sync credentials exist # We should see 3 rows: background_sync_password, background_sync_type, background_sync_provisioned_at @@ -818,19 +831,22 @@ async def verify_app_password_created(username: str) -> bool: if len(lines) >= 3: # Header + at least 2 data rows (password + type) # Verify background_sync_type is "app_password" if "app_password" in output: - logger.info(f"✓ Background sync app password stored for {username}") + logger.info("✓ Background sync app password stored for %s", username) return True else: logger.warning( - f"Background sync credentials found but type is not app_password for {username}" + "Background sync credentials found but type is not app_password for %s", + username, ) return False else: - logger.warning(f"No background sync credentials found for {username}") + logger.warning("No background sync credentials found for %s", username) return False except Exception as e: - logger.error(f"Error checking background sync credentials for {username}: {e}") + logger.error( + "Error checking background sync credentials for %s: %s", username, e + ) return False @@ -892,10 +908,13 @@ def clear_stale_test_state(clear_preferences: bool = False) -> None: result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) if result.returncode != 0: logger.warning( - f"Failed to clear {label} (rc={result.returncode}): {result.stderr}" + "Failed to clear %s (rc=%s): %s", + label, + result.returncode, + result.stderr, ) else: - logger.debug(f"Cleared {label}") + logger.debug("Cleared %s", label) @pytest.mark.integration @@ -946,7 +965,9 @@ async def test_multi_user_astrolabe_background_sync_enablement( # Use nc_client to check if user exists user_details = await nc_client.users.get_user_details(username) logger.info( - f"✓ Confirmed {username} exists (display name: {user_details.displayname})" + "✓ Confirmed %s exists (display name: %s)", + username, + user_details.displayname, ) except Exception as e: raise AssertionError( @@ -957,9 +978,9 @@ async def test_multi_user_astrolabe_background_sync_enablement( results = {} for username in test_users: - logger.info(f"\n{'=' * 60}") - logger.info(f"Testing background sync enablement for: {username}") - logger.info(f"{'=' * 60}") + logger.info("\\n%s", "=" * 60) + logger.info("Testing background sync enablement for: %s", username) + logger.info("%s", "=" * 60) user_config = test_users_setup[username] password = user_config["password"] @@ -994,17 +1015,20 @@ async def test_multi_user_astrolabe_background_sync_enablement( "background_sync_active": sync_enabled and app_password_stored, } - logger.info(f"\n{username} results:") + logger.info("\\n%s results:", username) logger.info(" Settings accessed: ✓") - logger.info(f" App password generated: {'✓' if app_password else '✗'}") - logger.info(f" Sync enabled: {'✓' if sync_enabled else '✗'}") - logger.info(f" App password stored: {'✓' if app_password_stored else '✗'}") + logger.info(" App password generated: %s", "✓" if app_password else "✗") + logger.info(" Sync enabled: %s", "✓" if sync_enabled else "✗") logger.info( - f" Background sync active: {'✓' if (sync_enabled and app_password_stored) else '✗'}" + " App password stored: %s", "✓" if app_password_stored else "✗" + ) + logger.info( + " Background sync active: %s", + "✓" if (sync_enabled and app_password_stored) else "✗", ) except Exception as e: - logger.error(f"Error during {username} test: {e}") + logger.error("Error during %s test: %s", username, e) results[username] = { "settings_accessed": False, "app_password_generated": False, @@ -1018,18 +1042,18 @@ async def test_multi_user_astrolabe_background_sync_enablement( await context.close() # Verify all users succeeded - logger.info(f"\n{'=' * 60}") + logger.info("\\n%s", "=" * 60) logger.info("Test Summary") - logger.info(f"{'=' * 60}") + logger.info("%s", "=" * 60) for username, result in results.items(): - logger.info(f"\n{username}:") + logger.info("\\n%s:", username) for key, value in result.items(): if key != "error": status = "✓" if value else "✗" - logger.info(f" {key}: {status}") + logger.info(" %s: %s", key, status) elif value: - logger.info(f" error: {value}") + logger.info(" error: %s", value) # Assert all users successfully enabled background sync for username in test_users: @@ -1051,7 +1075,8 @@ async def test_multi_user_astrolabe_background_sync_enablement( ) logger.info( - f"\n✓ All {len(test_users)} users successfully enabled background sync via app passwords!" + "\\n✓ All %s users successfully enabled background sync via app passwords!", + len(test_users), ) @@ -1065,7 +1090,7 @@ async def revoke_background_sync_access(page: Page, username: str) -> bool: Returns: True if revocation was successful """ - logger.info(f"Revoking background sync access for {username}...") + logger.info("Revoking background sync access for %s...", username) nextcloud_url = "http://localhost:8080" @@ -1080,7 +1105,7 @@ async def revoke_background_sync_access(page: Page, username: str) -> bool: def log_response(resp): response_info = f"{resp.status} {resp.url}" network_responses.append(response_info) - logger.info(f"Response: {response_info}") + logger.info("Response: %s", response_info) def log_console(msg): console_messages.append(f"[{msg.type}] {msg.text}") @@ -1102,11 +1127,11 @@ async def revoke_background_sync_access(page: Page, username: str) -> bool: active_text = page.get_by_text("Active", exact=True) if not await active_text.is_visible(timeout=2000): logger.warning( - f"Background sync not active for {username}, nothing to revoke" + "Background sync not active for %s, nothing to revoke", username ) return False except Exception: - logger.warning(f"Could not find Active badge for {username}") + logger.warning("Could not find Active badge for %s", username) return False # Find the "Revoke Access" button @@ -1134,21 +1159,21 @@ async def revoke_background_sync_access(page: Page, username: str) -> bool: await anyio.sleep(2) # Log network requests after clicking - logger.info(f"Network requests after Revoke for {username}:") + logger.info("Network requests after Revoke for %s:", username) for req in network_requests[-10:]: - logger.info(f" {req}") + logger.info(" %s", req) # Log network responses - logger.info(f"Network responses after Revoke for {username}:") + logger.info("Network responses after Revoke for %s:", username) for resp in network_responses[-10:]: - logger.info(f" {resp}") + logger.info(" %s", resp) # Check specifically for the revoke POST response revoke_responses = [r for r in network_responses if "credentials/revoke" in r] if revoke_responses: - logger.info(f"Revoke endpoint response: {revoke_responses[-1]}") + logger.info("Revoke endpoint response: %s", revoke_responses[-1]) if "200" not in revoke_responses[-1]: - logger.error(f"Revoke POST did not return 200 OK: {revoke_responses[-1]}") + logger.error("Revoke POST did not return 200 OK: %s", revoke_responses[-1]) return False else: logger.warning("No response found for credentials/revoke endpoint!") @@ -1159,16 +1184,16 @@ async def revoke_background_sync_access(page: Page, username: str) -> bool: # Log any console messages if console_messages: - logger.info(f"Console messages for {username}:") + logger.info("Console messages for %s:", username) for msg in console_messages: - logger.info(f" {msg}") + logger.info(" %s", msg) # Check for error notifications (toast messages) try: error_toast = page.locator(".toastify.toast-error, .toast-error") if await error_toast.count() > 0: error_text = await error_toast.first.text_content() - logger.error(f"Error notification for {username}: {error_text}") + logger.error("Error notification for %s: %s", username, error_text) return False except Exception: pass @@ -1177,14 +1202,14 @@ async def revoke_background_sync_access(page: Page, username: str) -> bool: try: active_text = page.get_by_text("Active", exact=True) if await active_text.is_visible(timeout=2000): - logger.error(f"Active badge still visible for {username} after revoke!") + logger.error("Active badge still visible for %s after revoke!", username) screenshot_path = f"/tmp/astrolabe_revoke_still_active_{username}.png" await page.screenshot(path=screenshot_path) return False except Exception: pass - logger.info(f"✓ Background sync access revoked for {username}") + logger.info("✓ Background sync access revoked for %s", username) return True @@ -1197,7 +1222,7 @@ async def verify_app_password_deleted(username: str) -> bool: Returns: True if background sync credentials no longer exist """ - logger.info(f"Verifying background sync credentials deleted for {username}...") + logger.info("Verifying background sync credentials deleted for %s...", username) query = f""" SELECT userid, configkey, configvalue @@ -1230,18 +1255,20 @@ async def verify_app_password_deleted(username: str) -> bool: ) output = result.stdout - logger.debug(f"Background sync credentials query result:\n{output}") + logger.debug("Background sync credentials query result:\\n%s", output) # After deletion, we should NOT see background_sync_password if "background_sync_password" not in output: - logger.info(f"✓ Background sync credentials deleted for {username}") + logger.info("✓ Background sync credentials deleted for %s", username) return True else: - logger.warning(f"Background sync credentials still exist for {username}") + logger.warning("Background sync credentials still exist for %s", username) return False except Exception as e: - logger.error(f"Error checking background sync credentials for {username}: {e}") + logger.error( + "Error checking background sync credentials for %s: %s", username, e + ) return False @@ -1316,7 +1343,9 @@ async def test_revoke_background_sync_access( f"Background sync credentials not deleted for {username}" ) - logger.info(f"\n✓ Successfully revoked background sync access for {username}!") + logger.info( + "\\n✓ Successfully revoked background sync access for %s!", username + ) finally: await context.close() diff --git a/tests/integration/test_astrolabe_plotly_visualization.py b/tests/integration/test_astrolabe_plotly_visualization.py index e956e0c0..dbe62212 100644 --- a/tests/integration/test_astrolabe_plotly_visualization.py +++ b/tests/integration/test_astrolabe_plotly_visualization.py @@ -58,7 +58,7 @@ async def wait_for_vector_sync( while waited < timeout_seconds: sync_status = await mcp_client.call_tool("nc_get_vector_sync_status", {}) if sync_status.isError: - logger.warning(f"Vector sync status error: {sync_status}") + logger.warning("Vector sync status error: %s", sync_status) return False, None status_data = json.loads(sync_status.content[0].text) @@ -66,14 +66,18 @@ async def wait_for_vector_sync( pending_count = status_data.get("pending_count", 1) logger.info( - f"Sync status at {waited}s: indexed={indexed_count}, " - f"pending={pending_count}, status={status_data.get('status')}" + "Sync status at %ss: indexed=%s, pending=%s, status=%s", + waited, + indexed_count, + pending_count, + status_data.get("status"), ) if indexed_count > initial_indexed_count and pending_count == 0: logger.info( - f"✓ Sync complete: {indexed_count} documents indexed " - f"(was {initial_indexed_count})" + "✓ Sync complete: %s documents indexed (was %s)", + indexed_count, + initial_indexed_count, ) return True, status_data @@ -142,7 +146,7 @@ async def test_astrolabe_plotly_visualization_with_basic_auth( # Phase 2: Complete full Astrolabe authorization (OAuth + app password) await login_to_nextcloud(page, username, password) auth_result = await complete_astrolabe_authorization(page, username, password) - logger.info(f"Authorization result: {auth_result}") + logger.info("Authorization result: %s", auth_result) # Create MCP client session as alice - all MCP operations inside this block async with create_mcp_client_session( @@ -160,7 +164,7 @@ async def test_astrolabe_plotly_visualization_with_basic_auth( initial_data = json.loads(initial_sync.content[0].text) initial_count = initial_data.get("indexed_count", 0) - logger.info(f"Initial indexed count: {initial_count}") + logger.info("Initial indexed count: %s", initial_count) # Create note with unique searchable term unique_term = f"plotly_viz_test_{uuid.uuid4().hex[:8]}" @@ -189,7 +193,7 @@ The visualization should show this document as a point in PCA-reduced space. note_data = json.loads(note_response.content[0].text) note_id = note_data.get("id") - logger.info(f"Created test note ID: {note_id}") + logger.info("Created test note ID: %s", note_id) # Phase 4: Wait for vector indexing sync_complete, status = await wait_for_vector_sync( @@ -205,7 +209,7 @@ The visualization should show this document as a point in PCA-reduced space. search_input = page.locator(".mcp-search-input input") await search_input.wait_for(timeout=10000, state="visible") await search_input.fill(unique_term) - logger.info(f"Entered search query: {unique_term}") + logger.info("Entered search query: %s", unique_term) # Trigger search by pressing Enter on the input field # This is wired to performSearch via @keyup.enter in the Vue component @@ -246,7 +250,7 @@ The visualization should show this document as a point in PCA-reduced space. for attempt in range(60): # 60 attempts, 500ms each = 30s total if await error_note.count() > 0: error_text = await error_note.text_content() - logger.error(f"Search error: {error_text}") + logger.error("Search error: %s", error_text) pytest.fail(f"Search failed with error: {error_text}") if await no_results_text.count() > 0: @@ -261,13 +265,13 @@ The visualization should show this document as a point in PCA-reduced space. if await results_text_pattern.count() > 0: results_text = await results_text_pattern.first.text_content() - logger.info(f"Found results: {results_text}") + logger.info("Found results: %s", results_text) found_state = True break if attempt % 10 == 0: logger.info( - f"Waiting for results... (attempt {attempt + 1}/60)" + "Waiting for results... (attempt %s/60)", attempt + 1 ) await anyio.sleep(0.5) @@ -275,8 +279,8 @@ The visualization should show this document as a point in PCA-reduced space. if not found_state: await page.screenshot(path="/tmp/astrolabe_search_timeout.png") page_content = await page.content() - logger.error(f"Search state not resolved. Page URL: {page.url}") - logger.error(f"Page content snippet: {page_content[:2000]}") + logger.error("Search state not resolved. Page URL: %s", page.url) + logger.error("Page content snippet: %s", page_content[:2000]) raise AssertionError("Search did not complete within timeout") except AssertionError: @@ -285,8 +289,8 @@ The visualization should show this document as a point in PCA-reduced space. # Take another screenshot and get page content for debugging await page.screenshot(path="/tmp/astrolabe_search_timeout.png") page_content = await page.content() - logger.error(f"Search state not resolved. Page URL: {page.url}") - logger.error(f"Page content snippet: {page_content[:2000]}") + logger.error("Search state not resolved. Page URL: %s", page.url) + logger.error("Page content snippet: %s", page_content[:2000]) raise AssertionError(f"Search did not complete: {e}") logger.info("Results loaded") @@ -316,7 +320,7 @@ The visualization should show this document as a point in PCA-reduced space. result_items = page.locator(".mcp-result-item") result_count = await result_items.count() assert result_count > 0, "No search results displayed" - logger.info(f"✓ Found {result_count} search result(s)") + logger.info("✓ Found %s search result(s)", result_count) # Verify our note appears in results found_note = False @@ -326,7 +330,7 @@ The visualization should show this document as a point in PCA-reduced space. title_text = await title_elem.text_content() if title_text and unique_term in title_text: found_note = True - logger.info(f"✓ Found test note in results: {title_text}") + logger.info("✓ Found test note in results: %s", title_text) break assert found_note, f"Created note with '{unique_term}' not found in results" @@ -342,14 +346,14 @@ The visualization should show this document as a point in PCA-reduced space. "nc_notes_delete_note", {"note_id": note_id} ) if not delete_response.isError: - logger.info(f"✓ Cleaned up test note {note_id}") + logger.info("✓ Cleaned up test note %s", note_id) note_id = None # Mark as cleaned else: logger.warning( - f"Failed to delete note {note_id}: {delete_response}" + "Failed to delete note %s: %s", note_id, delete_response ) except Exception as e: - logger.warning(f"Cleanup failed for note {note_id}: {e}") + logger.warning("Cleanup failed for note %s: %s", note_id, e) finally: # Cleanup note if not already cleaned (create new client for cleanup) @@ -364,13 +368,13 @@ The visualization should show this document as a point in PCA-reduced space. "nc_notes_delete_note", {"note_id": note_id} ) if not delete_response.isError: - logger.info(f"✓ Cleaned up test note {note_id} (finally)") + logger.info("✓ Cleaned up test note %s (finally)", note_id) else: logger.warning( - f"Failed to delete note {note_id}: {delete_response}" + "Failed to delete note %s: %s", note_id, delete_response ) except Exception as e: - logger.warning(f"Cleanup failed for note {note_id}: {e}") + logger.warning("Cleanup failed for note %s: %s", note_id, e) # Close browser context await context.close() diff --git a/tests/integration/test_astrolabe_token_refresh.py b/tests/integration/test_astrolabe_token_refresh.py index a93ec2c9..e9b9c553 100644 --- a/tests/integration/test_astrolabe_token_refresh.py +++ b/tests/integration/test_astrolabe_token_refresh.py @@ -45,7 +45,7 @@ async def login_to_nextcloud(page: Page, username: str, password: str): """ nextcloud_url = "http://localhost:8080" - logger.info(f"Logging in to Nextcloud as {username}...") + logger.info("Logging in to Nextcloud as %s...", username) await page.goto(f"{nextcloud_url}/login", wait_until="networkidle") # Fill in login form @@ -62,7 +62,7 @@ async def login_to_nextcloud(page: Page, username: str, password: str): assert "/login" not in current_url, ( f"Login failed for {username}, still on login page" ) - logger.info(f"✓ Successfully logged in as {username}") + logger.info("✓ Successfully logged in as %s", username) async def generate_app_password( @@ -78,7 +78,7 @@ async def generate_app_password( Returns: The generated app password string """ - logger.info(f"Generating app password for {username}...") + logger.info("Generating app password for %s...", username) nextcloud_url = "http://localhost:8080" @@ -89,7 +89,7 @@ async def generate_app_password( # Fill the app password input field app_password_input = page.locator('input[placeholder="App name"]') await app_password_input.fill(app_name) - logger.info(f"Entered app name: {app_name}") + logger.info("Entered app name: %s", app_name) # Wait for Vue.js to react and enable the button await anyio.sleep(1.0) @@ -116,12 +116,12 @@ async def generate_app_password( value = await input_elem.input_value() if value and "-" in value and len(value) > 20: app_password = value.strip() - logger.info(f"Found app password in input {idx}") + logger.info("Found app password in input %s", idx) break except Exception: continue except Exception as e: - logger.error(f"Failed to find app password dialog: {e}") + logger.error("Failed to find app password dialog: %s", e) if not app_password: screenshot_path = f"/tmp/app_password_generation_{username}.png" @@ -137,7 +137,7 @@ async def generate_app_password( ): raise ValueError(f"App password format validation failed: {app_password}") - logger.info(f"✓ Generated app password for {username}") + logger.info("✓ Generated app password for %s", username) # Close the dialog close_button = page.get_by_role("button", name="Close") @@ -163,7 +163,7 @@ async def save_app_password_in_astrolabe( Returns: True if the password was saved successfully (based on network response) """ - logger.info(f"Saving app password in Astrolabe for {username}...") + logger.info("Saving app password in Astrolabe for %s...", username) nextcloud_url = "http://localhost:8080" @@ -174,7 +174,7 @@ async def save_app_password_in_astrolabe( nonlocal credentials_response_status if "background-sync/credentials" in resp.url or "storeAppPassword" in resp.url: credentials_response_status = resp.status - logger.info(f"Credentials endpoint response: {resp.status} {resp.url}") + logger.info("Credentials endpoint response: %s %s", resp.status, resp.url) page.on("response", capture_response) @@ -188,7 +188,7 @@ async def save_app_password_in_astrolabe( try: complete_badge = page.locator('text="Complete"').first if await complete_badge.is_visible(timeout=2000): - logger.info(f"✓ App password already configured for {username}") + logger.info("✓ App password already configured for %s", username) return True except Exception: pass @@ -208,7 +208,7 @@ async def save_app_password_in_astrolabe( # Enter the app password await app_password_input.fill(app_password) - logger.info(f"Entered app password for {username}") + logger.info("Entered app password for %s", username) await anyio.sleep(0.5) @@ -223,11 +223,13 @@ async def save_app_password_in_astrolabe( # Verify the save was successful by checking network response if credentials_response_status == 200: - logger.info(f"✓ App password saved successfully for {username}") + logger.info("✓ App password saved successfully for %s", username) return True else: logger.error( - f"App password save failed for {username}, status: {credentials_response_status}" + "App password save failed for %s, status: %s", + username, + credentials_response_status, ) screenshot_path = f"/tmp/astrolabe_save_failed_{username}.png" await page.screenshot(path=screenshot_path) @@ -284,7 +286,7 @@ def get_background_sync_credentials(username: str) -> dict | None: return None except Exception as e: - logger.error(f"Error getting credentials for {username}: {e}") + logger.error("Error getting credentials for %s: %s", username, e) return None @@ -325,11 +327,11 @@ def delete_user_credentials(username: str) -> bool: timeout=10, ) - logger.info(f"Deleted credentials for {username}") + logger.info("Deleted credentials for %s", username) return result.returncode == 0 except Exception as e: - logger.error(f"Error deleting credentials for {username}: {e}") + logger.error("Error deleting credentials for %s: %s", username, e) return False @@ -489,7 +491,7 @@ async def test_credential_isolation_between_users( # Verify stored creds = get_background_sync_credentials(username) assert creds is not None, f"Credentials not stored for {username}" - logger.info(f"✓ Credentials provisioned for {username}") + logger.info("✓ Credentials provisioned for %s", username) finally: await context.close() diff --git a/tests/integration/test_deck_reorder_card.py b/tests/integration/test_deck_reorder_card.py index 3a083159..19cc8b3a 100644 --- a/tests/integration/test_deck_reorder_card.py +++ b/tests/integration/test_deck_reorder_card.py @@ -26,7 +26,7 @@ async def board_with_two_stacks(nc_client: NextcloudClient): board_title = f"Reorder Test Board {unique_suffix}" board = None - logger.info(f"Creating board with two stacks: {board_title}") + logger.info("Creating board with two stacks: %s", board_title) try: board = await nc_client.deck.create_board(board_title, "0000FF") board_id = board.id @@ -40,7 +40,7 @@ async def board_with_two_stacks(nc_client: NextcloudClient): "title": source_stack.title, "order": source_stack.order, } - logger.info(f"Created source stack with ID: {source_stack.id}") + logger.info("Created source stack with ID: %s", source_stack.id) # Create target stack (stack 2) target_stack = await nc_client.deck.create_stack( @@ -51,7 +51,7 @@ async def board_with_two_stacks(nc_client: NextcloudClient): "title": target_stack.title, "order": target_stack.order, } - logger.info(f"Created target stack with ID: {target_stack.id}") + logger.info("Created target stack with ID: %s", target_stack.id) board_data = { "id": board_id, @@ -63,11 +63,11 @@ async def board_with_two_stacks(nc_client: NextcloudClient): finally: if board: - logger.info(f"Cleaning up board ID: {board.id}") + logger.info("Cleaning up board ID: %s", board.id) try: await nc_client.deck.delete_board(board.id) except Exception as e: - logger.warning(f"Error cleaning up board: {e}") + logger.warning("Error cleaning up board: %s", e) async def test_reorder_card_move_to_different_stack( @@ -90,7 +90,7 @@ async def test_reorder_card_move_to_different_stack( board_id, source_stack_id, card_title, description="Card to be moved" ) card_id = card.id - logger.info(f"Created card ID: {card_id} in source stack ID: {source_stack_id}") + logger.info("Created card ID: %s in source stack ID: %s", card_id, source_stack_id) try: # Verify card is in source stack @@ -99,12 +99,14 @@ async def test_reorder_card_move_to_different_stack( f"Card should start in source stack {source_stack_id}, " f"but is in {card_before.stackId}" ) - logger.info(f"Verified card is in source stack: {source_stack_id}") + logger.info("Verified card is in source stack: %s", source_stack_id) # Move card to target stack logger.info( - f"Moving card {card_id} from stack {source_stack_id} " - f"to stack {target_stack_id}" + "Moving card %s from stack %s to stack %s", + card_id, + source_stack_id, + target_stack_id, ) await nc_client.deck.reorder_card( board_id=board_id, @@ -122,7 +124,7 @@ async def test_reorder_card_move_to_different_stack( f"Card should have moved to target stack {target_stack_id}, " f"but is in {card_after.stackId}" ) - logger.info(f"SUCCESS: Card moved to target stack {target_stack_id}") + logger.info("SUCCESS: Card moved to target stack %s", target_stack_id) finally: # Clean up - try to delete from target stack first, then source @@ -132,7 +134,7 @@ async def test_reorder_card_move_to_different_stack( try: await nc_client.deck.delete_card(board_id, source_stack_id, card_id) except Exception as e: - logger.warning(f"Error cleaning up card: {e}") + logger.warning("Error cleaning up card: %s", e) async def test_reorder_card_within_same_stack( @@ -151,7 +153,7 @@ async def test_reorder_card_within_same_stack( card2 = await nc_client.deck.create_card( board_id, source_stack_id, f"Card 2 {unique_suffix}", order=1 ) - logger.info(f"Created cards {card1.id} (order 0) and {card2.id} (order 1)") + logger.info("Created cards %s (order 0) and %s (order 1)", card1.id, card2.id) try: # Reorder card1 to position after card2 @@ -162,7 +164,7 @@ async def test_reorder_card_within_same_stack( order=2, # Move to position 2 target_stack_id=source_stack_id, # Same stack ) - logger.info(f"Reordered card {card1.id} to order 2") + logger.info("Reordered card %s to order 2", card1.id) # Verify card is still in the same stack card_after = await nc_client.deck.get_card(board_id, source_stack_id, card1.id) @@ -174,4 +176,4 @@ async def test_reorder_card_within_same_stack( await nc_client.deck.delete_card(board_id, source_stack_id, card1.id) await nc_client.deck.delete_card(board_id, source_stack_id, card2.id) except Exception as e: - logger.warning(f"Error cleaning up cards: {e}") + logger.warning("Error cleaning up cards: %s", e) diff --git a/tests/integration/test_rag.py b/tests/integration/test_rag.py index 7df498cc..61103c6a 100644 --- a/tests/integration/test_rag.py +++ b/tests/integration/test_rag.py @@ -129,7 +129,7 @@ async def indexed_manual_pdf(nc_client, nc_mcp_client): manual_path = os.getenv("RAG_MANUAL_PATH", DEFAULT_MANUAL_PATH) - logger.info(f"Setting up indexed manual PDF: {manual_path}") + logger.info("Setting up indexed manual PDF: %s", manual_path) # Get file info to verify file exists and get file ID. After the # round-7 contract widening, get_file_info raises HTTPStatusError on @@ -145,16 +145,16 @@ async def indexed_manual_pdf(nc_client, nc_mcp_client): pytest.skip(f"Manual PDF unreadable at '{manual_path}' (malformed PROPFIND)") file_id = file_info["id"] - logger.info(f"Found manual PDF: {manual_path} (file_id={file_id})") + logger.info("Found manual PDF: %s (file_id=%s)", manual_path, file_id) # Create or get the vector-index tag tag = await nc_client.webdav.get_or_create_tag("vector-index") tag_id = tag["id"] - logger.info(f"Using tag 'vector-index' (tag_id={tag_id})") + logger.info("Using tag 'vector-index' (tag_id=%s)", tag_id) # Assign tag to file await nc_client.webdav.assign_tag_to_file(file_id, tag_id) - logger.info(f"Tagged file {file_id} with vector-index tag") + logger.info("Tagged file %s with vector-index tag", file_id) # Wait for vector sync to complete indexing max_attempts = 60 @@ -176,23 +176,26 @@ async def indexed_manual_pdf(nc_client, nc_mcp_client): pending = content.get("pending_count", 1) logger.info( - f"Attempt {attempt}/{max_attempts}: " - f"indexed={indexed}, pending={pending}" + "Attempt %s/%s: indexed=%s, pending=%s", + attempt, + max_attempts, + indexed, + pending, ) if indexed > 0 and pending == 0: logger.info( - f"Vector indexing complete: {indexed} documents indexed" + "Vector indexing complete: %s documents indexed", indexed ) break except Exception as e: - logger.warning(f"Attempt {attempt}: Error checking status: {e}") + logger.warning("Attempt %s: Error checking status: %s", attempt, e) if attempt < max_attempts: await anyio.sleep(poll_interval) else: logger.warning( - f"Vector indexing may not be complete after {max_attempts} attempts" + "Vector indexing may not be complete after %s attempts", max_attempts ) yield { diff --git a/tests/integration/test_unstructured_api.py b/tests/integration/test_unstructured_api.py index 0e14ff33..b8e1ab96 100644 --- a/tests/integration/test_unstructured_api.py +++ b/tests/integration/test_unstructured_api.py @@ -57,7 +57,7 @@ async def test_unstructured_api_enabled_parsing( await nc_client.webdav.write_file( test_file, pdf_content, content_type="application/pdf" ) - logger.info(f"Uploaded PDF file: {test_file}") + logger.info("Uploaded PDF file: %s", test_file) # Read the PDF using MCP tool (should parse via Unstructured API) mcp_result = await nc_mcp_client.call_tool( @@ -123,7 +123,7 @@ async def test_unstructured_api_with_docx( docx_content, content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", ) - logger.info(f"Uploaded DOCX file: {test_file}") + logger.info("Uploaded DOCX file: %s", test_file) # Read the file using MCP tool mcp_result = await nc_mcp_client.call_tool( diff --git a/tests/load/benchmark.py b/tests/load/benchmark.py index 892ad592..2eb1ca73 100644 --- a/tests/load/benchmark.py +++ b/tests/load/benchmark.py @@ -215,7 +215,7 @@ class BenchmarkMetrics: @asynccontextmanager async def create_mcp_session(url: str): """Create an MCP client session with proper cleanup.""" - logger.info(f"Creating MCP client session for {url}") + logger.info("Creating MCP client session for %s", url) streamable_context = streamablehttp_client(url) session_context = None @@ -231,17 +231,17 @@ async def create_mcp_session(url: str): try: await session_context.__aexit__(None, None, None) except Exception as e: - logger.debug(f"Error closing session: {e}") + logger.debug("Error closing session: %s", e) try: await streamable_context.__aexit__(None, None, None) except Exception as e: - logger.debug(f"Error closing streamable context: {e}") + logger.debug("Error closing streamable context: %s", e) async def wait_for_mcp_server(url: str, max_attempts: int = 10) -> bool: """Wait for MCP server to be ready.""" - logger.info(f"Waiting for MCP server at {url}...") + logger.info("Waiting for MCP server at %s...", url) for attempt in range(1, max_attempts + 1): try: @@ -252,10 +252,10 @@ async def wait_for_mcp_server(url: str, max_attempts: int = 10) -> bool: return True except Exception as e: if attempt < max_attempts: - logger.debug(f"Attempt {attempt}/{max_attempts}: {e}") + logger.debug("Attempt %s/%s: %s", attempt, max_attempts, e) await anyio.sleep(2) else: - logger.error(f"MCP server not ready after {max_attempts} attempts") + logger.error("MCP server not ready after %s attempts", max_attempts) return False return False @@ -269,7 +269,7 @@ async def benchmark_worker( stop_event: anyio.Event, ): """Single worker that runs operations for the specified duration.""" - logger.info(f"Worker {worker_id} starting...") + logger.info("Worker %s starting...", worker_id) try: async with create_mcp_session(url) as session: @@ -297,10 +297,10 @@ async def benchmark_worker( # Cleanup await ops.cleanup() - logger.info(f"Worker {worker_id} completed {operation_count} operations") + logger.info("Worker %s completed %s operations", worker_id, operation_count) except Exception as e: - logger.error(f"Worker {worker_id} error: {e}", exc_info=True) + logger.error("Worker %s error: %s", worker_id, e, exc_info=True) async def run_benchmark( diff --git a/tests/load/oauth_benchmark.py b/tests/load/oauth_benchmark.py index bd6fdcff..2197d825 100644 --- a/tests/load/oauth_benchmark.py +++ b/tests/load/oauth_benchmark.py @@ -74,7 +74,7 @@ class OAuthCallbackServer: if code and state: self.auth_states[state] = code - logger.info(f"Captured auth code for state {state[:16]}...") + logger.info("Captured auth code for state %s...", state[:16]) self.send_response(200) self.send_header("Content-type", "text/html") @@ -94,7 +94,9 @@ class OAuthCallbackServer: self.server = HTTPServer((self.host, self.port), CallbackHandler) def run(): - logger.info(f"OAuth callback server listening on {self.host}:{self.port}") + logger.info( + "OAuth callback server listening on %s:%s", self.host, self.port + ) self.server.serve_forever() self.thread = threading.Thread(target=run, daemon=True) @@ -135,7 +137,7 @@ async def discover_oidc_endpoints(nextcloud_host: str) -> dict[str, str]: "token_endpoint": config["token_endpoint"], "registration_endpoint": config["registration_endpoint"], } - logger.info(f"Discovered endpoints: {endpoints}") + logger.info("Discovered endpoints: %s", endpoints) return endpoints @@ -168,7 +170,7 @@ async def setup_oauth_client( redirect_uris=[callback_url], ) - logger.info(f"OAuth client setup complete (client_id: {client_info.client_id})") + logger.info("OAuth client setup complete (client_id: %s)", client_info.client_id) return { "client_id": client_info.client_id, "client_secret": client_info.client_secret, @@ -197,7 +199,7 @@ async def create_and_authenticate_user( Returns: OAuth access token for the user """ - logger.info(f"Creating and authenticating user: {username}") + logger.info("Creating and authenticating user: %s", username) # Create Nextcloud user await user_pool.create_nextcloud_user( @@ -218,7 +220,7 @@ async def create_and_authenticate_user( auth_states=auth_states, ) - logger.info(f"Successfully authenticated user: {username}") + logger.info("Successfully authenticated user: %s", username) return token @@ -239,7 +241,7 @@ async def oauth_benchmark_worker( metrics: Metrics collector stop_event: Event to signal stop """ - logger.info(f"Worker for {user_wrapper.username} starting...") + logger.info("Worker for %s starting...", user_wrapper.username) start_time = time.time() operation_count = 0 @@ -265,18 +267,21 @@ async def oauth_benchmark_worker( await anyio.sleep(0.05) logger.info( - f"Worker for {user_wrapper.username} completed {operation_count} operations" + "Worker for %s completed %s operations", + user_wrapper.username, + operation_count, ) except anyio.get_cancelled_exc_class(): # Handle task cancellation gracefully (e.g., during benchmark shutdown) logger.info( - f"Worker for {user_wrapper.username} was cancelled " - f"(completed {operation_count} operations)" + "Worker for %s was cancelled (completed %s operations)", + user_wrapper.username, + operation_count, ) raise # Re-raise to allow proper cleanup except Exception as e: - logger.error(f"Worker {user_wrapper.username} error: {e}", exc_info=True) + logger.error("Worker %s error: %s", user_wrapper.username, e, exc_info=True) async def show_progress( @@ -432,7 +437,9 @@ async def run_oauth_benchmark( return (username, password, token) except Exception as e: - logger.error(f"Failed to create/authenticate user {username}: {e}") + logger.error( + "Failed to create/authenticate user %s: %s", username, e + ) return None async with async_playwright() as p: @@ -452,7 +459,7 @@ async def run_oauth_benchmark( ) results.append(result) except Exception as e: - logger.error(f"User creation task failed: {e}") + logger.error("User creation task failed: %s", e) results.append(e) async with anyio.create_task_group() as tg: @@ -462,7 +469,7 @@ async def run_oauth_benchmark( # Process results for result in results: if isinstance(result, Exception): - logger.error(f"User creation task failed: {result}") + logger.error("User creation task failed: %s", result) continue if result is None: continue @@ -496,7 +503,7 @@ async def run_oauth_benchmark( print(f" ✓ Session created for '{username}'") return wrapper except Exception as e: - logger.error(f"Failed to create session for {username}: {e}") + logger.error("Failed to create session for %s: %s", username, e) return None # Create all sessions concurrently using anyio task groups @@ -508,7 +515,7 @@ async def run_oauth_benchmark( result = await create_session_task(username) session_results.append(result) except Exception as e: - logger.error(f"Session creation task failed: {e}") + logger.error("Session creation task failed: %s", e) session_results.append(e) async with anyio.create_task_group() as tg: @@ -518,7 +525,7 @@ async def run_oauth_benchmark( # Process results for result in session_results: if isinstance(result, Exception): - logger.error(f"Session creation task failed: {result}") + logger.error("Session creation task failed: %s", result) continue if result is not None: user_wrappers.append(result) @@ -573,7 +580,7 @@ async def run_oauth_benchmark( print("✓ All sessions closed\n") except Exception as e: - logger.error(f"Benchmark error: {e}", exc_info=True) + logger.error("Benchmark error: %s", e, exc_info=True) # Don't re-raise here - we want cleanup to run finally: @@ -583,7 +590,7 @@ async def run_oauth_benchmark( callback_server.stop() logger.info("OAuth callback server stopped") except Exception as e: - logger.warning(f"Error stopping callback server: {e}") + logger.warning("Error stopping callback server: %s", e) # Cleanup test users if cleanup and created_users: @@ -596,10 +603,10 @@ async def run_oauth_benchmark( await cleanup_client.users.delete_user(userid=username) print(f" ✓ Deleted user '{username}'") except Exception as e: - logger.warning(f"Failed to delete user {username}: {e}") + logger.warning("Failed to delete user %s: %s", username, e) print("✓ Cleanup complete\n") except Exception as e: - logger.error(f"Error during user cleanup: {e}") + logger.error("Error during user cleanup: %s", e) print( "⚠️ Failed to cleanup users. Please run cleanup script manually.\n" ) diff --git a/tests/load/oauth_pool.py b/tests/load/oauth_pool.py index e4b7e38d..f36d569a 100644 --- a/tests/load/oauth_pool.py +++ b/tests/load/oauth_pool.py @@ -92,7 +92,7 @@ class OAuthUserPool: Returns: OAuth access token """ - logger.info(f"Exchanging auth code for access token (user: {username})...") + logger.info("Exchanging auth code for access token (user: %s)...", username) if not self._http_client: raise RuntimeError( @@ -118,7 +118,7 @@ class OAuthUserPool: if not access_token: raise ValueError(f"No access token in response for {username}") - logger.info(f"Successfully acquired OAuth token for {username}") + logger.info("Successfully acquired OAuth token for %s", username) return access_token async def add_user(self, username: str, password: str, token: str) -> UserProfile: @@ -134,11 +134,11 @@ class OAuthUserPool: UserProfile for the added user """ if username in self.users: - logger.warning(f"User {username} already in pool, updating token") + logger.warning("User %s already in pool, updating token", username) profile = UserProfile(username=username, password=password, token=token) self.users[username] = profile - logger.info(f"Added user {username} to pool (total: {len(self.users)})") + logger.info("Added user %s to pool (total: %s)", username, len(self.users)) return profile async def create_user_session( @@ -177,7 +177,7 @@ class OAuthUserPool: # Store both session and context for proper cleanup profile.session = session profile.streamable_context = streamable_context - logger.info(f"Created MCP session for {username}") + logger.info("Created MCP session for %s", username) return session except Exception as e: @@ -185,7 +185,7 @@ class OAuthUserPool: try: await streamable_context.__aexit__(None, None, None) except Exception as cleanup_error: - logger.debug(f"Error during cleanup: {cleanup_error}") + logger.debug("Error during cleanup: %s", cleanup_error) raise e async def close_user_session(self, username: str): @@ -200,7 +200,7 @@ class OAuthUserPool: try: await profile.session.__aexit__(None, None, None) except Exception as e: - logger.debug(f"Error closing session for {username}: {e}") + logger.debug("Error closing session for %s: %s", username, e) profile.session = None # Close streamable context @@ -208,7 +208,7 @@ class OAuthUserPool: try: await profile.streamable_context.__aexit__(None, None, None) except Exception as e: - logger.debug(f"Error closing streamable context for {username}: {e}") + logger.debug("Error closing streamable context for %s: %s", username, e) profile.streamable_context = None async def close_all_sessions(self): @@ -270,7 +270,7 @@ class OAuthUserPool: Raises: HTTPStatusError: If user creation fails """ - logger.info(f"Creating Nextcloud user: {username}") + logger.info("Creating Nextcloud user: %s", username) await self.admin_client.users.create_user( userid=username, @@ -279,7 +279,7 @@ class OAuthUserPool: email=email or f"{username}@benchmark.local", ) - logger.info(f"Successfully created Nextcloud user: {username}") + logger.info("Successfully created Nextcloud user: %s", username) return UserConfig( username=username, @@ -296,13 +296,13 @@ class OAuthUserPool: Args: username: Username to delete """ - logger.info(f"Deleting Nextcloud user: {username}") + logger.info("Deleting Nextcloud user: %s", username) try: await self.admin_client.users.delete_user(userid=username) - logger.info(f"Successfully deleted Nextcloud user: {username}") + logger.info("Successfully deleted Nextcloud user: %s", username) except Exception as e: - logger.warning(f"Failed to delete user {username}: {e}") + logger.warning("Failed to delete user %s: %s", username, e) async def acquire_token_playwright( self, @@ -338,8 +338,8 @@ class OAuthUserPool: ValueError: If token exchange fails """ - logger.info(f"Starting Playwright OAuth flow for {username}...") - logger.debug(f"Using state: {state[:16]}...") + logger.info("Starting Playwright OAuth flow for %s...", username) + logger.debug("Using state: %s...", state[:16]) # Construct authorization URL auth_url = ( @@ -363,7 +363,7 @@ class OAuthUserPool: # Login if needed if "/login" in current_url or "/index.php/login" in current_url: - logger.info(f"Logging in as {username}...") + logger.info("Logging in as %s...", username) await page.wait_for_selector('input[name="user"]', timeout=10000) await page.fill('input[name="user"]', username) await page.fill('input[name="password"]', password) @@ -382,7 +382,7 @@ class OAuthUserPool: await authorize_button.click() await page.wait_for_load_state("networkidle", timeout=10000) except Exception as e: - logger.debug(f"No authorization needed: {e}") + logger.debug("No authorization needed: %s", e) # Wait for callback server to receive auth code logger.info("Waiting for OAuth callback...") @@ -392,20 +392,20 @@ class OAuthUserPool: if time.time() - start_time > timeout_seconds: screenshot_path = f"/tmp/oauth_timeout_{username}.png" await page.screenshot(path=screenshot_path) - logger.error(f"Screenshot saved to {screenshot_path}") + logger.error("Screenshot saved to %s", screenshot_path) raise TimeoutError( f"Timeout waiting for OAuth callback for {username}" ) await anyio.sleep(0.5) auth_code = auth_states[state] - logger.info(f"Received auth code for {username}") + logger.info("Received auth code for %s", username) finally: await context.close() # Exchange code for token - logger.info(f"Exchanging auth code for access token ({username})...") + logger.info("Exchanging auth code for access token (%s)...", username) token_response = await self._http_client.post( self.token_endpoint, data={ @@ -424,7 +424,7 @@ class OAuthUserPool: if not access_token: raise ValueError(f"No access token for {username}: {token_data}") - logger.info(f"Successfully acquired OAuth token for {username}") + logger.info("Successfully acquired OAuth token for %s", username) return access_token diff --git a/tests/load/oauth_workloads.py b/tests/load/oauth_workloads.py index 99ce330f..94856682 100644 --- a/tests/load/oauth_workloads.py +++ b/tests/load/oauth_workloads.py @@ -115,7 +115,7 @@ class Workflow(ABC): return step_result except Exception as e: duration = time.time() - start - logger.error(f"Step {step_name} failed for user {user.username}: {e}") + logger.error("Step %s failed for user %s: %s", step_name, user.username, e) step_result = WorkflowStepResult( step_name=step_name, user=user.username, @@ -236,7 +236,7 @@ class NoteShareWorkflow(Workflow): return self._finish(success=True) except Exception as e: - logger.error(f"Note share workflow failed: {e}") + logger.error("Note share workflow failed: %s", e) return self._finish(False, error=str(e)) @@ -338,7 +338,7 @@ class CollaborativeEditWorkflow(Workflow): return self._finish(success=True) except Exception as e: - logger.error(f"Collaborative edit workflow failed: {e}") + logger.error("Collaborative edit workflow failed: %s", e) return self._finish(False, error=str(e)) @@ -424,7 +424,7 @@ class FileShareAndDownloadWorkflow(Workflow): return self._finish(success=True) except Exception as e: - logger.error(f"File share workflow failed: {e}") + logger.error("File share workflow failed: %s", e) return self._finish(False, error=str(e)) diff --git a/tests/load/workloads.py b/tests/load/workloads.py index 9a775508..76152d8c 100644 --- a/tests/load/workloads.py +++ b/tests/load/workloads.py @@ -182,12 +182,12 @@ class WorkloadOperations: async def cleanup(self): """Clean up any resources created during testing.""" - logger.info(f"Cleaning up {len(self._created_notes)} test notes...") + logger.info("Cleaning up %s test notes...", len(self._created_notes)) for note_id in self._created_notes[:]: try: await self.delete_note(note_id) except Exception as e: - logger.warning(f"Failed to delete note {note_id}: {e}") + logger.warning("Failed to delete note %s: %s", note_id, e) class MixedWorkload: @@ -210,7 +210,7 @@ class MixedWorkload: async def warmup(self, count: int = 10): """Create initial notes for read/update operations.""" - logger.info(f"Warming up with {count} test notes...") + logger.info("Warming up with %s test notes...", count) for _ in range(count): result = await self.ops.create_note() if result.success and self.ops._created_notes: @@ -225,7 +225,7 @@ class MixedWorkload: etag = note_data.get("etag", "") self._warmup_note_ids.append((note_id, etag)) except Exception as e: - logger.warning(f"Failed to get etag for note {note_id}: {e}") + logger.warning("Failed to get etag for note %s: %s", note_id, e) async def run_operation(self) -> OperationResult: """Execute one random operation based on the workload distribution.""" diff --git a/tests/server/login_flow/conftest.py b/tests/server/login_flow/conftest.py index a7714a87..2906e9e9 100644 --- a/tests/server/login_flow/conftest.py +++ b/tests/server/login_flow/conftest.py @@ -81,7 +81,7 @@ async def login_flow_oauth_client_credentials(anyio_backend, oauth_callback_serv token_type="Bearer", ) - logger.info(f"Login Flow OAuth client ready: {client_info.client_id[:16]}...") + logger.info("Login Flow OAuth client ready: %s...", client_info.client_id[:16]) yield ( client_info.client_id, @@ -101,10 +101,10 @@ async def login_flow_oauth_client_credentials(anyio_backend, oauth_callback_serv registration_client_uri=client_info.registration_client_uri, ) logger.info( - f"Cleaned up Login Flow OAuth client: {client_info.client_id[:16]}..." + "Cleaned up Login Flow OAuth client: %s...", client_info.client_id[:16] ) except Exception as e: - logger.warning(f"Failed to clean up Login Flow OAuth client: {e}") + logger.warning("Failed to clean up Login Flow OAuth client: %s", e) @pytest.fixture(scope="session") @@ -137,7 +137,7 @@ async def login_flow_oauth_token( ) resource_id = resource_metadata.get("resource") except Exception as e: - logger.warning(f"Failed to fetch resource metadata from port 8004: {e}") + logger.warning("Failed to fetch resource metadata from port 8004: %s", e) resource_id = None state = secrets.token_urlsafe(32) @@ -242,9 +242,9 @@ async def _complete_login_flow_v2(browser, login_url: str) -> None: page = await context.new_page() try: - logger.info(f"Opening Login Flow v2 URL: {login_url[:80]}...") + logger.info("Opening Login Flow v2 URL: %s...", login_url[:80]) await page.goto(login_url, wait_until="networkidle", timeout=60000) - logger.info(f"Step 1 - Current URL: {page.url}") + logger.info("Step 1 - Current URL: %s", page.url) # Step 1: "Connect to your account" page - click "Log in" login_btn = page.get_by_role("button", name="Log in") @@ -256,7 +256,7 @@ async def _complete_login_flow_v2(browser, login_url: str) -> None: except Exception: logger.info("No 'Log in' button - may already be on login/grant page") - logger.info(f"Step 2 - Current URL: {page.url}") + logger.info("Step 2 - Current URL: %s", page.url) # Step 2: Login form (only if not already logged in) # If the user has an active session, they skip straight to the grant page. @@ -267,7 +267,7 @@ async def _complete_login_flow_v2(browser, login_url: str) -> None: await page.locator('input[name="password"]').fill(password) await page.get_by_role("button", name="Log in", exact=True).click() await page.wait_for_load_state("networkidle", timeout=60000) - logger.info(f"After login: {page.url}") + logger.info("After login: %s", page.url) else: logger.info("No login form - already logged in via session") @@ -278,7 +278,7 @@ async def _complete_login_flow_v2(browser, login_url: str) -> None: await grant_btn.click() logger.info("Clicked 'Grant access'") except Exception as e: - logger.warning(f"No Grant access button: {e}") + logger.warning("No Grant access button: %s", e) await page.screenshot(path="/tmp/login_flow_no_grant.png") # Step 4: Password confirmation dialog @@ -310,7 +310,7 @@ async def _complete_login_flow_v2(browser, login_url: str) -> None: except Exception: # The grant may have completed without the success page being visible await page.wait_for_load_state("networkidle", timeout=10000) - logger.info(f"Login Flow v2 done. Final URL: {page.url}") + logger.info("Login Flow v2 done. Final URL: %s", page.url) finally: await context.close() @@ -347,14 +347,14 @@ async def nc_mcp_login_flow_client( completes the Login Flow v2 browser login. """ message = params.message - logger.info(f"Elicitation received: {message[:100]}...") + logger.info("Elicitation received: %s...", message[:100]) # Extract login URL from elicitation message for line in message.split("\n"): stripped = line.strip() if stripped.startswith("http") and "/login/v2/" in stripped: login_url_holder["url"] = stripped - logger.info(f"Extracted login URL: {stripped[:80]}...") + logger.info("Extracted login URL: %s...", stripped[:80]) break if "url" in login_url_holder: @@ -381,7 +381,7 @@ async def nc_mcp_login_flow_client( ) provision_data = json.loads(provision_result.content[0].text) - logger.info(f"Provision result: {provision_data.get('status')}") + logger.info("Provision result: %s", provision_data.get("status")) # If elicitation didn't fire (client doesn't support it), # extract URL from the response and complete flow manually @@ -398,11 +398,12 @@ async def nc_mcp_login_flow_client( status_result = await session.call_tool("nc_auth_check_status", {}) status_data = json.loads(status_result.content[0].text) status = status_data.get("status") - logger.info(f"Status check {attempt + 1}/{max_attempts}: {status}") + logger.info("Status check %s/%s: %s", attempt + 1, max_attempts, status) if status == "provisioned": logger.info( - f"Login Flow v2 provisioned! Username: {status_data.get('username')}" + "Login Flow v2 provisioned! Username: %s", + status_data.get("username"), ) break @@ -698,8 +699,10 @@ async def all_login_flow_user_tokens( elapsed = time.time() - start_time logger.info( - f"Fetched {len(results)} login-flow tokens in {elapsed:.1f}s " - f"(~{elapsed / len(results):.1f}s per user)" + "Fetched %s login-flow tokens in %ss (~%ss per user)", + len(results), + format(elapsed, ".1f"), + format(elapsed / len(results), ".1f"), ) return results # type: ignore[return-value] @@ -756,8 +759,9 @@ async def _provision_login_flow_mcp_client( status_data = json.loads(status_result.content[0].text) if status_data.get("status") == "provisioned": logger.info( - f"Login Flow v2 provisioned for {username}: " - f"{status_data.get('username')}" + "Login Flow v2 provisioned for %s: %s", + username, + status_data.get("username"), ) break if status_data.get("status") in ("not_initiated", "error"): @@ -795,44 +799,44 @@ async def _complete_login_flow_v2_as_user( page = await context.new_page() try: - logger.info(f"[{username}] Opening Login Flow v2 URL: {login_url[:80]}...") + logger.info("[%s] Opening Login Flow v2 URL: %s...", username, login_url[:80]) await page.goto(login_url, wait_until="networkidle", timeout=60000) - logger.info(f"[{username}] Step 1 - Current URL: {page.url}") + logger.info("[%s] Step 1 - Current URL: %s", username, page.url) # Step 1: "Connect to your account" page - click "Log in" login_btn = page.get_by_role("button", name="Log in") try: await login_btn.wait_for(timeout=10000) await login_btn.click() - logger.info(f"[{username}] Clicked 'Log in' on Connect page") + logger.info("[%s] Clicked 'Log in' on Connect page", username) await page.wait_for_load_state("networkidle", timeout=30000) except Exception: logger.info( - f"[{username}] No 'Log in' button - may already be on login/grant page" + "[%s] No 'Log in' button - may already be on login/grant page", username ) - logger.info(f"[{username}] Step 2 - Current URL: {page.url}") + logger.info("[%s] Step 2 - Current URL: %s", username, page.url) # Step 2: Login form (only if not already logged in) user_field = page.locator('input[name="user"]') if await user_field.count() > 0: - logger.info(f"[{username}] Login form detected, filling credentials...") + logger.info("[%s] Login form detected, filling credentials...", username) await user_field.fill(username) await page.locator('input[name="password"]').fill(password) await page.get_by_role("button", name="Log in", exact=True).click() await page.wait_for_load_state("networkidle", timeout=60000) - logger.info(f"[{username}] After login: {page.url}") + logger.info("[%s] After login: %s", username, page.url) else: - logger.info(f"[{username}] No login form - already logged in via session") + logger.info("[%s] No login form - already logged in via session", username) # Step 3: "Account access" grant page - click "Grant access" grant_btn = page.get_by_role("button", name="Grant access") try: await grant_btn.wait_for(timeout=15000) await grant_btn.click() - logger.info(f"[{username}] Clicked 'Grant access'") + logger.info("[%s] Clicked 'Grant access'", username) except Exception as e: - logger.warning(f"[{username}] No Grant access button: {e}") + logger.warning("[%s] No Grant access button: %s", username, e) await page.screenshot(path=f"/tmp/login_flow_no_grant_{username}.png") # Step 4: Password confirmation dialog @@ -841,27 +845,27 @@ async def _complete_login_flow_v2_as_user( ) try: await confirm_password.wait_for(timeout=10000) - logger.info(f"[{username}] Password confirmation dialog detected") + logger.info("[%s] Password confirmation dialog detected", username) await confirm_password.fill(password) confirm_btn = page.get_by_role("dialog").get_by_role( "button", name="Confirm" ) await confirm_btn.wait_for(timeout=5000) await confirm_btn.click() - logger.info(f"[{username}] Clicked 'Confirm' in password dialog") + logger.info("[%s] Clicked 'Confirm' in password dialog", username) except Exception: logger.info( - f"[{username}] No password confirmation dialog " - "(may have been auto-confirmed)" + "[%s] No password confirmation dialog (may have been auto-confirmed)", + username, ) # Step 5: Wait for "Account connected" success page try: await page.get_by_text("Account connected").wait_for(timeout=15000) - logger.info(f"[{username}] Login Flow v2 completed: Account connected!") + logger.info("[%s] Login Flow v2 completed: Account connected!", username) except Exception: await page.wait_for_load_state("networkidle", timeout=10000) - logger.info(f"[{username}] Login Flow v2 done. Final URL: {page.url}") + logger.info("[%s] Login Flow v2 done. Final URL: %s", username, page.url) finally: await context.close() @@ -986,7 +990,9 @@ async def login_flow_static_client_credentials(anyio_backend, oauth_callback_ser capture_output=True, ) - logger.info(f"Creating static OIDC client {client_id} with callback {callback_url}") + logger.info( + "Creating static OIDC client %s with callback %s", client_id, callback_url + ) result = subprocess.run( [ "docker", diff --git a/tests/server/login_flow/test_dcr_deletion_methods.py b/tests/server/login_flow/test_dcr_deletion_methods.py index 0f305f0d..99ac6eec 100644 --- a/tests/server/login_flow/test_dcr_deletion_methods.py +++ b/tests/server/login_flow/test_dcr_deletion_methods.py @@ -61,9 +61,9 @@ async def test_dcr_deletion_authentication_methods( ) deletion_endpoint = f"{nextcloud_host}/apps/oidc/register/{client_info.client_id}" - logger.info(f"\nTesting deletion endpoint: {deletion_endpoint}") - logger.info(f"Client ID: {client_info.client_id}") - logger.info(f"Client Secret (first 16 chars): {client_info.client_secret[:16]}...") + logger.info("\\nTesting deletion endpoint: %s", deletion_endpoint) + logger.info("Client ID: %s", client_info.client_id) + logger.info("Client Secret (first 16 chars): %s...", client_info.client_secret[:16]) results = {} @@ -79,11 +79,11 @@ async def test_dcr_deletion_authentication_methods( "status": response.status_code, "body": response.text[:200], } - logger.info(f"Status: {response.status_code}") - logger.info(f"Body: {response.text[:200]}") + logger.info("Status: %s", response.status_code) + logger.info("Body: %s", response.text[:200]) except Exception as e: results["basic_auth"] = {"status": "error", "error": str(e)} - logger.error(f"Error: {e}") + logger.error("Error: %s", e) # Method 2: Credentials in JSON body logger.info("\n=== Method 2: Credentials in JSON Body ===") @@ -99,11 +99,11 @@ async def test_dcr_deletion_authentication_methods( "status": response.status_code, "body": response.text[:200], } - logger.info(f"Status: {response.status_code}") - logger.info(f"Body: {response.text[:200]}") + logger.info("Status: %s", response.status_code) + logger.info("Body: %s", response.text[:200]) except Exception as e: results["json_body"] = {"status": "error", "error": str(e)} - logger.error(f"Error: {e}") + logger.error("Error: %s", e) # Method 3: Credentials in query parameters logger.info("\n=== Method 3: Credentials in Query Parameters ===") @@ -119,11 +119,11 @@ async def test_dcr_deletion_authentication_methods( "status": response.status_code, "body": response.text[:200], } - logger.info(f"Status: {response.status_code}") - logger.info(f"Body: {response.text[:200]}") + logger.info("Status: %s", response.status_code) + logger.info("Body: %s", response.text[:200]) except Exception as e: results["query_params"] = {"status": "error", "error": str(e)} - logger.error(f"Error: {e}") + logger.error("Error: %s", e) # Method 4: No authentication (baseline) logger.info("\n=== Method 4: No Authentication (Baseline) ===") @@ -133,11 +133,11 @@ async def test_dcr_deletion_authentication_methods( "status": response.status_code, "body": response.text[:200], } - logger.info(f"Status: {response.status_code}") - logger.info(f"Body: {response.text[:200]}") + logger.info("Status: %s", response.status_code) + logger.info("Body: %s", response.text[:200]) except Exception as e: results["no_auth"] = {"status": "error", "error": str(e)} - logger.error(f"Error: {e}") + logger.error("Error: %s", e) # Print summary logger.info("\n" + "=" * 70) @@ -146,7 +146,7 @@ async def test_dcr_deletion_authentication_methods( for method, result in results.items(): status = result.get("status", "unknown") - logger.info(f"{method:20s} → Status: {status}") + logger.info("%s → Status: %s", format(method, "20s"), status) # Analysis logger.info("\n" + "=" * 70) @@ -170,11 +170,11 @@ async def test_dcr_deletion_authentication_methods( logger.info("✓ At least one authentication method succeeded (204 No Content)") for method, result in results.items(): if result.get("status") == 204: - logger.info(f" Working method: {method}") + logger.info(" Working method: %s", method) else: logger.info("? Mixed results - further investigation needed") for method, result in results.items(): - logger.info(f" {method}: {result.get('status')}") + logger.info(" %s: %s", method, result.get("status")) # Document the finding assert all_401 or any_204, ( diff --git a/tests/server/login_flow/test_dcr_lifecycle.py b/tests/server/login_flow/test_dcr_lifecycle.py index b7d71fd6..301816d8 100644 --- a/tests/server/login_flow/test_dcr_lifecycle.py +++ b/tests/server/login_flow/test_dcr_lifecycle.py @@ -101,7 +101,7 @@ async def get_oauth_token_with_client( try: await _handle_oauth_consent_screen(page, username) except Exception as e: - logger.debug(f"No consent screen or already authorized: {e}") + logger.debug("No consent screen or already authorized: %s", e) # Wait for callback logger.info("Waiting for OAuth callback...") @@ -115,7 +115,7 @@ async def get_oauth_token_with_client( await anyio.sleep(0.5) auth_code = auth_states[state] - logger.info(f"Got auth code: {auth_code[:20]}...") + logger.info("Got auth code: %s...", auth_code[:20]) finally: await context.close() @@ -200,8 +200,8 @@ async def test_dcr_register_and_delete_lifecycle( reg_response.raise_for_status() full_client_info = reg_response.json() - logger.info(f"Full registration response keys: {list(full_client_info.keys())}") - logger.info(f"Registration response: {full_client_info}") + logger.info("Full registration response keys: %s", list(full_client_info.keys())) + logger.info("Registration response: %s", full_client_info) # Use the register_client function for the ClientInfo object client_info = await register_client( @@ -217,13 +217,13 @@ async def test_dcr_register_and_delete_lifecycle( registration_access_token = full_client_info.get("registration_access_token") registration_client_uri = full_client_info.get("registration_client_uri") logger.info( - f"Registration access token present: {registration_access_token is not None}" + "Registration access token present: %s", registration_access_token is not None ) logger.info( - f"Registration client URI present: {registration_client_uri is not None}" + "Registration client URI present: %s", registration_client_uri is not None ) - logger.info(f"✅ Client registered: {client_info.client_id[:16]}...") + logger.info("✅ Client registered: %s...", client_info.client_id[:16]) # Step 2: Obtain token and verify client works logger.info("Step 2: Obtaining OAuth token with registered client...") @@ -239,14 +239,15 @@ async def test_dcr_register_and_delete_lifecycle( ) assert access_token, "Failed to obtain access token" - logger.info(f"✅ Access token obtained: {access_token[:30]}...") + logger.info("✅ Access token obtained: %s...", access_token[:30]) # Step 3: Delete the client using RFC 7592 logger.info("Step 3: Deleting OAuth client...") - logger.info(f"Client ID: {client_info.client_id}") - logger.info(f"Client secret (first 16 chars): {client_info.client_secret[:16]}...") + logger.info("Client ID: %s", client_info.client_id) + logger.info("Client secret (first 16 chars): %s...", client_info.client_secret[:16]) logger.info( - f"Registration access token: {registration_access_token[:16] if registration_access_token else 'None'}..." + "Registration access token: %s...", + registration_access_token[:16] if registration_access_token else "None", ) # Use delete_client() which prefers RFC 7592 Bearer token, falls back to Basic Auth @@ -261,7 +262,7 @@ async def test_dcr_register_and_delete_lifecycle( assert success, ( "Client deletion should succeed with RFC 7592 Bearer token or Basic Auth" ) - logger.info(f"✅ Client deleted successfully: {client_info.client_id[:16]}...") + logger.info("✅ Client deleted successfully: %s...", client_info.client_id[:16]) # Step 4: Verify deleted client cannot obtain new tokens logger.info("Step 4: Verifying deleted client cannot obtain new tokens...") @@ -284,7 +285,8 @@ async def test_dcr_register_and_delete_lifecycle( # Accept either 400 (Bad Request) or 401 (Unauthorized) as valid rejection if token_response.status_code in [400, 401]: logger.info( - f"✅ Deleted client correctly rejected ({token_response.status_code})" + "✅ Deleted client correctly rejected (%s)", + token_response.status_code, ) else: # Unexpected success - client should be deleted @@ -343,7 +345,7 @@ async def test_dcr_delete_with_wrong_credentials( token_type="Bearer", ) - logger.info(f"Client registered: {client_info.client_id[:16]}...") + logger.info("Client registered: %s...", client_info.client_id[:16]) # Try to delete with wrong registration_access_token (RFC 7592 Bearer token) logger.info("Attempting deletion with wrong registration_access_token...") @@ -392,7 +394,7 @@ async def test_dcr_delete_nonexistent_client( fake_client_id = "nonexistent_" + secrets.token_urlsafe(16) fake_client_secret = secrets.token_urlsafe(32) - logger.info(f"Attempting to delete non-existent client: {fake_client_id[:16]}...") + logger.info("Attempting to delete non-existent client: %s...", fake_client_id[:16]) success = await delete_client( nextcloud_url=nextcloud_host, @@ -442,7 +444,7 @@ async def test_dcr_deletion_is_idempotent( token_type="Bearer", ) - logger.info(f"Client registered: {client_info.client_id[:16]}...") + logger.info("Client registered: %s...", client_info.client_id[:16]) # First deletion with RFC 7592 Bearer token logger.info("First deletion attempt...") diff --git a/tests/server/login_flow/test_dcr_new_implementation.py b/tests/server/login_flow/test_dcr_new_implementation.py index 05c57731..50edf172 100644 --- a/tests/server/login_flow/test_dcr_new_implementation.py +++ b/tests/server/login_flow/test_dcr_new_implementation.py @@ -63,28 +63,28 @@ async def test_new_dcr_registration_includes_access_token( registration_data = response.json() # Log the full response - logger.info(f"\n{'=' * 70}") + logger.info("\\n%s", "=" * 70) logger.info("REGISTRATION RESPONSE") - logger.info(f"{'=' * 70}") - logger.info(f"Response keys: {sorted(registration_data.keys())}") + logger.info("%s", "=" * 70) + logger.info("Response keys: %s", sorted(registration_data.keys())) logger.info("\nFull response:") for key, value in sorted(registration_data.items()): if key in ["client_secret", "registration_access_token"]: # Truncate secrets for security - logger.info(f" {key}: {value[:20]}... (truncated)") + logger.info(" %s: %s... (truncated)", key, value[:20]) else: - logger.info(f" {key}: {value}") + logger.info(" %s: %s", key, value) # Check for RFC 7592 required fields - logger.info(f"\n{'=' * 70}") + logger.info("\\n%s", "=" * 70) logger.info("RFC 7592 COMPLIANCE CHECK") - logger.info(f"{'=' * 70}") + logger.info("%s", "=" * 70) has_token = "registration_access_token" in registration_data has_uri = "registration_client_uri" in registration_data - logger.info(f"registration_access_token present: {has_token}") - logger.info(f"registration_client_uri present: {has_uri}") + logger.info("registration_access_token present: %s", has_token) + logger.info("registration_client_uri present: %s", has_uri) if has_token and has_uri: logger.info( @@ -100,15 +100,15 @@ async def test_new_dcr_registration_includes_access_token( registration_client_uri = registration_data.get("registration_client_uri") # Now test deletion with the registration_access_token - logger.info(f"\n{'=' * 70}") + logger.info("\\n%s", "=" * 70) logger.info("TESTING DCR DELETION WITH REGISTRATION_ACCESS_TOKEN") - logger.info(f"{'=' * 70}") + logger.info("%s", "=" * 70) deletion_endpoint = ( registration_client_uri or f"{nextcloud_host}/apps/oidc/register/{client_id}" ) - logger.info(f"Deletion endpoint: {deletion_endpoint}") + logger.info("Deletion endpoint: %s", deletion_endpoint) async with httpx.AsyncClient(timeout=30.0) as client: # Try deletion with Bearer token (RFC 7592 standard) @@ -118,8 +118,8 @@ async def test_new_dcr_registration_includes_access_token( headers={"Authorization": f"Bearer {registration_access_token}"}, ) - logger.info(f"Response status: {delete_response.status_code}") - logger.info(f"Response body: {delete_response.text[:200]}") + logger.info("Response status: %s", delete_response.status_code) + logger.info("Response body: %s", delete_response.text[:200]) if delete_response.status_code == 204: logger.info( @@ -139,7 +139,7 @@ async def test_new_dcr_registration_includes_access_token( ) else: logger.warning( - f"\n? UNEXPECTED: Got status {delete_response.status_code}" + "\\n? UNEXPECTED: Got status %s", delete_response.status_code ) pytest.fail( f"Unexpected status code: {delete_response.status_code}, body: {delete_response.text[:500]}" @@ -204,10 +204,10 @@ async def test_dcr_deletion_with_basic_auth_new_impl( client_secret = reg_data["client_secret"] deletion_endpoint = f"{nextcloud_host}/apps/oidc/register/{client_id}" - logger.info(f"\n{'=' * 70}") + logger.info("\\n%s", "=" * 70) logger.info("TESTING DCR DELETION WITH HTTP BASIC AUTH") - logger.info(f"{'=' * 70}") - logger.info(f"Endpoint: {deletion_endpoint}") + logger.info("%s", "=" * 70) + logger.info("Endpoint: %s", deletion_endpoint) async with httpx.AsyncClient(timeout=30.0) as client: response = await client.delete( @@ -215,8 +215,8 @@ async def test_dcr_deletion_with_basic_auth_new_impl( auth=(client_id, client_secret), ) - logger.info(f"Status: {response.status_code}") - logger.info(f"Body: {response.text[:200]}") + logger.info("Status: %s", response.status_code) + logger.info("Body: %s", response.text[:200]) if response.status_code == 204: logger.info("\n✓ SUCCESS: HTTP Basic Auth works for deletion!") @@ -225,7 +225,7 @@ async def test_dcr_deletion_with_basic_auth_new_impl( "\n✗ HTTP Basic Auth not supported - use registration_access_token instead" ) else: - logger.warning(f"\n? Unexpected status: {response.status_code}") + logger.warning("\\n? Unexpected status: %s", response.status_code) # This test is informational - we don't fail if Basic Auth doesn't work # as long as Bearer token works diff --git a/tests/server/login_flow/test_dcr_token_type.py b/tests/server/login_flow/test_dcr_token_type.py index 79059e62..e9faefce 100644 --- a/tests/server/login_flow/test_dcr_token_type.py +++ b/tests/server/login_flow/test_dcr_token_type.py @@ -157,7 +157,7 @@ async def get_oauth_token_with_client( try: await _handle_oauth_consent_screen(page, username) except Exception as e: - logger.debug(f"No consent screen or already authorized: {e}") + logger.debug("No consent screen or already authorized: %s", e) # Wait for callback logger.info("Waiting for OAuth callback...") @@ -171,7 +171,7 @@ async def get_oauth_token_with_client( await anyio.sleep(0.5) auth_code = auth_states[state] - logger.info(f"Got auth code: {auth_code[:20]}...") + logger.info("Got auth code: %s...", auth_code[:20]) finally: await context.close() @@ -245,7 +245,7 @@ async def test_dcr_respects_jwt_token_type( token_type="jwt", ) - logger.info(f"Registered JWT client: {client_info.client_id[:16]}...") + logger.info("Registered JWT client: %s...", client_info.client_id[:16]) # Obtain token via OAuth flow access_token = await get_oauth_token_with_client( @@ -280,8 +280,8 @@ async def test_dcr_respects_jwt_token_type( assert "notes.write" in scopes, "JWT scope claim missing notes.write" logger.info( - f"✅ DCR with token_type=jwt works correctly! " - f"Token is JWT format with scope claim: {payload['scope']}" + "✅ DCR with token_type=jwt works correctly! Token is JWT format with scope claim: %s", + payload["scope"], ) @@ -329,7 +329,7 @@ async def test_dcr_respects_bearer_token_type( token_type="opaque", ) - logger.info(f"Registered Opaque token client: {client_info.client_id[:16]}...") + logger.info("Registered Opaque token client: %s...", client_info.client_id[:16]) # Obtain token via OAuth flow access_token = await get_oauth_token_with_client( @@ -357,8 +357,8 @@ async def test_dcr_respects_bearer_token_type( pass logger.info( - f"✅ DCR with token_type=opaque works correctly! " - f"Token is opaque (not JWT format): {access_token[:30]}..." + "✅ DCR with token_type=opaque works correctly! Token is opaque (not JWT format): %s...", + access_token[:30], ) @@ -390,8 +390,8 @@ async def test_jwt_tokens_embed_scopes_in_payload(): # but we document the behavior explicitly here for reference logger.info( - "✅ JWT token scope embedding verified. " - f"Expected scopes in JWT payload: {DEFAULT_FULL_SCOPES}" + "✅ JWT token scope embedding verified. Expected scopes in JWT payload: %s", + DEFAULT_FULL_SCOPES, ) # This test primarily serves as documentation diff --git a/tests/server/login_flow/test_introspection_authorization.py b/tests/server/login_flow/test_introspection_authorization.py index 43a6bfe6..1f197e2f 100644 --- a/tests/server/login_flow/test_introspection_authorization.py +++ b/tests/server/login_flow/test_introspection_authorization.py @@ -82,7 +82,7 @@ async def test_oauth_clients( token_type="Bearer", # Use opaque tokens for this test ) clients["clientA"] = (client_a.client_id, client_a.client_secret) - logger.info(f"Created client A: {client_a.client_id[:16]}...") + logger.info("Created client A: %s...", client_a.client_id[:16]) # Create client B (will attempt to introspect client A's tokens) logger.info("Creating OAuth client B for introspection testing") @@ -95,7 +95,7 @@ async def test_oauth_clients( token_type="Bearer", ) clients["clientB"] = (client_b.client_id, client_b.client_secret) - logger.info(f"Created client B: {client_b.client_id[:16]}...") + logger.info("Created client B: %s...", client_b.client_id[:16]) # Create client C (third party, should not be able to introspect) logger.info("Creating OAuth client C for introspection testing") @@ -108,7 +108,7 @@ async def test_oauth_clients( token_type="Bearer", ) clients["clientC"] = (client_c.client_id, client_c.client_secret) - logger.info(f"Created client C: {client_c.client_id[:16]}...") + logger.info("Created client C: %s...", client_c.client_id[:16]) yield clients @@ -146,7 +146,7 @@ async def test_introspection_requires_client_authentication( ) assert response.status_code == 401, "Should return 401 with invalid credentials" data = response.json() - logger.info(f"Invalid client response: {data}") + logger.info("Invalid client response: %s", data) # Response may be either {"error": "invalid_client"} or {"message": "..."} # Both are acceptable as long as we get 401 assert "error" in data or "message" in data, "Should return error information" @@ -191,19 +191,21 @@ async def _obtain_token_for_client( auth_url = "".join(auth_url_parts) - logger.info(f"Obtaining token for client {client_id[:16]}... with scopes={scope}") + logger.info( + "Obtaining token for client %s... with scopes=%s", client_id[:16], scope + ) if resource: - logger.info(f" Resource parameter: {resource[:16]}...") + logger.info(" Resource parameter: %s...", resource[:16]) # Browser automation (same pattern as conftest.py) context = await browser.new_context(ignore_https_errors=True) page = await context.new_page() try: - logger.debug(f"Navigating to: {auth_url[:100]}...") + logger.debug("Navigating to: %s...", auth_url[:100]) await page.goto(auth_url, wait_until="networkidle", timeout=60000) current_url = page.url - logger.debug(f"Current URL after navigation: {current_url}") + logger.debug("Current URL after navigation: %s", current_url) # Handle login if needed if "/login" in current_url or "/index.php/login" in current_url: @@ -214,24 +216,24 @@ async def _obtain_token_for_client( await page.click('button[type="submit"]') await page.wait_for_load_state("networkidle", timeout=60000) current_url = page.url - logger.info(f"After login: {current_url}") + logger.info("After login: %s", current_url) # Wait a bit for page to fully render after login await anyio.sleep(2) current_url = page.url - logger.info(f"After waiting, current URL: {current_url}") + logger.info("After waiting, current URL: %s", current_url) # Check page content for debugging page_content = await page.content() has_consent_div = "#oidc-consent" in page_content - logger.info(f"Page has #oidc-consent div: {has_consent_div}") + logger.info("Page has #oidc-consent div: %s", has_consent_div) # Handle consent screen using the helper from conftest try: consent_handled = await _handle_oauth_consent_screen(page, username) - logger.info(f"Consent screen handled: {consent_handled}") + logger.info("Consent screen handled: %s", consent_handled) except Exception as e: - logger.warning(f"Error handling consent screen: {e}") + logger.warning("Error handling consent screen: %s", e) # Take screenshot for debugging await page.screenshot(path=f"/tmp/consent_error_{state[:8]}.png") logger.error("Consent error screenshot saved") @@ -247,15 +249,15 @@ async def _obtain_token_for_client( f"/tmp/oauth_introspection_test_timeout_{state[:8]}.png" ) await page.screenshot(path=screenshot_path) - logger.error(f"Timeout! Screenshot saved to {screenshot_path}") - logger.error(f"Current URL: {page.url}") + logger.error("Timeout! Screenshot saved to %s", screenshot_path) + logger.error("Current URL: %s", page.url) raise TimeoutError( f"Timeout waiting for OAuth callback (state={state[:16]}...)" ) await anyio.sleep(0.5) auth_code = auth_states[state] - logger.info(f"Successfully received auth code: {auth_code[:20]}...") + logger.info("Successfully received auth code: %s...", auth_code[:20]) finally: await context.close() @@ -311,10 +313,10 @@ async def test_client_cannot_introspect_other_clients_tokens( different_client_id, different_client_secret = test_oauth_clients["clientB"] logger.info( - f"Testing introspection with shared client token: {access_token[:16]}..." + "Testing introspection with shared client token: %s...", access_token[:16] ) - logger.info(f"Shared client ID: {shared_client_id[:16]}...") - logger.info(f"Different client ID: {different_client_id[:16]}...") + logger.info("Shared client ID: %s...", shared_client_id[:16]) + logger.info("Different client ID: %s...", different_client_id[:16]) async with httpx.AsyncClient(timeout=10.0) as client: # Test 1: The owning client (shared client) can introspect its own token @@ -325,7 +327,7 @@ async def test_client_cannot_introspect_other_clients_tokens( ) assert response.status_code == 200 data = response.json() - logger.info(f"Owner client introspection response: {data}") + logger.info("Owner client introspection response: %s", data) assert data.get("active") is True, ( "Owner client should be able to introspect its own token" ) @@ -338,7 +340,7 @@ async def test_client_cannot_introspect_other_clients_tokens( ) assert response.status_code == 200 data = response.json() - logger.info(f"Different client introspection response: {data}") + logger.info("Different client introspection response: %s", data) assert data.get("active") is False, ( "Different client should NOT be able to introspect another client's token" ) @@ -387,11 +389,13 @@ async def test_introspection_with_resource_parameter( resource=client_b_id, # Set client B as the resource server ) except Exception as e: - logger.error(f"Failed to obtain token with resource parameter: {e}") + logger.error("Failed to obtain token with resource parameter: %s", e) pytest.skip(f"Cannot obtain test token with resource parameter: {e}") logger.info( - f"Obtained access token from client A with resource={client_b_id}: {access_token[:16]}..." + "Obtained access token from client A with resource=%s: %s...", + client_b_id, + access_token[:16], ) # Test introspection @@ -404,7 +408,7 @@ async def test_introspection_with_resource_parameter( ) assert response.status_code == 200 data = response.json() - logger.info(f"Client A (owner) introspection response: {data}") + logger.info("Client A (owner) introspection response: %s", data) assert data.get("active") is True, ( "Client A (owner) should be able to introspect its own token" ) @@ -417,13 +421,13 @@ async def test_introspection_with_resource_parameter( ) assert response.status_code == 200 data = response.json() - logger.info(f"Client B (resource server) introspection response: {data}") + logger.info("Client B (resource server) introspection response: %s", data) assert data.get("active") is True, ( "Client B (resource server) should be able to introspect token intended for it" ) # Verify the resource field in the response matches client B - logger.info(f"Full introspection response from Client B: {data}") + logger.info("Full introspection response from Client B: %s", data) # Test 3: Client C CANNOT introspect the token (not owner, not resource server) response = await client.post( @@ -433,7 +437,7 @@ async def test_introspection_with_resource_parameter( ) assert response.status_code == 200 data = response.json() - logger.info(f"Client C (third party) introspection response: {data}") + logger.info("Client C (third party) introspection response: %s", data) assert data.get("active") is False, ( "Client C should NOT be able to introspect token (not owner or resource server)" ) @@ -464,7 +468,7 @@ async def test_introspection_returns_inactive_for_invalid_token( assert response.status_code == 200 data = response.json() - logger.info(f"Introspection response for fake token: {data}") + logger.info("Introspection response for fake token: %s", data) assert data.get("active") is False, ( "Should return active=false for invalid token" ) diff --git a/tests/server/login_flow/test_login_flow_integration.py b/tests/server/login_flow/test_login_flow_integration.py index 262be92d..73cea9b3 100644 --- a/tests/server/login_flow/test_login_flow_integration.py +++ b/tests/server/login_flow/test_login_flow_integration.py @@ -50,7 +50,7 @@ class TestLoginFlowAuthTools: # Search" flow) stores. So accept either a non-empty list or None; # the field's *presence* in the payload is what we care about here. assert data["scopes"] is None or len(data["scopes"]) > 0 - logger.info(f"Provisioned as: {data['username']}, scopes: {data['scopes']}") + logger.info("Provisioned as: %s, scopes: %s", data["username"], data["scopes"]) async def test_provision_access_already_provisioned( self, nc_mcp_login_flow_client: ClientSession @@ -100,7 +100,7 @@ class TestLoginFlowNotes: note = json.loads(create_result.content[0].text) note_id = note["id"] etag = note["etag"] - logger.info(f"Created note {note_id}") + logger.info("Created note %s", note_id) try: # Read @@ -152,7 +152,7 @@ class TestLoginFlowNotes: await nc_mcp_login_flow_client.call_tool( "nc_notes_delete_note", {"note_id": note_id} ) - logger.info(f"Deleted note {note_id}") + logger.info("Deleted note %s", note_id) # --------------------------------------------------------------------------- @@ -176,7 +176,7 @@ class TestLoginFlowCalendarEvents: calendars = cal_data.get("calendars", []) assert len(calendars) > 0 calendar_name = calendars[0].get("name", "personal") - logger.info(f"Using calendar: {calendar_name}") + logger.info("Using calendar: %s", calendar_name) suffix = uuid.uuid4().hex[:8] event_title = f"LoginFlow Event {suffix}" @@ -197,7 +197,7 @@ class TestLoginFlowCalendarEvents: ) event_data = json.loads(create_result.content[0].text) event_uid = event_data.get("uid") or event_data.get("event_uid") - logger.info(f"Created event: {event_uid}") + logger.info("Created event: %s", event_uid) try: # Get event @@ -213,7 +213,7 @@ class TestLoginFlowCalendarEvents: "nc_calendar_delete_event", {"calendar_name": calendar_name, "event_uid": event_uid}, ) - logger.info(f"Deleted event {event_uid}") + logger.info("Deleted event %s", event_uid) # --------------------------------------------------------------------------- @@ -254,7 +254,7 @@ class TestLoginFlowCalendarTodos: raise AssertionError(f"Create todo failed: {error_text}") todo_data = json.loads(create_result.content[0].text) todo_uid = todo_data.get("uid") or todo_data.get("todo_uid") - logger.info(f"Created todo: {todo_uid}") + logger.info("Created todo: %s", todo_uid) try: # List todos @@ -280,7 +280,7 @@ class TestLoginFlowCalendarTodos: "nc_calendar_delete_todo", {"calendar_name": calendar_name, "todo_uid": todo_uid}, ) - logger.info(f"Deleted todo {todo_uid}") + logger.info("Deleted todo %s", todo_uid) # --------------------------------------------------------------------------- @@ -312,7 +312,7 @@ class TestLoginFlowContacts: assert create_ab_result.isError is False, ( f"Create addressbook failed: {create_ab_result.content[0].text}" ) - logger.info(f"Created address book: {ab_name}") + logger.info("Created address book: %s", ab_name) try: # Create contact (requires addressbook, uid, contact_data dict) @@ -330,7 +330,7 @@ class TestLoginFlowContacts: assert create_result.isError is False, ( f"Create contact failed: {create_result.content[0].text}" ) - logger.info(f"Created contact: {contact_uid}") + logger.info("Created contact: %s", contact_uid) # List contacts in our clean addressbook # Note: may fail due to server-side Pydantic bug where ContactField.value @@ -343,7 +343,7 @@ class TestLoginFlowContacts: error_text = list_result.content[0].text if "ContactField" in error_text: logger.warning( - f"Known server bug: ContactField validation: {error_text}" + "Known server bug: ContactField validation: %s", error_text ) else: raise AssertionError(f"List contacts failed: {error_text}") @@ -360,7 +360,7 @@ class TestLoginFlowContacts: "nc_contacts_delete_contact", {"addressbook": ab_name, "uid": contact_uid}, ) - logger.info(f"Deleted contact {contact_uid}") + logger.info("Deleted contact %s", contact_uid) finally: # Always clean up the temporary address book @@ -368,7 +368,7 @@ class TestLoginFlowContacts: "nc_contacts_delete_addressbook", {"name": ab_name}, ) - logger.info(f"Deleted address book {ab_name}") + logger.info("Deleted address book %s", ab_name) # --------------------------------------------------------------------------- @@ -393,7 +393,7 @@ class TestLoginFlowFiles: assert mkdir_result.isError is False, ( f"Create dir failed: {mkdir_result.content[0].text}" ) - logger.info(f"Created directory: {dir_path}") + logger.info("Created directory: %s", dir_path) try: # Write file @@ -436,7 +436,7 @@ class TestLoginFlowFiles: await nc_mcp_login_flow_client.call_tool( "nc_webdav_delete_resource", {"path": dir_path} ) - logger.info(f"Cleaned up {dir_path}") + logger.info("Cleaned up %s", dir_path) # --------------------------------------------------------------------------- @@ -467,7 +467,7 @@ class TestLoginFlowDeck: ) board_data = json.loads(create_result.content[0].text) board_id = board_data.get("id") or board_data.get("board_id") - logger.info(f"Created board: {board_id}") + logger.info("Created board: %s", board_id) # List boards (tool name is deck_get_boards) list_result = await nc_mcp_login_flow_client.call_tool( @@ -499,9 +499,11 @@ class TestLoginFlowDeck: resp = await client.delete( f"/apps/deck/api/v1.0/boards/{board_id}" ) - logger.info(f"Board cleanup: {board_id} → {resp.status_code}") + logger.info( + "Board cleanup: %s → %s", board_id, resp.status_code + ) except Exception as e: - logger.warning(f"Board cleanup failed: {e}") + logger.warning("Board cleanup failed: %s", e) # --------------------------------------------------------------------------- @@ -521,7 +523,7 @@ class TestLoginFlowTables: result = await nc_mcp_login_flow_client.call_tool("nc_tables_list_tables", {}) assert result.isError is False, f"List tables failed: {result.content[0].text}" data = json.loads(result.content[0].text) - logger.info(f"Tables: {data}") + logger.info("Tables: %s", data) # --------------------------------------------------------------------------- @@ -569,7 +571,7 @@ class TestLoginFlowCookbook: ) recipe_data = json.loads(create_result.content[0].text) recipe_id = recipe_data.get("id") or recipe_data.get("recipe_id") - logger.info(f"Created recipe: {recipe_id}") + logger.info("Created recipe: %s", recipe_id) try: # Get recipe (may fail due to server-side Pydantic bug with recipeYield=None) @@ -580,7 +582,8 @@ class TestLoginFlowCookbook: error_text = get_result.content[0].text if "recipeYield" in error_text: logger.warning( - f"Known server bug: Recipe.recipeYield validation: {error_text}" + "Known server bug: Recipe.recipeYield validation: %s", + error_text, ) else: raise AssertionError(f"Get recipe failed: {error_text}") @@ -590,7 +593,7 @@ class TestLoginFlowCookbook: await nc_mcp_login_flow_client.call_tool( "nc_cookbook_delete_recipe", {"recipe_id": recipe_id} ) - logger.info(f"Deleted recipe {recipe_id}") + logger.info("Deleted recipe %s", recipe_id) # --------------------------------------------------------------------------- @@ -655,4 +658,4 @@ class TestLoginFlowConnectivity: async def test_list_resources(self, nc_mcp_login_flow_client: ClientSession): """Verify resource templates are available.""" templates = await nc_mcp_login_flow_client.list_resource_templates() - logger.info(f"Resource templates: {len(templates.resourceTemplates)}") + logger.info("Resource templates: %s", len(templates.resourceTemplates)) diff --git a/tests/server/login_flow/test_multi_user_permissions.py b/tests/server/login_flow/test_multi_user_permissions.py index a0faee00..1c382597 100644 --- a/tests/server/login_flow/test_multi_user_permissions.py +++ b/tests/server/login_flow/test_multi_user_permissions.py @@ -537,4 +537,4 @@ class TestMultiUserSmoke: ]: tools = await client.list_tools() assert len(tools.tools) > 0, f"{name} MCP client has no tools" - logger.info(f"{name} MCP client working ({len(tools.tools)} tools)") + logger.info("%s MCP client working (%s tools)", name, len(tools.tools)) diff --git a/tests/server/login_flow/test_scope_authorization.py b/tests/server/login_flow/test_scope_authorization.py index ae68127d..426bd06b 100644 --- a/tests/server/login_flow/test_scope_authorization.py +++ b/tests/server/login_flow/test_scope_authorization.py @@ -73,7 +73,7 @@ async def test_read_only_token_filters_write_tools(nc_mcp_login_flow_client_read assert len(result.tools) > 0 tool_names = [tool.name for tool in result.tools] - logger.info(f"Read-only token sees {len(tool_names)} tools") + logger.info("Read-only token sees %s tools", len(tool_names)) # Verify read tools are present (only for apps with :read scopes) # Read-only token has: notes.read, calendar.read, contacts.read, @@ -104,8 +104,8 @@ async def test_read_only_token_filters_write_tools(nc_mcp_login_flow_client_read ) logger.info( - f"✅ Read-only token properly filters tools: {len(tool_names)} read tools visible, " - f"write tools hidden" + "✅ Read-only token properly filters tools: %s read tools visible, write tools hidden", + len(tool_names), ) @@ -122,7 +122,7 @@ async def test_write_only_token_filters_read_tools(nc_mcp_login_flow_client_writ assert len(result.tools) > 0 tool_names = [tool.name for tool in result.tools] - logger.info(f"Write-only token sees {len(tool_names)} tools") + logger.info("Write-only token sees %s tools", len(tool_names)) # Verify write tools are present # Write-only token has: notes.write, calendar.write, contacts.write, @@ -153,8 +153,8 @@ async def test_write_only_token_filters_read_tools(nc_mcp_login_flow_client_writ ) logger.info( - f"✅ Write-only token properly filters tools: {len(tool_names)} write tools visible, " - f"read tools hidden" + "✅ Write-only token properly filters tools: %s write tools visible, read tools hidden", + len(tool_names), ) @@ -171,8 +171,8 @@ async def test_full_access_token_shows_all_tools(nc_mcp_login_flow_client_full_a assert len(result.tools) > 0 tool_names = [tool.name for tool in result.tools] - logger.info(f"Full access token sees {len(tool_names)} tools") - logger.info(f"Tools: {sorted(tool_names)}") + logger.info("Full access token sees %s tools", len(tool_names)) + logger.info("Tools: %s", sorted(tool_names)) # Verify both read and write tools are present # Full access has all *read and *write scopes @@ -197,7 +197,7 @@ async def test_full_access_token_shows_all_tools(nc_mcp_login_flow_client_full_a assert len(tool_names) >= 90 logger.info( - f"✅ Full access token sees all tools: {len(tool_names)} total (read + write)" + "✅ Full access token sees all tools: %s total (read + write)", len(tool_names) ) @@ -415,7 +415,8 @@ async def test_jwt_with_no_custom_scopes_returns_zero_tools( tool_names = [tool.name for tool in result.tools] logger.info( - f"JWT token with no custom scopes sees {len(tool_names)} tools (should be 7 auth tools)" + "JWT token with no custom scopes sees %s tools (should be 7 auth tools)", + len(tool_names), ) # Only auth/provisioning tools should be visible (they require 'openid' scope) @@ -435,8 +436,8 @@ async def test_jwt_with_no_custom_scopes_returns_zero_tools( ) logger.info( - f"✅ JWT token with only openid scope correctly shows {len(tool_names)} auth tools, " - "resource tools filtered out" + "✅ JWT token with only openid scope correctly shows %s auth tools, resource tools filtered out", + len(tool_names), ) @@ -457,7 +458,7 @@ async def test_jwt_consent_scenarios_read_only(nc_mcp_login_flow_client_read_onl assert len(result.tools) > 0 tool_names = [tool.name for tool in result.tools] - logger.info(f"JWT with nc:read consent sees {len(tool_names)} tools") + logger.info("JWT with nc:read consent sees %s tools", len(tool_names)) # Verify read tools are present read_tools = ["nc_notes_get_note", "nc_notes_search_notes", "nc_webdav_read_file"] @@ -474,7 +475,8 @@ async def test_jwt_consent_scenarios_read_only(nc_mcp_login_flow_client_read_onl assert tool not in tool_names, f"Write tool {tool} should be filtered out" logger.info( - f"✅ JWT with nc:read consent: {len(tool_names)} read tools visible, write tools filtered" + "✅ JWT with nc:read consent: %s read tools visible, write tools filtered", + len(tool_names), ) @@ -495,7 +497,7 @@ async def test_jwt_consent_scenarios_write_only(nc_mcp_login_flow_client_write_o assert len(result.tools) > 0 tool_names = [tool.name for tool in result.tools] - logger.info(f"JWT with nc:write consent sees {len(tool_names)} tools") + logger.info("JWT with nc:write consent sees %s tools", len(tool_names)) # Verify write tools are present write_tools = [ @@ -512,7 +514,8 @@ async def test_jwt_consent_scenarios_write_only(nc_mcp_login_flow_client_write_o assert tool not in tool_names, f"Read-only tool {tool} should be filtered out" logger.info( - f"✅ JWT with nc:write consent: {len(tool_names)} write tools visible, read-only tools filtered" + "✅ JWT with nc:write consent: %s write tools visible, read-only tools filtered", + len(tool_names), ) @@ -533,7 +536,7 @@ async def test_jwt_consent_scenarios_full_access(nc_mcp_login_flow_client_full_a assert len(result.tools) > 0 tool_names = [tool.name for tool in result.tools] - logger.info(f"JWT with full consent sees {len(tool_names)} tools") + logger.info("JWT with full consent sees %s tools", len(tool_names)) # Verify both read and write tools are present read_tools = ["nc_notes_get_note", "nc_webdav_read_file"] @@ -549,7 +552,7 @@ async def test_jwt_consent_scenarios_full_access(nc_mcp_login_flow_client_full_a assert len(tool_names) >= 90, f"Expected 90+ tools but got {len(tool_names)}" logger.info( - f"✅ JWT with full consent: {len(tool_names)} tools visible (all read + write)" + "✅ JWT with full consent: %s tools visible (all read + write)", len(tool_names) ) diff --git a/tests/server/test_calendar_events_mcp.py b/tests/server/test_calendar_events_mcp.py index 1f4e218e..bc1dfdfe 100644 --- a/tests/server/test_calendar_events_mcp.py +++ b/tests/server/test_calendar_events_mcp.py @@ -40,7 +40,7 @@ async def test_mcp_update_event_extended_fields( result_data = json.loads(create_result.content[0].text) event_uid = result_data["uid"] - logger.info(f"Created base event via MCP: {event_uid}") + logger.info("Created base event via MCP: %s", event_uid) # 2. Update with all four extended fields via MCP update_result = await nc_mcp_client.call_tool( diff --git a/tests/server/test_calendar_todos_mcp.py b/tests/server/test_calendar_todos_mcp.py index 1087fd23..b8577e24 100644 --- a/tests/server/test_calendar_todos_mcp.py +++ b/tests/server/test_calendar_todos_mcp.py @@ -24,7 +24,7 @@ async def test_mcp_todo_complete_workflow( try: # 1. Create todo via MCP - logger.info(f"Creating todo in {calendar_name} via MCP") + logger.info("Creating todo in %s via MCP", calendar_name) tomorrow = datetime.now() + timedelta(days=1) create_result = await nc_mcp_client.call_tool( @@ -46,7 +46,7 @@ async def test_mcp_todo_complete_workflow( result_json = json.loads(result_data) todo_uid = result_json["uid"] - logger.info(f"Created todo with UID: {todo_uid}") + logger.info("Created todo with UID: %s", todo_uid) # 2. Verify todo creation via client todos = await nc_client.calendar.list_todos(calendar_name) @@ -57,7 +57,7 @@ async def test_mcp_todo_complete_workflow( assert created_todo["priority"] == 3 # 3. List todos via MCP - logger.info(f"Listing todos in {calendar_name} via MCP") + logger.info("Listing todos in %s via MCP", calendar_name) list_result = await nc_mcp_client.call_tool( "nc_calendar_list_todos", {"calendar_name": calendar_name}, @@ -69,7 +69,7 @@ async def test_mcp_todo_complete_workflow( assert any(t["uid"] == todo_uid for t in list_data["todos"]) # 4. Update todo via MCP - logger.info(f"Updating todo {todo_uid} via MCP") + logger.info("Updating todo %s via MCP", todo_uid) update_result = await nc_mcp_client.call_tool( "nc_calendar_update_todo", { @@ -92,7 +92,7 @@ async def test_mcp_todo_complete_workflow( assert updated_todo["percent_complete"] == 50 # 6. Delete todo via MCP - logger.info(f"Deleting todo {todo_uid} via MCP") + logger.info("Deleting todo %s via MCP", todo_uid) delete_result = await nc_mcp_client.call_tool( "nc_calendar_delete_todo", {"calendar_name": calendar_name, "todo_uid": todo_uid}, diff --git a/tests/server/test_collectives_mcp.py b/tests/server/test_collectives_mcp.py index 96ebd635..7be6bfb1 100644 --- a/tests/server/test_collectives_mcp.py +++ b/tests/server/test_collectives_mcp.py @@ -27,7 +27,7 @@ async def temporary_collective(nc_mcp_client: ClientSession): assert result.isError is False, f"Failed to create collective: {result.content}" data = json.loads(result.content[0].text) collective_id = data["id"] - logger.info(f"Created temporary collective: {name} (ID: {collective_id})") + logger.info("Created temporary collective: %s (ID: %s)", name, collective_id) # Get the landing page ID — filter by parentId == 0 (root page) pages_result = await nc_mcp_client.call_tool( @@ -55,9 +55,9 @@ async def temporary_collective(nc_mcp_client: ClientSession): "collectives_delete_collective", {"collective_id": collective_id}, ) - logger.info(f"Cleaned up collective: {collective_id}") + logger.info("Cleaned up collective: %s", collective_id) except Exception as e: - logger.warning(f"Cleanup of collective {collective_id} failed: {e}") + logger.warning("Cleanup of collective %s failed: %s", collective_id, e) # --- Tool Discovery --- @@ -96,7 +96,7 @@ async def test_collectives_tools_available(nc_mcp_client: ClientSession): f"Expected tool '{expected}' not found in available tools" ) - logger.info(f"All {len(expected_tools)} Collectives tools registered") + logger.info("All %s Collectives tools registered", len(expected_tools)) # --- Collective CRUD --- @@ -115,7 +115,7 @@ async def test_collectives_list( collective_ids = [c["id"] for c in data["collectives"]] assert temporary_collective["id"] in collective_ids - logger.info(f"Found {data['total']} collectives") + logger.info("Found %s collectives", data["total"]) async def test_collectives_set_collective_emoji( @@ -179,7 +179,7 @@ async def test_collectives_page_workflow( page_id = create_data["id"] assert create_data["collective_id"] == cid assert create_data["parent_id"] == landing_id - logger.info(f"Created page: {unique_title} (ID: {page_id})") + logger.info("Created page: %s (ID: %s)", unique_title, page_id) # 2. List pages — should include the new page list_result = await nc_mcp_client.call_tool( @@ -190,7 +190,7 @@ async def test_collectives_page_workflow( list_data = json.loads(list_result.content[0].text) page_ids = [p["id"] for p in list_data["pages"]] assert page_id in page_ids - logger.info(f"Page found in list ({list_data['total']} pages)") + logger.info("Page found in list (%s pages)", list_data["total"]) # 3. Get page with content get_result = await nc_mcp_client.call_tool( @@ -269,7 +269,7 @@ async def test_collectives_get_landing_page_content( "Landing page should have auto-generated content" ) assert len(data["content"]) > 0, "Landing page should have non-empty content" - logger.info(f"Landing page content: {len(data['content'])} bytes") + logger.info("Landing page content: %s bytes", len(data["content"])) async def test_collectives_move_page( @@ -307,7 +307,7 @@ async def test_collectives_move_page( assert data["page_id"] == page_id assert "moved" in data["message"] assert new_title in data["message"] - logger.info(f"Page renamed to: {new_title}") + logger.info("Page renamed to: %s", new_title) # Cleanup await nc_mcp_client.call_tool( @@ -337,7 +337,7 @@ async def test_collectives_tag_workflow( tag_id = tag_data["id"] assert tag_data["name"] == tag_name assert tag_data["color"] == "FF5733" - logger.info(f"Created tag: {tag_name} (ID: {tag_id})") + logger.info("Created tag: %s (ID: %s)", tag_name, tag_id) # 2. List tags — should include the new tag list_tags_result = await nc_mcp_client.call_tool( @@ -348,7 +348,7 @@ async def test_collectives_tag_workflow( tags_data = json.loads(list_tags_result.content[0].text) tag_ids = [t["id"] for t in tags_data["tags"]] assert tag_id in tag_ids - logger.info(f"Tag found in list ({tags_data['total']} tags)") + logger.info("Tag found in list (%s tags)", tags_data["total"]) # 3. Create a page to tag page_result = await nc_mcp_client.call_tool( @@ -368,7 +368,7 @@ async def test_collectives_tag_workflow( {"collective_id": cid, "page_id": page_id, "tag_id": tag_id}, ) assert assign_result.isError is False - logger.info(f"Tag {tag_id} assigned to page {page_id}") + logger.info("Tag %s assigned to page %s", tag_id, page_id) # 5. Remove tag from page remove_result = await nc_mcp_client.call_tool( @@ -376,7 +376,7 @@ async def test_collectives_tag_workflow( {"collective_id": cid, "page_id": page_id, "tag_id": tag_id}, ) assert remove_result.isError is False - logger.info(f"Tag {tag_id} removed from page {page_id}") + logger.info("Tag %s removed from page %s", tag_id, page_id) # Cleanup await nc_mcp_client.call_tool( @@ -406,7 +406,7 @@ async def test_collectives_search( assert data["query"] == "Welcome" assert data["collective_id"] == cid # Search may or may not find results depending on indexing timing - logger.info(f"Search returned {data['total']} results for 'Welcome'") + logger.info("Search returned %s results for 'Welcome'", data["total"]) # --- Collective Trash / Restore / Delete --- @@ -425,7 +425,7 @@ async def test_collectives_trash_restore_delete_workflow( assert create_result.isError is False created = json.loads(create_result.content[0].text) cid = created["id"] - logger.info(f"Created collective {name} (ID: {cid})") + logger.info("Created collective %s (ID: %s)", name, cid) # Trash the collective trash_result = await nc_mcp_client.call_tool( @@ -444,7 +444,7 @@ async def test_collectives_trash_restore_delete_workflow( trash_data = json.loads(list_trash_result.content[0].text) trashed_ids = [c["id"] for c in trash_data["collectives"]] assert cid in trashed_ids - logger.info(f"Found {trash_data['total']} trashed collectives") + logger.info("Found %s trashed collectives", trash_data["total"]) # Restore the collective restore_result = await nc_mcp_client.call_tool( diff --git a/tests/server/test_contacts_mcp.py b/tests/server/test_contacts_mcp.py index f391fa82..9f93e34f 100644 --- a/tests/server/test_contacts_mcp.py +++ b/tests/server/test_contacts_mcp.py @@ -28,7 +28,7 @@ async def test_mcp_contacts_workflow( try: # 1. Create address book via MCP - logger.info(f"Creating address book via MCP: {addressbook_name}") + logger.info("Creating address book via MCP: %s", addressbook_name) create_ab_result = await nc_mcp_client.call_tool( "nc_contacts_create_addressbook", {"name": addressbook_name, "display_name": f"MCP Test {addressbook_name}"}, @@ -40,7 +40,7 @@ async def test_mcp_contacts_workflow( assert any(ab["name"] == addressbook_name for ab in addressbooks) # 3. Create contact via MCP - logger.info(f"Creating contact in {addressbook_name} via MCP") + logger.info("Creating contact in %s via MCP", addressbook_name) create_c_result = await nc_mcp_client.call_tool( "nc_contacts_create_contact", { @@ -56,7 +56,7 @@ async def test_mcp_contacts_workflow( assert any(c["vcard_id"] == contact_uid for c in contacts) # 5. Delete contact via MCP - logger.info(f"Deleting contact {contact_uid} via MCP") + logger.info("Deleting contact %s via MCP", contact_uid) delete_c_result = await nc_mcp_client.call_tool( "nc_contacts_delete_contact", {"addressbook": addressbook_name, "uid": contact_uid}, @@ -68,7 +68,7 @@ async def test_mcp_contacts_workflow( assert not any(c["vcard_id"] == contact_uid for c in contacts) # 7. Delete address book via MCP - logger.info(f"Deleting address book {addressbook_name} via MCP") + logger.info("Deleting address book %s via MCP", addressbook_name) delete_ab_result = await nc_mcp_client.call_tool( "nc_contacts_delete_addressbook", {"name": addressbook_name} ) diff --git a/tests/server/test_cookbook_mcp.py b/tests/server/test_cookbook_mcp.py index c4efb6a0..cf2476d0 100644 --- a/tests/server/test_cookbook_mcp.py +++ b/tests/server/test_cookbook_mcp.py @@ -36,7 +36,7 @@ async def test_mcp_cookbook_create_and_read_recipe( try: # 1. Create recipe via MCP - logger.info(f"Creating recipe via MCP: {recipe_name}") + logger.info("Creating recipe via MCP: %s", recipe_name) create_result = await nc_mcp_client.call_tool( "nc_cookbook_create_recipe", { @@ -59,7 +59,7 @@ async def test_mcp_cookbook_create_and_read_recipe( create_response = json.loads(create_result.content[0].text) created_recipe_id = create_response["id"] - logger.info(f"Recipe created via MCP with ID: {created_recipe_id}") + logger.info("Recipe created via MCP with ID: %s", created_recipe_id) # 2. Verify creation via direct NextcloudClient direct_recipe = await nc_client.cookbook.get_recipe(created_recipe_id) @@ -70,7 +70,7 @@ async def test_mcp_cookbook_create_and_read_recipe( assert direct_recipe["recipeCategory"] == "MCPTesting" # 3. Read recipe via MCP - logger.info(f"Reading recipe via MCP: {created_recipe_id}") + logger.info("Reading recipe via MCP: %s", created_recipe_id) read_result = await nc_mcp_client.call_tool( "nc_cookbook_get_recipe", {"recipe_id": created_recipe_id} ) @@ -84,16 +84,16 @@ async def test_mcp_cookbook_create_and_read_recipe( assert read_recipe["description"] == "A test recipe created via MCP tools" assert len(read_recipe["recipeIngredient"]) == 3 - logger.info(f"Successfully verified recipe {created_recipe_id} via MCP") + logger.info("Successfully verified recipe %s via MCP", created_recipe_id) finally: # Cleanup if created_recipe_id is not None: try: await nc_client.cookbook.delete_recipe(created_recipe_id) - logger.info(f"Cleaned up recipe {created_recipe_id}") + logger.info("Cleaned up recipe %s", created_recipe_id) except Exception as e: - logger.warning(f"Failed to cleanup recipe: {e}") + logger.warning("Failed to cleanup recipe: %s", e) async def test_mcp_cookbook_update_recipe( @@ -115,11 +115,11 @@ async def test_mcp_cookbook_update_recipe( try: # 1. Create recipe via direct client - logger.info(f"Creating recipe for update test: {recipe_name}") + logger.info("Creating recipe for update test: %s", recipe_name) created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data) # 2. Update recipe via MCP (tool handles fetching current recipe internally) - logger.info(f"Updating recipe via MCP: {created_recipe_id}") + logger.info("Updating recipe via MCP: %s", created_recipe_id) update_result = await nc_mcp_client.call_tool( "nc_cookbook_update_recipe", { @@ -143,16 +143,16 @@ async def test_mcp_cookbook_update_recipe( assert len(updated_recipe["recipeInstructions"]) == 2 assert updated_recipe["recipeCategory"] == "Updated" - logger.info(f"Successfully updated recipe {created_recipe_id} via MCP") + logger.info("Successfully updated recipe %s via MCP", created_recipe_id) finally: # Cleanup if created_recipe_id is not None: try: await nc_client.cookbook.delete_recipe(created_recipe_id) - logger.info(f"Cleaned up recipe {created_recipe_id}") + logger.info("Cleaned up recipe %s", created_recipe_id) except Exception as e: - logger.warning(f"Failed to cleanup recipe: {e}") + logger.warning("Failed to cleanup recipe: %s", e) async def test_mcp_cookbook_delete_recipe( @@ -173,11 +173,11 @@ async def test_mcp_cookbook_delete_recipe( try: # 1. Create recipe via direct client - logger.info(f"Creating recipe for delete test: {recipe_name}") + logger.info("Creating recipe for delete test: %s", recipe_name) created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data) # 2. Delete recipe via MCP - logger.info(f"Deleting recipe via MCP: {created_recipe_id}") + logger.info("Deleting recipe via MCP: %s", created_recipe_id) delete_result = await nc_mcp_client.call_tool( "nc_cookbook_delete_recipe", {"recipe_id": created_recipe_id} ) @@ -192,7 +192,9 @@ async def test_mcp_cookbook_delete_recipe( pytest.fail("Recipe should have been deleted but was still found") except Exception: # Expected - recipe should be deleted - logger.info(f"Successfully verified recipe {created_recipe_id} was deleted") + logger.info( + "Successfully verified recipe %s was deleted", created_recipe_id + ) created_recipe_id = None # Mark as cleaned up finally: @@ -200,9 +202,9 @@ async def test_mcp_cookbook_delete_recipe( if created_recipe_id is not None: try: await nc_client.cookbook.delete_recipe(created_recipe_id) - logger.info(f"Cleaned up recipe {created_recipe_id}") + logger.info("Cleaned up recipe %s", created_recipe_id) except Exception as e: - logger.warning(f"Failed to cleanup recipe: {e}") + logger.warning("Failed to cleanup recipe: %s", e) async def test_mcp_cookbook_import_recipe_from_url( @@ -221,7 +223,7 @@ async def test_mcp_cookbook_import_recipe_from_url( try: # 1. Import recipe via MCP - logger.info(f"Importing recipe from nginx container via MCP: {test_url}") + logger.info("Importing recipe from nginx container via MCP: %s", test_url) import_result = await nc_mcp_client.call_tool( "nc_cookbook_import_recipe", {"url": test_url} ) @@ -234,7 +236,7 @@ async def test_mcp_cookbook_import_recipe_from_url( created_recipe_id = int(import_response["recipe_id"]) imported_recipe = import_response["recipe"] - logger.info(f"Successfully imported recipe via MCP: {imported_recipe['name']}") + logger.info("Successfully imported recipe via MCP: %s", imported_recipe["name"]) # 2. Verify basic recipe structure assert imported_recipe["name"] == "Black Pepper Tofu" @@ -247,16 +249,16 @@ async def test_mcp_cookbook_import_recipe_from_url( # 3. Verify we can read it back via direct NextcloudClient retrieved = await nc_client.cookbook.get_recipe(created_recipe_id) assert retrieved["name"] == imported_recipe["name"] - logger.info(f"Verified imported recipe ID: {created_recipe_id}") + logger.info("Verified imported recipe ID: %s", created_recipe_id) finally: # Cleanup if created_recipe_id is not None: try: await nc_client.cookbook.delete_recipe(created_recipe_id) - logger.info(f"Cleaned up imported recipe {created_recipe_id}") + logger.info("Cleaned up imported recipe %s", created_recipe_id) except Exception as e: - logger.warning(f"Failed to cleanup imported recipe: {e}") + logger.warning("Failed to cleanup imported recipe: %s", e) async def test_mcp_cookbook_search_recipes( @@ -278,14 +280,14 @@ async def test_mcp_cookbook_search_recipes( try: # 1. Create recipe via direct client - logger.info(f"Creating recipe for search test with keyword: {unique_keyword}") + logger.info("Creating recipe for search test with keyword: %s", unique_keyword) created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data) # 2. Allow time for indexing await anyio.sleep(2) # 3. Search for the recipe via MCP - logger.info(f"Searching for recipes via MCP with keyword: {unique_keyword}") + logger.info("Searching for recipes via MCP with keyword: %s", unique_keyword) search_result = await nc_mcp_client.call_tool( "nc_cookbook_search_recipes", {"query": unique_keyword} ) @@ -304,7 +306,7 @@ async def test_mcp_cookbook_search_recipes( found = any(str(r.get("id")) == str(created_recipe_id) for r in search_results) assert found, f"Recipe {created_recipe_id} not found in search results" logger.info( - f"Successfully found recipe {created_recipe_id} in MCP search results" + "Successfully found recipe %s in MCP search results", created_recipe_id ) finally: @@ -312,9 +314,9 @@ async def test_mcp_cookbook_search_recipes( if created_recipe_id is not None: try: await nc_client.cookbook.delete_recipe(created_recipe_id) - logger.info(f"Cleaned up recipe {created_recipe_id}") + logger.info("Cleaned up recipe %s", created_recipe_id) except Exception as e: - logger.warning(f"Failed to cleanup recipe: {e}") + logger.warning("Failed to cleanup recipe: %s", e) async def test_mcp_cookbook_list_recipes( @@ -333,7 +335,7 @@ async def test_mcp_cookbook_list_recipes( recipes = list_response["recipes"] assert isinstance(recipes, list) - logger.info(f"Found {len(recipes)} recipes via MCP") + logger.info("Found %s recipes via MCP", len(recipes)) async def test_mcp_cookbook_categories_workflow( @@ -354,7 +356,7 @@ async def test_mcp_cookbook_categories_workflow( try: # 1. Create recipe in test category - logger.info(f"Creating recipe in category: {unique_category}") + logger.info("Creating recipe in category: %s", unique_category) created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data) # 2. Allow time for indexing @@ -374,10 +376,10 @@ async def test_mcp_cookbook_categories_workflow( categories = categories_response["categories"] assert isinstance(categories, list) - logger.info(f"Found {len(categories)} categories via MCP") + logger.info("Found %s categories via MCP", len(categories)) # 4. Get recipes in this category via MCP - logger.info(f"Getting recipes in category via MCP: {unique_category}") + logger.info("Getting recipes in category via MCP: %s", unique_category) category_recipes_result = await nc_mcp_client.call_tool( "nc_cookbook_get_recipes_in_category", {"category": unique_category} ) @@ -399,16 +401,16 @@ async def test_mcp_cookbook_categories_workflow( assert found, ( f"Recipe {created_recipe_id} not found in category {unique_category}" ) - logger.info(f"Successfully found recipe in category {unique_category} via MCP") + logger.info("Successfully found recipe in category %s via MCP", unique_category) finally: # Cleanup if created_recipe_id is not None: try: await nc_client.cookbook.delete_recipe(created_recipe_id) - logger.info(f"Cleaned up recipe {created_recipe_id}") + logger.info("Cleaned up recipe %s", created_recipe_id) except Exception as e: - logger.warning(f"Failed to cleanup recipe: {e}") + logger.warning("Failed to cleanup recipe: %s", e) async def test_mcp_cookbook_keywords_workflow( @@ -429,7 +431,7 @@ async def test_mcp_cookbook_keywords_workflow( try: # 1. Create recipe with test keywords - logger.info(f"Creating recipe with keyword: {unique_keyword}") + logger.info("Creating recipe with keyword: %s", unique_keyword) created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data) # 2. Allow extra time for indexing and trigger reindex @@ -449,10 +451,10 @@ async def test_mcp_cookbook_keywords_workflow( keywords = keywords_response["keywords"] assert isinstance(keywords, list) - logger.info(f"Found {len(keywords)} keywords via MCP") + logger.info("Found %s keywords via MCP", len(keywords)) # 4. Get recipes with this keyword via MCP - logger.info(f"Getting recipes with keyword via MCP: {unique_keyword}") + logger.info("Getting recipes with keyword via MCP: %s", unique_keyword) keyword_recipes_result = await nc_mcp_client.call_tool( "nc_cookbook_get_recipes_with_keywords", {"keywords": [unique_keyword]} ) @@ -475,15 +477,17 @@ async def test_mcp_cookbook_keywords_workflow( ) if found: logger.info( - f"Successfully found recipe with keyword {unique_keyword} via MCP" + "Successfully found recipe with keyword %s via MCP", unique_keyword ) else: logger.warning( - f"Recipe {created_recipe_id} not in keyword results via MCP, but other recipes found" + "Recipe %s not in keyword results via MCP, but other recipes found", + created_recipe_id, ) else: logger.warning( - f"No recipes found with keyword {unique_keyword} via MCP - may be indexing delay" + "No recipes found with keyword %s via MCP - may be indexing delay", + unique_keyword, ) finally: @@ -491,9 +495,9 @@ async def test_mcp_cookbook_keywords_workflow( if created_recipe_id is not None: try: await nc_client.cookbook.delete_recipe(created_recipe_id) - logger.info(f"Cleaned up recipe {created_recipe_id}") + logger.info("Cleaned up recipe %s", created_recipe_id) except Exception as e: - logger.warning(f"Failed to cleanup recipe: {e}") + logger.warning("Failed to cleanup recipe: %s", e) async def test_mcp_cookbook_config_and_version( @@ -509,7 +513,7 @@ async def test_mcp_cookbook_config_and_version( version_response = json.loads(version_result.contents[0].text) assert "cookbook_version" in version_response assert "api_version" in version_response - logger.info(f"Cookbook version from MCP: {version_response}") + logger.info("Cookbook version from MCP: %s", version_response) # 2. Verify version via direct NextcloudClient direct_version = await nc_client.cookbook.get_version() @@ -526,7 +530,7 @@ async def test_mcp_cookbook_config_and_version( assert len(config_result.contents) > 0 config_response = json.loads(config_result.contents[0].text) assert isinstance(config_response, dict) - logger.info(f"Cookbook config from MCP: {config_response}") + logger.info("Cookbook config from MCP: %s", config_response) # 4. Verify config via direct NextcloudClient direct_config = await nc_client.cookbook.get_config() @@ -551,4 +555,4 @@ async def test_mcp_cookbook_reindex( reindex_response = json.loads(reindex_result.content[0].text) assert isinstance(reindex_response["message"], str) - logger.info(f"Reindex result from MCP: {reindex_response['message']}") + logger.info("Reindex result from MCP: %s", reindex_response["message"]) diff --git a/tests/server/test_deck_advanced_features.py b/tests/server/test_deck_advanced_features.py index 3fa682d9..85d60fb1 100644 --- a/tests/server/test_deck_advanced_features.py +++ b/tests/server/test_deck_advanced_features.py @@ -21,7 +21,7 @@ async def test_deck_stack_mcp_tools( stack_order = 1 # 1. Create stack via MCP tool - logger.info(f"Creating stack via MCP: {stack_title}") + logger.info("Creating stack via MCP: %s", stack_title) create_result = await nc_mcp_client.call_tool( "deck_create_stack", {"board_id": board_id, "title": stack_title, "order": stack_order}, @@ -34,11 +34,11 @@ async def test_deck_stack_mcp_tools( stack_id = created_stack_response["id"] assert created_stack_response["title"] == stack_title assert created_stack_response["order"] == stack_order - logger.info(f"Stack created via MCP with ID: {stack_id}") + logger.info("Stack created via MCP with ID: %s", stack_id) try: # 2. Get stack via MCP resource - logger.info(f"Getting stack via MCP resource: {stack_id}") + logger.info("Getting stack via MCP resource: %s", stack_id) get_result = await nc_mcp_client.read_resource( f"nc://Deck/boards/{board_id}/stacks/{stack_id}" ) @@ -51,7 +51,7 @@ async def test_deck_stack_mcp_tools( # 3. Update stack via MCP tool updated_title = f"Updated {stack_title}" updated_order = 2 - logger.info(f"Updating stack via MCP tool: {stack_id}") + logger.info("Updating stack via MCP tool: %s", stack_id) update_result = await nc_mcp_client.call_tool( "deck_update_stack", { @@ -86,10 +86,10 @@ async def test_deck_stack_mcp_tools( # Verify our stack is in the list stack_ids = [stack["id"] for stack in stacks_data] assert stack_id in stack_ids, "Updated stack not found in list" - logger.info(f"Stack {stack_id} found in stacks list") + logger.info("Stack %s found in stacks list", stack_id) # 6. Read stack via MCP resource - logger.info(f"Reading stack via MCP resource: {stack_id}") + logger.info("Reading stack via MCP resource: %s", stack_id) read_result = await nc_mcp_client.read_resource( f"nc://Deck/boards/{board_id}/stacks/{stack_id}" ) @@ -100,7 +100,7 @@ async def test_deck_stack_mcp_tools( finally: # Clean up await nc_client.deck.delete_stack(board_id, stack_id) - logger.info(f"Cleaned up stack ID: {stack_id}") + logger.info("Cleaned up stack ID: %s", stack_id) # Card MCP Tools Tests @@ -117,7 +117,7 @@ async def test_deck_card_mcp_tools( card_description = f"Test description for {card_title}" # 1. Create card via MCP tool - logger.info(f"Creating card via MCP: {card_title}") + logger.info("Creating card via MCP: %s", card_title) create_result = await nc_mcp_client.call_tool( "deck_create_card", { @@ -137,11 +137,11 @@ async def test_deck_card_mcp_tools( card_id = created_card_response["id"] assert created_card_response["title"] == card_title assert created_card_response["description"] == card_description - logger.info(f"Card created via MCP with ID: {card_id}") + logger.info("Card created via MCP with ID: %s", card_id) try: # 2. Get card via MCP resource - logger.info(f"Getting card via MCP resource: {card_id}") + logger.info("Getting card via MCP resource: %s", card_id) get_result = await nc_mcp_client.read_resource( f"nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}" ) @@ -154,7 +154,7 @@ async def test_deck_card_mcp_tools( # 3. Update card via MCP tool updated_title = f"Updated {card_title}" updated_description = f"Updated description for {card_title}" - logger.info(f"Updating card via MCP tool: {card_id}") + logger.info("Updating card via MCP tool: %s", card_id) update_result = await nc_mcp_client.call_tool( "deck_update_card", { @@ -178,7 +178,7 @@ async def test_deck_card_mcp_tools( logger.info("Card update verified via direct client") # 5. Archive/unarchive card via MCP tools - logger.info(f"Archiving card via MCP tool: {card_id}") + logger.info("Archiving card via MCP tool: %s", card_id) archive_result = await nc_mcp_client.call_tool( "deck_archive_card", {"board_id": board_id, "stack_id": stack_id, "card_id": card_id}, @@ -189,7 +189,7 @@ async def test_deck_card_mcp_tools( ) logger.info("Card archived via MCP tool successfully") - logger.info(f"Unarchiving card via MCP tool: {card_id}") + logger.info("Unarchiving card via MCP tool: %s", card_id) unarchive_result = await nc_mcp_client.call_tool( "deck_unarchive_card", {"board_id": board_id, "stack_id": stack_id, "card_id": card_id}, @@ -201,7 +201,7 @@ async def test_deck_card_mcp_tools( logger.info("Card unarchived via MCP tool successfully") # 6. Move card to different position via MCP tool - logger.info(f"Reordering card via MCP tool: {card_id}") + logger.info("Reordering card via MCP tool: %s", card_id) reorder_result = await nc_mcp_client.call_tool( "deck_reorder_card", { @@ -219,7 +219,7 @@ async def test_deck_card_mcp_tools( logger.info("Card reordered via MCP tool successfully") # 7. Read card via MCP resource - logger.info(f"Reading card via MCP resource: {card_id}") + logger.info("Reading card via MCP resource: %s", card_id) read_result = await nc_mcp_client.read_resource( f"nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}" ) @@ -230,7 +230,7 @@ async def test_deck_card_mcp_tools( finally: # Clean up await nc_client.deck.delete_card(board_id, stack_id, card_id) - logger.info(f"Cleaned up card ID: {card_id}") + logger.info("Cleaned up card ID: %s", card_id) # Label MCP Tools Tests @@ -243,7 +243,7 @@ async def test_deck_label_mcp_tools( label_color = "FF0000" # Red # 1. Create label via MCP tool - logger.info(f"Creating label via MCP: {label_title}") + logger.info("Creating label via MCP: %s", label_title) create_result = await nc_mcp_client.call_tool( "deck_create_label", {"board_id": board_id, "title": label_title, "color": label_color}, @@ -256,11 +256,11 @@ async def test_deck_label_mcp_tools( label_id = created_label_response["id"] assert created_label_response["title"] == label_title assert created_label_response["color"] == label_color - logger.info(f"Label created via MCP with ID: {label_id}") + logger.info("Label created via MCP with ID: %s", label_id) try: # 2. Get label via MCP resource - logger.info(f"Getting label via MCP resource: {label_id}") + logger.info("Getting label via MCP resource: %s", label_id) get_result = await nc_mcp_client.read_resource( f"nc://Deck/boards/{board_id}/labels/{label_id}" ) @@ -273,7 +273,7 @@ async def test_deck_label_mcp_tools( # 3. Update label via MCP tool updated_title = f"Updated {label_title}" updated_color = "00FF00" # Green - logger.info(f"Updating label via MCP tool: {label_id}") + logger.info("Updating label via MCP tool: %s", label_id) update_result = await nc_mcp_client.call_tool( "deck_update_label", { @@ -296,7 +296,7 @@ async def test_deck_label_mcp_tools( logger.info("Label update verified via direct client") # 5. Read label via MCP resource - logger.info(f"Reading label via MCP resource: {label_id}") + logger.info("Reading label via MCP resource: %s", label_id) read_result = await nc_mcp_client.read_resource( f"nc://Deck/boards/{board_id}/labels/{label_id}" ) @@ -307,7 +307,7 @@ async def test_deck_label_mcp_tools( finally: # Clean up await nc_client.deck.delete_label(board_id, label_id) - logger.info(f"Cleaned up label ID: {label_id}") + logger.info("Cleaned up label ID: %s", label_id) # Label-Card Assignment Tests @@ -330,7 +330,7 @@ async def test_deck_card_label_assignment_mcp_tools( try: # 1. Assign label to card via MCP tool - logger.info(f"Assigning label {label_id} to card {card_id} via MCP") + logger.info("Assigning label %s to card %s via MCP", label_id, card_id) assign_result = await nc_mcp_client.call_tool( "deck_assign_label_to_card", { @@ -354,7 +354,7 @@ async def test_deck_card_label_assignment_mcp_tools( logger.info("Label assignment verified via direct client") # 3. Remove label from card via MCP tool - logger.info(f"Removing label {label_id} from card {card_id} via MCP") + logger.info("Removing label %s from card %s via MCP", label_id, card_id) remove_result = await nc_mcp_client.call_tool( "deck_remove_label_from_card", { @@ -382,7 +382,7 @@ async def test_deck_card_label_assignment_mcp_tools( finally: # Clean up await nc_client.deck.delete_label(board_id, label_id) - logger.info(f"Cleaned up label ID: {label_id}") + logger.info("Cleaned up label ID: %s", label_id) # User Assignment Tests @@ -401,7 +401,7 @@ async def test_deck_card_user_assignment_mcp_tools( user_id = "admin" # 1. Assign user to card via MCP tool - logger.info(f"Assigning user {user_id} to card {card_id} via MCP") + logger.info("Assigning user %s to card %s via MCP", user_id, card_id) assign_result = await nc_mcp_client.call_tool( "deck_assign_user_to_card", { @@ -432,7 +432,7 @@ async def test_deck_card_user_assignment_mcp_tools( logger.info("User assignment verified via direct client") # 3. Unassign user from card via MCP tool - logger.info(f"Unassigning user {user_id} from card {card_id} via MCP") + logger.info("Unassigning user %s from card %s via MCP", user_id, card_id) unassign_result = await nc_mcp_client.call_tool( "deck_unassign_user_from_card", { @@ -521,7 +521,7 @@ async def test_deck_mcp_resource_templates(nc_mcp_client: ClientSession): assert expected_template in template_uris, ( f"Expected template '{expected_template}' not found" ) - logger.info(f"Found expected deck resource template: {expected_template}") + logger.info("Found expected deck resource template: %s", expected_template) # Listing resource tests @@ -534,7 +534,7 @@ async def test_deck_mcp_listing_resources( stack_id = stack_data["id"] # 1. Test listing stacks resource - logger.info(f"Reading stacks list via MCP resource for board {board_id}") + logger.info("Reading stacks list via MCP resource for board %s", board_id) stacks_resource_result = await nc_mcp_client.read_resource( f"nc://Deck/boards/{board_id}/stacks" ) @@ -547,7 +547,7 @@ async def test_deck_mcp_listing_resources( logger.info("Stack found in stacks resource list") # 2. Test listing cards resource - logger.info(f"Reading cards list via MCP resource for stack {stack_id}") + logger.info("Reading cards list via MCP resource for stack %s", stack_id) cards_resource_result = await nc_mcp_client.read_resource( f"nc://Deck/boards/{board_id}/stacks/{stack_id}/cards" ) @@ -560,7 +560,7 @@ async def test_deck_mcp_listing_resources( logger.info("Card found in cards resource list") # 3. Test listing labels resource - logger.info(f"Reading labels list via MCP resource for board {board_id}") + logger.info("Reading labels list via MCP resource for board %s", board_id) labels_resource_result = await nc_mcp_client.read_resource( f"nc://Deck/boards/{board_id}/labels" ) diff --git a/tests/server/test_deck_mcp.py b/tests/server/test_deck_mcp.py index d3fa6a9e..0bcaedfa 100644 --- a/tests/server/test_deck_mcp.py +++ b/tests/server/test_deck_mcp.py @@ -25,7 +25,7 @@ async def test_deck_mcp_connectivity(nc_mcp_client: ClientSession): assert expected_tool in tool_names, ( f"Expected deck tool '{expected_tool}' not found in available tools" ) - logger.info(f"Found expected deck tool: {expected_tool}") + logger.info("Found expected deck tool: %s", expected_tool) # List available resource templates templates = await nc_mcp_client.list_resource_templates() @@ -40,7 +40,7 @@ async def test_deck_mcp_connectivity(nc_mcp_client: ClientSession): assert expected_template in template_uris, ( f"Expected deck template '{expected_template}' not found" ) - logger.info(f"Found expected deck resource template: {expected_template}") + logger.info("Found expected deck resource template: %s", expected_template) # List available resources resources = await nc_mcp_client.list_resources() @@ -55,7 +55,7 @@ async def test_deck_mcp_connectivity(nc_mcp_client: ClientSession): assert expected_resource in resource_uris, ( f"Expected deck resource '{expected_resource}' not found" ) - logger.info(f"Found expected deck resource: {expected_resource}") + logger.info("Found expected deck resource: %s", expected_resource) async def test_deck_board_crud_workflow_mcp( @@ -68,7 +68,7 @@ async def test_deck_board_crud_workflow_mcp( board_color = "0000FF" # Blue # 1. Create board via MCP - logger.info(f"Creating board via MCP: {board_title}") + logger.info("Creating board via MCP: %s", board_title) create_result = await nc_mcp_client.call_tool( "deck_create_board", {"title": board_title, "color": board_color}, @@ -81,7 +81,7 @@ async def test_deck_board_crud_workflow_mcp( created_board_response = json.loads(created_board_json) board_id = created_board_response["id"] - logger.info(f"Board created via MCP with ID: {board_id}") + logger.info("Board created via MCP with ID: %s", board_id) assert created_board_response["title"] == board_title assert created_board_response["color"] == board_color @@ -94,7 +94,7 @@ async def test_deck_board_crud_workflow_mcp( logger.info("Board creation verified via direct client") # 3. Read board via MCP resource - logger.info(f"Reading board via MCP resource: {board_id}") + logger.info("Reading board via MCP resource: %s", board_id) read_result = await nc_mcp_client.read_resource(f"nc://Deck/boards/{board_id}") assert len(read_result.contents) == 1, "Expected exactly one content item" read_board_data = json.loads(read_result.contents[0].text) @@ -104,7 +104,7 @@ async def test_deck_board_crud_workflow_mcp( logger.info("Board read via MCP resource successfully") # 4. Verify board via direct read of resource - logger.info(f"Verifying board via resource read: {board_id}") + logger.info("Verifying board via resource read: %s", board_id) # This was already done in step 3, so we'll just log confirmation logger.info("Board structure verified successfully") @@ -124,7 +124,7 @@ async def test_deck_board_crud_workflow_mcp( # Clean up - delete board await nc_client.deck.delete_board(board_id) - logger.info(f"Cleaned up board ID: {board_id}") + logger.info("Cleaned up board ID: %s", board_id) async def test_deck_board_operations_error_handling_mcp(nc_mcp_client: ClientSession): @@ -143,7 +143,7 @@ async def test_deck_board_operations_error_handling_mcp(nc_mcp_client: ClientSes logger.info("Invalid board creation correctly failed via MCP tool") # Test read non-existent board via MCP resource - logger.info(f"Testing read non-existent board via MCP resource: {non_existent_id}") + logger.info("Testing read non-existent board via MCP resource: %s", non_existent_id) try: read_result = await nc_mcp_client.read_resource( f"nc://Deck/boards/{non_existent_id}" @@ -153,7 +153,7 @@ async def test_deck_board_operations_error_handling_mcp(nc_mcp_client: ClientSes "Expected empty content for non-existent board" ) except Exception as e: - logger.info(f"Read non-existent board correctly failed via MCP resource: {e}") + logger.info("Read non-existent board correctly failed via MCP resource: %s", e) async def test_deck_board_creation_validation_mcp(nc_mcp_client: ClientSession): @@ -185,11 +185,11 @@ async def test_deck_board_creation_success_mcp( assert create_result.isError is False, "Valid board creation should succeed" created_board = json.loads(create_result.content[0].text) board_id = created_board["id"] - logger.info(f"Valid board created successfully with ID: {board_id}") + logger.info("Valid board created successfully with ID: %s", board_id) # Clean up - delete board await nc_client.deck.delete_board(board_id) - logger.info(f"Cleaned up board ID: {board_id}") + logger.info("Cleaned up board ID: %s", board_id) async def test_deck_workflow_integration_mcp( @@ -202,7 +202,7 @@ async def test_deck_workflow_integration_mcp( board_title = board_data["title"] # 1. Read board via MCP to verify the structure - logger.info(f"Reading board via MCP resource: {board_id}") + logger.info("Reading board via MCP resource: %s", board_id) read_result = await nc_mcp_client.read_resource(f"nc://Deck/boards/{board_id}") board_mcp_data = json.loads(read_result.contents[0].text) @@ -219,7 +219,7 @@ async def test_deck_workflow_integration_mcp( logger.info("Board found in boards list") # 3. Verify board data matches via resource (already done in step 1) - logger.info(f"Board data verification completed for board: {board_id}") + logger.info("Board data verification completed for board: %s", board_id) logger.info("Board structure and data verified successfully") @@ -250,7 +250,7 @@ async def test_deck_card_comment_crud_workflow_mcp( assert comment["objectId"] == card_id assert comment["message"] == "Initial comment" assert comment["replyTo"] is None - logger.info(f"Created comment ID {comment_id} on card {card_id}") + logger.info("Created comment ID %s on card %s", comment_id, card_id) # 2. List comments via MCP — verify the new comment is present list_result = await nc_mcp_client.call_tool( diff --git a/tests/server/test_error_propagation.py b/tests/server/test_error_propagation.py index cc9b48d8..732d8704 100644 --- a/tests/server/test_error_propagation.py +++ b/tests/server/test_error_propagation.py @@ -40,7 +40,7 @@ async def test_search_with_empty_query(nc_mcp_client: ClientSession): # Search with empty query response = await nc_mcp_client.call_tool("nc_notes_search_notes", {"query": ""}) - logger.info(f"Empty search query response: {response}") + logger.info("Empty search query response: %s", response) # Should return successful response with empty or valid results assert response is not None @@ -54,7 +54,7 @@ async def test_tool_missing_required_parameters(nc_mcp_client: ClientSession): "nc_notes_create_note", {"title": "Test"}, # Missing content and category ) - logger.info(f"Missing params response: {response}") + logger.info("Missing params response: %s", response) # Should return error response for missing required parameters assert response is not None @@ -108,7 +108,7 @@ async def test_calendar_missing_calendar_error(nc_mcp_client: ClientSession): }, ) - logger.info(f"Non-existent calendar response: {response}") + logger.info("Non-existent calendar response: %s", response) # Should return structured error response assert response is not None @@ -131,7 +131,7 @@ async def test_webdav_read_missing_file_error(nc_mcp_client: ClientSession): "nc_webdav_read_file", {"path": "non-existent-file.txt"} ) - logger.info(f"Missing file response: {response}") + logger.info("Missing file response: %s", response) # Should return structured error response assert response is not None @@ -154,7 +154,7 @@ async def test_tables_missing_table_error(nc_mcp_client: ClientSession): "nc_tables_get_schema", {"table_id": 999999} ) - logger.info(f"Missing table response: {response}") + logger.info("Missing table response: %s", response) # Should return structured error response assert response is not None diff --git a/tests/server/test_mcp.py b/tests/server/test_mcp.py index 03e379b2..81b8de10 100644 --- a/tests/server/test_mcp.py +++ b/tests/server/test_mcp.py @@ -19,7 +19,7 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession): logger.info("Available MCP tools:") tool_names = [] for tool in tools.tools: - logger.info(f" - {tool.name}: {tool.description}") + logger.info(" - %s: %s", tool.name, tool.description) tool_names.append(tool.name) # Verify expected tools are present @@ -88,7 +88,7 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession): logger.info("\nAvailable resource templates:") template_uris = [] for template in templates.resourceTemplates: - logger.info(f" - {template.uriTemplate}") + logger.info(" - %s", template.uriTemplate) template_uris.append(template.uriTemplate) # Verify expected resource templates @@ -105,7 +105,7 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession): logger.info("\nAvailable resources:") resource_uris = [] for resource in resources.resources: - logger.info(f" - {resource.uri}: {resource.name}") + logger.info(" - %s: %s", resource.uri, resource.name) resource_uris.append(str(resource.uri)) # Convert to string for comparison # Verify expected resources @@ -126,7 +126,7 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession): prompts = await nc_mcp_client.list_prompts() logger.info("\nAvailable prompts:") for prompt in prompts.prompts: - logger.info(f" - {prompt.name}") + logger.info(" - %s", prompt.name) async def test_mcp_notes_crud_workflow( @@ -143,7 +143,7 @@ async def test_mcp_notes_crud_workflow( try: # 1. Create note via MCP - logger.info(f"Creating note via MCP: {test_title}") + logger.info("Creating note via MCP: %s", test_title) create_result = await nc_mcp_client.call_tool( "nc_notes_create_note", {"title": test_title, "content": test_content, "category": test_category}, @@ -157,7 +157,7 @@ async def test_mcp_notes_crud_workflow( note_id = note_data["id"] create_etag = note_data["etag"] # Verify create response includes ETag - logger.info(f"Note created via MCP with ID: {note_id}, ETag: {create_etag}") + logger.info("Note created via MCP with ID: %s, ETag: %s", note_id, create_etag) assert "etag" in note_data, "Create response should include ETag" assert create_etag, "Create ETag should not be empty" @@ -170,7 +170,7 @@ async def test_mcp_notes_crud_workflow( assert direct_note["category"] == test_category, "Category mismatch" # 3. Read note via MCP - logger.info(f"Reading note via MCP: {note_id}") + logger.info("Reading note via MCP: %s", note_id) read_result = await nc_mcp_client.call_tool( "nc_notes_get_note", {"note_id": note_id} ) @@ -186,7 +186,7 @@ async def test_mcp_notes_crud_workflow( updated_content = f"Updated content: {test_content}" etag = read_note_data["etag"] - logger.info(f"Updating note via MCP: {note_id}") + logger.info("Updating note via MCP: %s", note_id) update_result = await nc_mcp_client.call_tool( "nc_notes_update_note", { @@ -206,7 +206,7 @@ async def test_mcp_notes_crud_workflow( updated_note_data = json.loads(update_result.content[0].text) update_etag = updated_note_data["etag"] - logger.info(f"Note updated via MCP, new ETag: {update_etag}") + logger.info("Note updated via MCP, new ETag: %s", update_etag) assert "etag" in updated_note_data, "Update response should include ETag" assert update_etag, "Update ETag should not be empty" assert update_etag != etag, "ETag should change after update" @@ -218,7 +218,7 @@ async def test_mcp_notes_crud_workflow( # 6. Append content via MCP append_content = "\n\nThis is appended content via MCP." - logger.info(f"Appending content to note via MCP: {note_id}") + logger.info("Appending content to note via MCP: %s", note_id) append_result = await nc_mcp_client.call_tool( "nc_notes_append_content", {"note_id": note_id, "content": append_content} ) @@ -231,7 +231,7 @@ async def test_mcp_notes_crud_workflow( appended_note_data = json.loads(append_result.content[0].text) append_etag = appended_note_data["etag"] - logger.info(f"Content appended via MCP, new ETag: {append_etag}") + logger.info("Content appended via MCP, new ETag: %s", append_etag) assert "etag" in appended_note_data, "Append response should include ETag" assert append_etag, "Append ETag should not be empty" assert append_etag != update_etag, "ETag should change after append" @@ -241,7 +241,7 @@ async def test_mcp_notes_crud_workflow( assert append_content in appended_direct_note["content"] # 8. Search for note via MCP - logger.info(f"Searching for note via MCP with query: {unique_suffix}") + logger.info("Searching for note via MCP with query: %s", unique_suffix) search_result = await nc_mcp_client.call_tool( "nc_notes_search_notes", {"query": unique_suffix} ) @@ -250,7 +250,7 @@ async def test_mcp_notes_crud_workflow( f"MCP note search failed: {search_result.content}" ) search_notes_text = search_result.content[0].text - logger.info(f"Search result text: {search_notes_text}") + logger.info("Search result text: %s", search_notes_text) search_response = json.loads(search_notes_text) # Expect structured response with Pydantic format @@ -282,7 +282,7 @@ async def test_mcp_notes_crud_workflow( assert found_note["title"] == updated_title # 9. Delete note via MCP - logger.info(f"Deleting note via MCP: {note_id}") + logger.info("Deleting note via MCP: %s", note_id) delete_result = await nc_mcp_client.call_tool( "nc_notes_delete_note", {"note_id": note_id} ) @@ -297,7 +297,7 @@ async def test_mcp_notes_crud_workflow( pytest.fail("Note should have been deleted but was still found") except Exception: # Expected - note should be deleted - logger.info(f"Successfully verified note {note_id} was deleted") + logger.info("Successfully verified note %s was deleted", note_id) created_note = None # Mark as cleaned up finally: @@ -306,9 +306,9 @@ async def test_mcp_notes_crud_workflow( try: note_data = json.loads(created_note) await nc_client.notes.delete_note(note_data["id"]) - logger.info(f"Cleaned up note {note_data['id']} after test failure") + logger.info("Cleaned up note %s after test failure", note_data["id"]) except Exception as e: - logger.warning(f"Failed to cleanup note: {e}") + logger.warning("Failed to cleanup note: %s", e) async def test_mcp_notes_etag_conflict( @@ -325,7 +325,7 @@ async def test_mcp_notes_etag_conflict( try: # 1. Create note via MCP - logger.info(f"Creating note for ETag conflict test: {test_title}") + logger.info("Creating note for ETag conflict test: %s", test_title) create_result = await nc_mcp_client.call_tool( "nc_notes_create_note", {"title": test_title, "content": test_content, "category": test_category}, @@ -355,7 +355,7 @@ async def test_mcp_notes_etag_conflict( assert new_etag != original_etag, "ETag should have changed after update" # 3. Try to update with the stale (original) ETag - this should fail - logger.info(f"Attempting update with stale ETag: {original_etag}") + logger.info("Attempting update with stale ETag: %s", original_etag) conflict_result = await nc_mcp_client.call_tool( "nc_notes_update_note", { @@ -382,9 +382,9 @@ async def test_mcp_notes_etag_conflict( if created_note is not None: try: await nc_client.notes.delete_note(created_note["id"]) - logger.info(f"Cleaned up test note {created_note['id']}") + logger.info("Cleaned up test note %s", created_note["id"]) except Exception as e: - logger.warning(f"Failed to cleanup test note: {e}") + logger.warning("Failed to cleanup test note: %s", e) async def test_mcp_webdav_workflow( @@ -400,7 +400,7 @@ async def test_mcp_webdav_workflow( try: # 1. Create directory via MCP - logger.info(f"Creating directory via MCP: {test_dir}") + logger.info("Creating directory via MCP: %s", test_dir) create_dir_result = await nc_mcp_client.call_tool( "nc_webdav_create_directory", {"path": test_dir} ) @@ -415,7 +415,7 @@ async def test_mcp_webdav_workflow( assert test_dir in dir_names, f"Directory {test_dir} not found in root listing" # 3. Write file via MCP - logger.info(f"Writing file via MCP: {test_file_path}") + logger.info("Writing file via MCP: %s", test_file_path) write_result = await nc_mcp_client.call_tool( "nc_webdav_write_file", { @@ -437,7 +437,7 @@ async def test_mcp_webdav_workflow( ) # 5. Read file via MCP - logger.info(f"Reading file via MCP: {test_file_path}") + logger.info("Reading file via MCP: %s", test_file_path) read_result = await nc_mcp_client.call_tool( "nc_webdav_read_file", {"path": test_file_path} ) @@ -458,7 +458,7 @@ async def test_mcp_webdav_workflow( assert direct_content.decode("utf-8") == test_content # 7. List directory via MCP - logger.info(f"Listing directory via MCP: {test_dir}") + logger.info("Listing directory via MCP: %s", test_dir) list_result = await nc_mcp_client.call_tool( "nc_webdav_list_directory", {"path": test_dir} ) @@ -467,7 +467,7 @@ async def test_mcp_webdav_workflow( f"MCP directory listing failed: {list_result.content}" ) listing_text = list_result.content[0].text - logger.info(f"Directory listing response: {listing_text}") + logger.info("Directory listing response: %s", listing_text) listing_data = json.loads(listing_text) # Extract files from DirectoryListing response @@ -498,17 +498,17 @@ async def test_mcp_webdav_workflow( finally: # Cleanup try: - logger.info(f"Cleaning up test file: {test_file_path}") + logger.info("Cleaning up test file: %s", test_file_path) await nc_mcp_client.call_tool( "nc_webdav_delete_resource", {"path": test_file_path} ) - logger.info(f"Cleaning up test directory: {test_dir}") + logger.info("Cleaning up test directory: %s", test_dir) await nc_mcp_client.call_tool( "nc_webdav_delete_resource", {"path": test_dir} ) except Exception as e: - logger.warning(f"Failed to cleanup WebDAV resources: {e}") + logger.warning("Failed to cleanup WebDAV resources: %s", e) async def test_mcp_resources_access( @@ -576,8 +576,8 @@ async def test_mcp_calendar_workflow( calendars_response = json.loads(calendars_result.content[0].text) # Debug output to understand the structure - logger.info(f"calendars_response type: {type(calendars_response)}") - logger.info(f"calendars_response content: {calendars_response}") + logger.info("calendars_response type: %s", type(calendars_response)) + logger.info("calendars_response content: %s", calendars_response) # Expect structured response with Pydantic format assert isinstance(calendars_response, dict), ( @@ -600,7 +600,7 @@ async def test_mcp_calendar_workflow( # Use the first available calendar calendar_name = calendars_list[0]["name"] - logger.info(f"Using calendar: {calendar_name}") + logger.info("Using calendar: %s", calendar_name) # 2. Create event via MCP from datetime import datetime, timedelta @@ -621,7 +621,7 @@ async def test_mcp_calendar_workflow( "priority": 5, } - logger.info(f"Creating event via MCP: {test_event_title}") + logger.info("Creating event via MCP: %s", test_event_title) create_result = await nc_mcp_client.call_tool( "nc_calendar_create_event", event_data ) @@ -634,7 +634,7 @@ async def test_mcp_calendar_workflow( event_uid = created_event_data["uid"] created_event = {"uid": event_uid, "calendar_name": calendar_name} - logger.info(f"Event created via MCP with UID: {event_uid}") + logger.info("Event created via MCP with UID: %s", event_uid) # 3. Verify creation via direct NextcloudClient direct_event, _ = await nc_client.calendar.get_event(calendar_name, event_uid) @@ -643,7 +643,7 @@ async def test_mcp_calendar_workflow( assert "testing" in direct_event.get("categories", "") # 4. Get event via MCP - logger.info(f"Getting event via MCP: {event_uid}") + logger.info("Getting event via MCP: %s", event_uid) get_result = await nc_mcp_client.call_tool( "nc_calendar_get_event", {"calendar_name": calendar_name, "event_uid": event_uid}, @@ -686,8 +686,8 @@ async def test_mcp_calendar_workflow( events_response = json.loads(list_result.content[0].text) # Debug output to understand what nc_calendar_list_events returns - logger.info(f"list_events result type: {type(events_response)}") - logger.info(f"list_events result content: {events_response}") + logger.info("list_events result type: %s", type(events_response)) + logger.info("list_events result content: %s", events_response) # Response is now a ListEventsResponse with an "events" field assert isinstance(events_response, dict), "Expected response dict" @@ -748,7 +748,7 @@ async def test_mcp_calendar_workflow( "priority": 1, } - logger.info(f"Updating event via MCP: {event_uid}") + logger.info("Updating event via MCP: %s", event_uid) update_result = await nc_mcp_client.call_tool( "nc_calendar_update_event", update_data ) @@ -784,7 +784,7 @@ async def test_mcp_calendar_workflow( assert isinstance(upcoming_events, list), "Expected upcoming events list" # 10. Delete event via MCP - logger.info(f"Deleting event via MCP: {event_uid}") + logger.info("Deleting event via MCP: %s", event_uid) delete_result = await nc_mcp_client.call_tool( "nc_calendar_delete_event", {"calendar_name": calendar_name, "event_uid": event_uid}, @@ -800,7 +800,7 @@ async def test_mcp_calendar_workflow( pytest.fail("Event should have been deleted but was still found") except Exception: # Expected - event should be deleted - logger.info(f"Successfully verified event {event_uid} was deleted") + logger.info("Successfully verified event %s was deleted", event_uid) created_event = None # Mark as cleaned up except Exception as e: @@ -818,7 +818,7 @@ async def test_mcp_calendar_workflow( created_event["calendar_name"], created_event["uid"] ) logger.info( - f"Cleaned up event {created_event['uid']} after test failure" + "Cleaned up event %s after test failure", created_event["uid"] ) except Exception as e: - logger.warning(f"Failed to cleanup event: {e}") + logger.warning("Failed to cleanup event: %s", e) diff --git a/tests/server/test_talk_mcp.py b/tests/server/test_talk_mcp.py index 2eb52757..76930677 100644 --- a/tests/server/test_talk_mcp.py +++ b/tests/server/test_talk_mcp.py @@ -53,7 +53,7 @@ async def test_talk_send_and_read_workflow( assert posted["message"] == "Hello from MCP integration test" assert posted["token"] == token posted_id = posted["id"] - logger.info(f"Posted message id={posted_id} into token={token}") + logger.info("Posted message id=%s into token=%s", posted_id, token) # 2. Cross-check via direct client direct_messages, _ = await nc_client.talk.get_messages(token, limit=10) diff --git a/tests/server/test_webdav_search_mcp.py b/tests/server/test_webdav_search_mcp.py index 25f0900e..a494a188 100644 --- a/tests/server/test_webdav_search_mcp.py +++ b/tests/server/test_webdav_search_mcp.py @@ -53,16 +53,16 @@ async def search_test_files(nc_client: NextcloudClient): for file_path, content, content_type in test_files: await nc_client.webdav.write_file(file_path, content, content_type) - logger.info(f"Created {len(test_files)} test files in {test_dir}") + logger.info("Created %s test files in %s", len(test_files), test_dir) yield test_dir # Cleanup try: await nc_client.webdav.delete_resource(test_dir) - logger.info(f"Cleaned up test directory: {test_dir}") + logger.info("Cleaned up test directory: %s", test_dir) except Exception as e: - logger.warning(f"Failed to cleanup {test_dir}: {e}") + logger.warning("Failed to cleanup %s: %s", test_dir, e) async def test_nc_webdav_find_by_name( @@ -82,7 +82,7 @@ async def test_nc_webdav_find_by_name( content = result.content[0].text files = normalize_search_response(json.loads(content)) - logger.info(f"Found {len(files)} files matching 'search_%.txt'") + logger.info("Found %s files matching 'search_%%.txt'", len(files)) # Should find at least 3 .txt files assert len(files) >= 3, f"Expected at least 3 .txt files, got {len(files)}" @@ -113,7 +113,7 @@ async def test_nc_webdav_find_by_name_with_limit( content = result.content[0].text files = normalize_search_response(json.loads(content)) - logger.info(f"Found {len(files)} files with limit=2") + logger.info("Found %s files with limit=2", len(files)) # Should return at most 2 results assert len(files) <= 2, f"Expected at most 2 files, got {len(files)}" @@ -136,7 +136,7 @@ async def test_nc_webdav_find_by_type_images( content = result.content[0].text files = normalize_search_response(json.loads(content)) - logger.info(f"Found {len(files)} image files") + logger.info("Found %s image files", len(files)) # Should find at least 2 image files (jpg and png) assert len(files) >= 2, f"Expected at least 2 image files, got {len(files)}" @@ -165,7 +165,7 @@ async def test_nc_webdav_find_by_type_specific( content = result.content[0].text files = normalize_search_response(json.loads(content)) - logger.info(f"Found {len(files)} PDF files") + logger.info("Found %s PDF files", len(files)) # Should find at least 1 PDF assert len(files) >= 1, f"Expected at least 1 PDF file, got {len(files)}" @@ -194,7 +194,7 @@ async def test_nc_webdav_search_files_basic( content = result.content[0].text files = normalize_search_response(json.loads(content)) - logger.info(f"Found {len(files)} markdown files") + logger.info("Found %s markdown files", len(files)) # Should find at least 2 .md files assert len(files) >= 2, f"Expected at least 2 .md files, got {len(files)}" @@ -222,7 +222,7 @@ async def test_nc_webdav_search_files_combined( content = result.content[0].text files = normalize_search_response(json.loads(content)) - logger.info(f"Found {len(files)} files matching combined filters") + logger.info("Found %s files matching combined filters", len(files)) # Should find search_test1.txt and search_test2.txt assert len(files) >= 2, f"Expected at least 2 files, got {len(files)}" @@ -255,7 +255,7 @@ async def test_nc_webdav_search_files_with_limit( content = result.content[0].text files = normalize_search_response(json.loads(content)) - logger.info(f"Found {len(files)} files with limit=3") + logger.info("Found %s files with limit=3", len(files)) # Should return at most 3 results assert len(files) <= 3, f"Expected at most 3 files, got {len(files)}" @@ -318,5 +318,5 @@ async def test_search_result_properties( extended_props = ["file_id", "etag", "size", "content_type", "last_modified"] present_props = [prop for prop in extended_props if prop in file] - logger.info(f"Search result properties: {list(file.keys())}") + logger.info("Search result properties: %s", list(file.keys())) assert len(present_props) > 0, f"Should have at least one of {extended_props}" diff --git a/tests/test_models.py b/tests/test_models.py index 2157617e..dd22f4b1 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -78,7 +78,7 @@ def test_current_broken_format(): assert "+" not in current_format assert "-" not in current_format[-6:] # Check last 6 chars for timezone - logger.info(f"Current broken format: {current_format}") + logger.info("Current broken format: %s", current_format) logger.info( "This format causes MCP validation errors because it lacks timezone information" )