Merge pull request #790 from cbcoutinho/chore/lazy-logging-g004-sweep

refactor: convert f-string logging to lazy %-style (G004 sweep)
This commit is contained in:
Chris Coutinho
2026-05-13 01:45:19 +02:00
committed by GitHub
112 changed files with 2511 additions and 1857 deletions
+2 -2
View File
@@ -61,8 +61,8 @@ def get_database_url() -> str:
db_path = Path(get_token_db_path()) db_path = Path(get_token_db_path())
url = f"sqlite+aiosqlite:///{db_path}" url = f"sqlite+aiosqlite:///{db_path}"
logger.warning( logger.warning(
f"No database URL configured, using default: {url}. " "No database URL configured, using default: %s. Set sqlalchemy.url in alembic.ini or pass -x database_url=...",
"Set sqlalchemy.url in alembic.ini or pass -x database_url=..." url,
) )
return url return url
+7 -6
View File
@@ -137,7 +137,7 @@ def _sanitize_error_for_client(error: Exception, context: str = "") -> str:
Generic error message safe for client consumption Generic error message safe for client consumption
""" """
# Log detailed error for debugging # 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 generic message
return "An internal error occurred. Please contact your administrator." 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 indexed_count = count_result.count
except Exception as e: 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 # Continue with indexed_count = 0
# Determine status # Determine status
@@ -355,7 +355,7 @@ async def get_user_session(request: Request) -> JSONResponse:
# Verify token user matches requested user # Verify token user matches requested user
if token_user_id != path_user_id: if token_user_id != path_user_id:
logger.warning( 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( return JSONResponse(
{ {
@@ -442,7 +442,7 @@ async def revoke_user_access(request: Request) -> JSONResponse:
# Validate OAuth token and extract user # Validate OAuth token and extract user
token_user_id, validated = await validate_token_and_get_user(request) token_user_id, validated = await validate_token_and_get_user(request)
except Exception as e: 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( return JSONResponse(
{ {
"error": "Unauthorized", "error": "Unauthorized",
@@ -457,7 +457,7 @@ async def revoke_user_access(request: Request) -> JSONResponse:
# Verify token user matches requested user # Verify token user matches requested user
if token_user_id != path_user_id: if token_user_id != path_user_id:
logger.warning( 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( return JSONResponse(
{ {
@@ -492,7 +492,8 @@ async def revoke_user_access(request: Request) -> JSONResponse:
await token_broker.cache.invalidate(token_user_id) await token_broker.cache.invalidate(token_user_id)
logger.info( 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( return JSONResponse(
+8 -7
View File
@@ -143,7 +143,7 @@ def _extract_basic_auth(
# Verify username matches path user_id # Verify username matches path user_id
if username != path_user_id: if username != path_user_id:
logger.warning( 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 ( return (
"", "",
@@ -211,7 +211,7 @@ async def provision_app_password(request: Request) -> JSONResponse:
is_allowed, retry_after = _check_rate_limit(path_user_id) is_allowed, retry_after = _check_rate_limit(path_user_id)
if not is_allowed: if not is_allowed:
logger.warning( 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( return JSONResponse(
{ {
@@ -263,7 +263,8 @@ async def provision_app_password(request: Request) -> JSONResponse:
if response.status_code != 200: if response.status_code != 200:
logger.warning( 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) _record_rate_limit_attempt(path_user_id, success=False)
return JSONResponse( return JSONResponse(
@@ -283,7 +284,7 @@ async def provision_app_password(request: Request) -> JSONResponse:
) )
except httpx.RequestError as e: 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( return JSONResponse(
{"success": False, "error": "Failed to validate credentials"}, {"success": False, "error": "Failed to validate credentials"},
status_code=500, status_code=500,
@@ -309,7 +310,7 @@ async def provision_app_password(request: Request) -> JSONResponse:
invalidate_scope_cache(username) invalidate_scope_cache(username)
_record_rate_limit_attempt(path_user_id, success=True) _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( return JSONResponse(
{ {
@@ -409,7 +410,7 @@ async def delete_app_password(request: Request) -> JSONResponse:
status_code=401, status_code=401,
) )
except httpx.RequestError as e: except httpx.RequestError as e:
logger.error(f"Failed to validate credentials: {e}") logger.error("Failed to validate credentials: %s", e)
return JSONResponse( return JSONResponse(
{"success": False, "error": "Failed to validate credentials"}, {"success": False, "error": "Failed to validate credentials"},
status_code=500, status_code=500,
@@ -420,7 +421,7 @@ async def delete_app_password(request: Request) -> JSONResponse:
deleted = await storage.delete_app_password(username) deleted = await storage.delete_app_password(username)
if deleted: if deleted:
logger.info(f"Deleted app password for user: {username}") logger.info("Deleted app password for user: %s", username)
return JSONResponse( return JSONResponse(
{ {
"success": True, "success": True,
+19 -14
View File
@@ -90,7 +90,7 @@ async def unified_search(request: Request) -> JSONResponse:
try: try:
user_id, _validated = await validate_token_and_get_user(request) user_id, _validated = await validate_token_and_get_user(request)
except Exception as e: 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( return JSONResponse(
{ {
"error": "Unauthorized", "error": "Unauthorized",
@@ -268,12 +268,12 @@ async def unified_search(request: Request) -> JSONResponse:
) )
response_data["pca_data"] = pca_data response_data["pca_data"] = pca_data
except Exception as e: 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) return JSONResponse(response_data)
except Exception as e: except Exception as e:
logger.error(f"Error in unified search: {e}") logger.error("Error in unified search: %s", e)
return JSONResponse( return JSONResponse(
{ {
"error": "Internal error", "error": "Internal error",
@@ -311,7 +311,7 @@ async def vector_search(request: Request) -> JSONResponse:
try: try:
user_id, _validated = await validate_token_and_get_user(request) user_id, _validated = await validate_token_and_get_user(request)
except Exception as e: 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( return JSONResponse(
{ {
"error": "Unauthorized", "error": "Unauthorized",
@@ -428,7 +428,7 @@ async def vector_search(request: Request) -> JSONResponse:
if "pca_variance" in pca_data: if "pca_variance" in pca_data:
response_data["pca_variance"] = pca_data["pca_variance"] response_data["pca_variance"] = pca_data["pca_variance"]
except Exception as e: 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["coordinates_3d"] = []
response_data["query_coords"] = [] response_data["query_coords"] = []
elif include_pca: elif include_pca:
@@ -465,7 +465,7 @@ async def get_chunk_context(request: Request) -> JSONResponse:
# Validate OAuth token and extract user # Validate OAuth token and extract user
user_id, validated = await validate_token_and_get_user(request) user_id, validated = await validate_token_and_get_user(request)
except Exception as e: 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( return JSONResponse(
{ {
"error": "Unauthorized", "error": "Unauthorized",
@@ -661,14 +661,16 @@ async def get_pdf_preview(request: Request) -> JSONResponse:
# Log incoming request # Log incoming request
file_path_param = request.query_params.get("file_path", "<not provided>") file_path_param = request.query_params.get("file_path", "<not provided>")
page_param = request.query_params.get("page", "1") 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: try:
# Validate OAuth token and extract user # Validate OAuth token and extract user
user_id, validated = await validate_token_and_get_user(request) 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: 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( return JSONResponse(
{ {
"success": False, "success": False,
@@ -763,8 +765,11 @@ async def get_pdf_preview(request: Request) -> JSONResponse:
image_b64 = base64.b64encode(png_bytes).decode("ascii") image_b64 = base64.b64encode(png_bytes).decode("ascii")
logger.info( logger.info(
f"Rendered PDF preview: {file_path} page {page_num}/{total_pages}, " "Rendered PDF preview: %s page %s/%s, %s bytes",
f"{len(png_bytes):,} bytes" file_path,
page_num,
total_pages,
format(len(png_bytes), ","),
) )
return JSONResponse( return JSONResponse(
@@ -777,19 +782,19 @@ async def get_pdf_preview(request: Request) -> JSONResponse:
) )
except FileNotFoundError: except FileNotFoundError:
logger.warning(f"PDF file not found: {file_path_param}") logger.warning("PDF file not found: %s", file_path_param)
return JSONResponse( return JSONResponse(
{"success": False, "error": "PDF file not found"}, {"success": False, "error": "PDF file not found"},
status_code=404, status_code=404,
) )
except (pymupdf.FileDataError, pymupdf.EmptyFileError): 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( return JSONResponse(
{"success": False, "error": "Invalid or corrupted PDF file"}, {"success": False, "error": "Invalid or corrupted PDF file"},
status_code=400, status_code=400,
) )
except Exception as e: 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") error_msg = _sanitize_error_for_client(e, "get_pdf_preview")
return JSONResponse( return JSONResponse(
{"success": False, "error": error_msg}, {"success": False, "error": error_msg},
+112 -85
View File
@@ -169,10 +169,10 @@ def initialize_document_processors():
progress_interval=unst_config.get("progress_interval", 10), progress_interval=unst_config.get("progress_interval", 10),
) )
registry.register(processor, priority=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 registered_count += 1
except Exception as e: 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 # Register Tesseract processor
if "tesseract" in config["processors"]: if "tesseract" in config["processors"]:
@@ -187,10 +187,10 @@ def initialize_document_processors():
default_lang=tess_config["lang"], default_lang=tess_config["lang"],
) )
registry.register(processor, priority=5) 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 registered_count += 1
except Exception as e: 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) # Register PyMuPDF processor (high priority, local, no API required)
if "pymupdf" in config["processors"]: if "pymupdf" in config["processors"]:
@@ -206,11 +206,12 @@ def initialize_document_processors():
) )
registry.register(processor, priority=15) # Higher than unstructured registry.register(processor, priority=15) # Higher than unstructured
logger.info( 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 registered_count += 1
except Exception as e: 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 # Register custom processor
if "custom" in config["processors"]: if "custom" in config["processors"]:
@@ -229,16 +230,19 @@ def initialize_document_processors():
) )
registry.register(processor, priority=1) registry.register(processor, priority=1)
logger.info( 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 registered_count += 1
except Exception as e: 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: if registered_count > 0:
logger.info( logger.info(
f"Document processing initialized with {registered_count} processor(s): " "Document processing initialized with %s processor(s): %s",
f"{', '.join(registry.list_processors())}" registered_count,
", ".join(registry.list_processors()),
) )
else: else:
logger.warning("Document processing enabled but no processors registered") logger.warning("Document processing enabled but no processors registered")
@@ -409,10 +413,10 @@ class BasicAuthMiddleware:
"password": password, "password": password,
} }
logger.debug( logger.debug(
f"BasicAuth credentials extracted for user: {username}" "BasicAuth credentials extracted for user: %s", username
) )
except Exception as e: 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) await self.app(scope, receive, send)
@@ -451,7 +455,7 @@ async def load_oauth_client_credentials(
client_data = await storage.get_oauth_client() client_data = await storage.get_oauth_client()
if client_data: if client_data:
logger.info( 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"]) return (client_data["client_id"], client_data["client_secret"])
except ValueError: except ValueError:
@@ -496,7 +500,7 @@ async def load_oauth_client_credentials(
dcr_scopes = f"{dcr_scopes} offline_access" dcr_scopes = f"{dcr_scopes} offline_access"
logger.info("✓ offline_access scope enabled for refresh tokens") 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) # Get token type from environment (Bearer or jwt)
# Note: Must be lowercase "jwt" to match OIDC app's check # 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 # Special case: "bearer" should remain capitalized for compatibility
if token_type != "jwt": if token_type != "jwt":
token_type = "Bearer" 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 # Ensure OAuth client in SQLite storage
storage = RefreshTokenStorage.from_env() storage = RefreshTokenStorage.from_env()
@@ -525,7 +529,7 @@ async def load_oauth_client_credentials(
resource_url=resource_url, # RFC 9728 Protected Resource URL 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) return (client_info.client_id, client_info.client_secret)
# No credentials available # 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 is_multi_user = settings.enable_multi_user_basic_auth
logger.info( 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 # Only create shared client for single-user mode
@@ -633,7 +638,7 @@ async def setup_oauth_config():
discovery_url = os.getenv( discovery_url = os.getenv(
"OIDC_DISCOVERY_URL", f"{nextcloud_host}/.well-known/openid-configuration" "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 # Perform OIDC discovery
async with nextcloud_httpx_client(follow_redirects=True) as client: 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") registration_endpoint = discovery.get("registration_endpoint")
logger.info("OIDC endpoints discovered:") logger.info("OIDC endpoints discovered:")
logger.info(f" Issuer: {issuer}") logger.info(" Issuer: %s", issuer)
logger.info(f" Userinfo: {userinfo_uri}") logger.info(" Userinfo: %s", userinfo_uri)
if jwks_uri: if jwks_uri:
logger.info(f" JWKS: {jwks_uri}") logger.info(" JWKS: %s", jwks_uri)
if introspection_uri: if introspection_uri:
logger.info(f" Introspection: {introspection_uri}") logger.info(" Introspection: %s", introspection_uri)
# Auto-detect provider mode based on issuer # Auto-detect provider mode based on issuer
# External IdP mode: issuer doesn't match Nextcloud host # External IdP mode: issuer doesn't match Nextcloud host
@@ -685,7 +690,9 @@ async def setup_oauth_config():
if is_external_idp: if is_external_idp:
oauth_provider = "external" # Could be Keycloak, Auth0, Okta, etc. oauth_provider = "external" # Could be Keycloak, Auth0, Okta, etc.
logger.info( 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") logger.info(" Tokens will be validated via Nextcloud user_oidc app")
else: else:
@@ -716,7 +723,7 @@ async def setup_oauth_config():
"✓ Refresh token storage initialized (offline_access enabled)" "✓ Refresh token storage initialized (offline_access enabled)"
) )
except Exception as e: 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( logger.warning(
"Continuing without refresh token storage - users will need to re-authenticate after token expiration" "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") client_secret = os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET")
if client_id and 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: elif registration_endpoint:
logger.info( logger.info(
"NEXTCLOUD_OIDC_CLIENT_ID not set, attempting Dynamic Client Registration" "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) # Warn if resource URIs are not configured (required for ADR-005 compliance)
if not os.getenv("NEXTCLOUD_MCP_SERVER_URL"): if not os.getenv("NEXTCLOUD_MCP_SERVER_URL"):
logger.warning( logger.warning(
f"NEXTCLOUD_MCP_SERVER_URL not set, defaulting to: {mcp_server_url}. " "NEXTCLOUD_MCP_SERVER_URL not set, defaulting to: %s. This should be set explicitly for proper audience validation.",
"This should be set explicitly for proper audience validation." mcp_server_url,
) )
if not os.getenv("NEXTCLOUD_RESOURCE_URI"): if not os.getenv("NEXTCLOUD_RESOURCE_URI"):
logger.warning( logger.warning(
f"NEXTCLOUD_RESOURCE_URI not set, defaulting to: {nextcloud_resource_uri}. " "NEXTCLOUD_RESOURCE_URI not set, defaulting to: %s. This should be set explicitly for proper audience validation.",
"This should be set explicitly for proper audience validation." nextcloud_resource_uri,
) )
# Create settings for UnifiedTokenVerifier (use same settings instance from start of function) # Create settings for UnifiedTokenVerifier (use same settings instance from start of function)
@@ -790,8 +797,8 @@ async def setup_oauth_config():
logger.info( logger.info(
"✓ Multi-audience mode enabled (ADR-005) - tokens must contain both MCP and Nextcloud audiences" "✓ 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(" Required MCP audience: %s or %s", client_id, mcp_server_url)
logger.info(f" Required Nextcloud audience: {nextcloud_resource_uri}") logger.info(" Required Nextcloud audience: %s", nextcloud_resource_uri)
if introspection_uri: if introspection_uri:
logger.info("✓ Opaque token introspection enabled (RFC 7662)") 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", f"{nextcloud_host}/.well-known/openid-configuration",
) )
logger.info( 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 # Perform OIDC discovery
@@ -894,20 +902,22 @@ async def setup_oauth_config_for_multi_user_basic(
discovery = response.json() discovery = response.json()
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
logger.error( 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( raise ValueError(
f"OIDC discovery failed: HTTP {e.response.status_code} from {discovery_url}. " f"OIDC discovery failed: HTTP {e.response.status_code} from {discovery_url}. "
"Ensure Nextcloud OIDC (user_oidc app) is installed and configured." "Ensure Nextcloud OIDC (user_oidc app) is installed and configured."
) from e ) from e
except httpx.RequestError as e: except httpx.RequestError as e:
logger.error(f"OIDC discovery failed: {e}") logger.error("OIDC discovery failed: %s", e)
raise ValueError( raise ValueError(
f"OIDC discovery failed: Cannot connect to {discovery_url}. Error: {e}" f"OIDC discovery failed: Cannot connect to {discovery_url}. Error: {e}"
) from e ) from e
except (KeyError, ValueError) as e: except (KeyError, ValueError) as e:
logger.error( 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( raise ValueError(
f"OIDC discovery failed: Invalid response from {discovery_url}. " 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") introspection_uri = discovery.get("introspection_endpoint")
logger.info("OIDC endpoints configured for management API:") logger.info("OIDC endpoints configured for management API:")
logger.info(f" Issuer: {issuer}") logger.info(" Issuer: %s", issuer)
logger.info(f" Userinfo: {userinfo_uri}") logger.info(" Userinfo: %s", userinfo_uri)
logger.info(f" JWKS: {jwks_uri}") logger.info(" JWKS: %s", jwks_uri)
logger.info(f" Introspection: {introspection_uri}") logger.info(" Introspection: %s", introspection_uri)
# Get MCP server URL for audience validation # Get MCP server URL for audience validation
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000") 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)" "✓ Refresh token storage initialized for background operations (hybrid mode)"
) )
except Exception as e: 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.debug(f"Full traceback:\n{traceback.format_exc()}") logger.debug("Full traceback:\\n%s", traceback.format_exc())
logger.warning( logger.warning(
"Continuing without refresh token storage - webhook management may be limited" "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) logger.error(error_msg)
raise ValueError(error_msg) raise ValueError(error_msg)
logger.info(f"✅ Configuration validated successfully for {mode.value} mode") logger.info("✅ Configuration validated successfully for %s mode", mode.value)
logger.debug(f"Mode details:\n{get_mode_summary(mode)}") logger.debug("Mode details:\\n%s", get_mode_summary(mode))
# Derive helper variables for backward compatibility with existing code. # Derive helper variables for backward compatibility with existing code.
# `oauth_enabled` is True for the LOGIN_FLOW (formerly OAUTH_SINGLE_AUDIENCE) # `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: if settings.metrics_enabled:
setup_metrics(port=settings.metrics_port) setup_metrics(port=settings.metrics_port)
logger.info( 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) # 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, sampling_rate=settings.otel_traces_sampler_arg,
) )
logger.info( logger.info(
f"OpenTelemetry tracing enabled (endpoint: {settings.otel_exporter_otlp_endpoint})" "OpenTelemetry tracing enabled (endpoint: %s)",
settings.otel_exporter_otlp_endpoint,
) )
else: else:
logger.info( 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 # This avoids relying on discovery doc which may use public URLs unreachable from containers
registration_endpoint = f"{settings.nextcloud_host}/apps/oidc/register" registration_endpoint = f"{settings.nextcloud_host}/apps/oidc/register"
logger.info( logger.info(
f"Attempting Dynamic Client Registration at: {registration_endpoint}" "Attempting Dynamic Client Registration at: %s",
registration_endpoint,
) )
# Perform DCR # Perform DCR
@@ -1108,13 +1120,13 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
registration_endpoint=registration_endpoint, registration_endpoint=registration_endpoint,
) )
logger.info( logger.info(
f"✓ Dynamic Client Registration successful for background operations " "✓ Dynamic Client Registration successful for background operations (client_id: %s...)",
f"(client_id: {client_id[:16]}...)" client_id[:16],
) )
return (client_id, client_secret) return (client_id, client_secret)
except Exception as e: except Exception as e:
logger.error(f"Dynamic Client Registration failed: {e}") logger.error("Dynamic Client Registration failed: %s", e)
logger.debug(f"Full traceback:\n{traceback.format_exc()}") logger.debug("Full traceback:\\n%s", traceback.format_exc())
logger.warning("Background vector sync will be disabled.") logger.warning("Background vector sync will be disabled.")
return None 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 # - httpx.HTTPError: Network issues, OIDC discovery failures
# - ValueError: Missing required configuration (NEXTCLOUD_HOST) # - ValueError: Missing required configuration (NEXTCLOUD_HOST)
# - KeyError: Missing required fields in OIDC discovery response # - KeyError: Missing required fields in OIDC discovery response
logger.error(f"Failed to setup OAuth infrastructure: {e}") logger.error("Failed to setup OAuth infrastructure: %s", e)
logger.debug(f"Full traceback:\n{traceback.format_exc()}") logger.debug("Full traceback:\\n%s", traceback.format_exc())
logger.warning( logger.warning(
"Management API will be unavailable. " "Management API will be unavailable. "
"Webhook management from Astrolabe admin UI will not work." "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: except Exception as e:
# Unexpected error - this is a programming error, re-raise it # Unexpected error - this is a programming error, re-raise it
logger.error( logger.error(
f"Unexpected error during OAuth infrastructure setup: {e}. " "Unexpected error during OAuth infrastructure setup: %s. This is likely a programming error that should be fixed.",
"This is likely a programming error that should be fixed." e,
) )
raise 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. Lifespan context for OAuth mode - captures OAuth configuration from outer scope.
""" """
logger.info("Starting MCP server in OAuth mode") 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: if refresh_token_storage:
logger.info("Refresh token storage is available") logger.info("Refresh token storage is available")
if oauth_client: if oauth_client:
@@ -1223,7 +1235,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
try: try:
await oauth_client.close() await oauth_client.close()
except Exception as e: 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") logger.info("MCP server shutdown complete")
mcp = FastMCP( mcp = FastMCP(
@@ -1239,7 +1251,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
) )
else: else:
# BasicAuth modes (single-user or multi-user) # 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( mcp = FastMCP(
"Nextcloud MCP", "Nextcloud MCP",
lifespan=app_lifespan_basic, 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 # Configure only the enabled apps
for app_name in enabled_apps: for app_name in enabled_apps:
if app_name in AVAILABLE_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) AVAILABLE_APPS[app_name](mcp)
else: else:
logger.warning( 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) # 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() user_scopes = get_access_token_scopes()
is_jwt = is_jwt_token() is_jwt = is_jwt_token()
logger.info( logger.info(
f"🔍 list_tools called - Token type: {'JWT' if is_jwt else 'opaque/none'}, " "🔍 list_tools called - Token type: %s, User scopes: %s",
f"User scopes: {user_scopes}" "JWT" if is_jwt else "opaque/none",
user_scopes,
) )
# Get all tools # 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" token_type = "JWT" if is_jwt else "Bearer"
logger.info( logger.info(
f"✂️ {token_type} scope filtering: {len(allowed_tools)}/{len(all_tools)} tools " "✂️ %s scope filtering: %s/%s tools available for scopes: %s",
f"available for scopes: {user_scopes}" token_type,
len(allowed_tools),
len(all_tools),
user_scopes,
) )
else: else:
# BasicAuth mode or no token - show all tools # BasicAuth mode or no token - show all tools
allowed_tools = all_tools allowed_tools = all_tools
logger.info( 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) # 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 break
logger.info( 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: else:
# BasicAuth mode - initialize storage for webhook management # 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 app.state.oauth_context = oauth_context_dict
logger.info( 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: elif multi_user_basic_oauth_creds and multi_user_token_verifier is None:
logger.warning( 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 await get_qdrant_client() # Triggers collection creation if needed
logger.info("Qdrant collection ready") logger.info("Qdrant collection ready")
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize Qdrant collection: {e}") logger.error("Failed to initialize Qdrant collection: %s", e)
raise RuntimeError( raise RuntimeError(
f"Cannot start vector sync - Qdrant initialization failed: {e}" f"Cannot start vector sync - Qdrant initialization failed: {e}"
) from 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 _vector_sync_state.eviction_task_group = tg
logger.info( logger.info(
f"Background sync tasks started: 1 scanner + " "Background sync tasks started: 1 scanner + %s processors",
f"{settings.vector_sync_processor_workers} processors" settings.vector_sync_processor_workers,
) )
# Run MCP session manager and yield # 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 # 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) # 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" 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) # Get nextcloud_host (from settings - already validated)
nextcloud_host_for_sync = settings.nextcloud_host 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 await get_qdrant_client() # Triggers collection creation if needed
logger.info("Qdrant collection ready") logger.info("Qdrant collection ready")
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize Qdrant collection: {e}") logger.error("Failed to initialize Qdrant collection: %s", e)
raise RuntimeError( raise RuntimeError(
f"Cannot start vector sync - Qdrant initialization failed: {e}" f"Cannot start vector sync - Qdrant initialization failed: {e}"
) from e ) from e
@@ -1748,10 +1768,12 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
) )
if removed: if removed:
logger.info( 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: 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 # Initialize shared state
send_stream, receive_stream = anyio.create_memory_object_stream( send_stream, receive_stream = anyio.create_memory_object_stream(
@@ -1827,8 +1849,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
_vector_sync_state.eviction_task_group = tg _vector_sync_state.eviction_task_group = tg
logger.info( logger.info(
f"Background sync tasks started: 1 user manager + " "Background sync tasks started: 1 user manager + %s processors",
f"{settings.vector_sync_processor_workers} processors" settings.vector_sync_processor_workers,
) )
# Run MCP session manager and yield # Run MCP session manager and yield
@@ -2135,8 +2157,10 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
if oauth_provisioning_available: if oauth_provisioning_available:
logger.info( logger.info(
f"OAuth provisioning routes enabled for mode: {mode.value} " "OAuth provisioning routes enabled for mode: %s (oauth_enabled=%s, hybrid_mode=%s)",
f"(oauth_enabled={oauth_enabled}, hybrid_mode={not oauth_enabled})" mode.value,
oauth_enabled,
not oauth_enabled,
) )
def oauth_protected_resource_metadata(request): def oauth_protected_resource_metadata(request):
@@ -2328,7 +2352,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
browser_routes.append( browser_routes.append(
Mount("/static", StaticFiles(directory=static_dir), name="static") 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 = Starlette(routes=browser_routes)
browser_app.add_middleware( browser_app.add_middleware(
@@ -2379,13 +2403,14 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
token_preview = ( token_preview = (
auth_header[:50] + "..." if len(auth_header) > 50 else auth_header 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: else:
# Only warn about missing Authorization in OAuth mode # Only warn about missing Authorization in OAuth mode
# In BasicAuth mode, /mcp requests without Authorization are expected # In BasicAuth mode, /mcp requests without Authorization are expected
if oauth_enabled: if oauth_enabled:
logger.warning( 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 # Log client capabilities on initialize request
@@ -2402,8 +2427,9 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
client_info = params.get("clientInfo", {}) client_info = params.get("clientInfo", {})
logger.info( logger.info(
f"🔌 MCP client connected: {client_info.get('name', 'unknown')} " "🔌 MCP client connected: %s v%s",
f"v{client_info.get('version', 'unknown')}" client_info.get("name", "unknown"),
client_info.get("version", "unknown"),
) )
# Log capabilities in a structured way # Log capabilities in a structured way
@@ -2419,16 +2445,17 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
) )
logger.info( 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 # Log full capabilities at INFO level to diagnose capability issues
logger.info( logger.info(
f"Full capabilities JSON: {json.dumps(capabilities)}" "Full capabilities JSON: %s", json.dumps(capabilities)
) )
except Exception as e: except Exception as e:
# Don't fail the request if logging fails # Don't fail the request if logging fails
logger.debug( 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) response = await call_next(request)
@@ -61,12 +61,12 @@ class AstrolabeClient:
discovery_url = f"{self.nextcloud_host}/.well-known/openid-configuration" discovery_url = f"{self.nextcloud_host}/.well-known/openid-configuration"
async with nextcloud_httpx_client() as client: 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 = await client.get(discovery_url)
discovery_resp.raise_for_status() discovery_resp.raise_for_status()
token_endpoint = discovery_resp.json()["token_endpoint"] 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 # Request token using client credentials grant
token_resp = await client.post( token_resp = await client.post(
@@ -88,7 +88,7 @@ class AstrolabeClient:
"expires_at": time.time() + expires_in - 60, "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"] return data["access_token"]
async def get_user_app_password(self, user_id: str) -> Optional[str]: 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}" url = f"{self.nextcloud_host}/apps/astrolabe/api/v1/background-sync/credentials/{user_id}"
async with nextcloud_httpx_client() as client: 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( response = await client.get(
url, url,
@@ -117,14 +117,16 @@ class AstrolabeClient:
) )
if response.status_code == 404: 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 return None
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
logger.info( 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") return data.get("app_password")
@@ -133,8 +133,8 @@ async def register_client(
if resource_url: if resource_url:
client_metadata["resource_url"] = resource_url client_metadata["resource_url"] = resource_url
logger.info(f"Registering OAuth client with Nextcloud: {client_name}") logger.info("Registering OAuth client with Nextcloud: %s", client_name)
logger.debug(f"Registration endpoint: {registration_endpoint}") logger.debug("Registration endpoint: %s", registration_endpoint)
async with nextcloud_httpx_client(timeout=30.0) as client: async with nextcloud_httpx_client(timeout=30.0) as client:
for attempt in range(max_retries): for attempt in range(max_retries):
@@ -151,14 +151,17 @@ async def register_client(
retry_after = int(response.headers.get("Retry-After", 2)) retry_after = int(response.headers.get("Retry-After", 2))
wait_time = min(retry_after, 2**attempt) wait_time = min(retry_after, 2**attempt)
logger.warning( logger.warning(
f"Rate limited (429) registering client, " "Rate limited (429) registering client, retrying in %ss (attempt %s/%s)",
f"retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})" wait_time,
attempt + 1,
max_retries,
) )
await anyio.sleep(wait_time) await anyio.sleep(wait_time)
continue continue
else: else:
logger.error( 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() response.raise_for_status()
@@ -166,14 +169,15 @@ async def register_client(
client_info = response.json() client_info = response.json()
logger.info( 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( expires_at = dt.datetime.fromtimestamp(
client_info.get("client_secret_expires_at") client_info.get("client_secret_expires_at")
) )
logger.info( logger.info(
f"Client expires at: {expires_at} " "Client expires at: %s (in %s seconds)",
f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)" expires_at,
client_info.get("client_secret_expires_at", 0) - int(time.time()),
) )
# Log if RFC 7592 fields are present # Log if RFC 7592 fields are present
@@ -206,13 +210,13 @@ async def register_client(
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
logger.error( 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 raise
except KeyError as e: except KeyError as e:
logger.error( 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}") raise ValueError(f"Invalid registration response: missing {e}")
@@ -264,8 +268,8 @@ async def delete_client(
else: else:
deletion_endpoint = f"{nextcloud_url}/apps/oidc/register/{client_id}" deletion_endpoint = f"{nextcloud_url}/apps/oidc/register/{client_id}"
logger.info(f"Deleting OAuth client: {client_id[:16]}...") logger.info("Deleting OAuth client: %s...", client_id[:16])
logger.debug(f"Deletion endpoint: {deletion_endpoint}") logger.debug("Deletion endpoint: %s", deletion_endpoint)
async with nextcloud_httpx_client(timeout=30.0) as http_client: async with nextcloud_httpx_client(timeout=30.0) as http_client:
for attempt in range(max_retries): for attempt in range(max_retries):
@@ -296,7 +300,7 @@ async def delete_client(
# RFC 7592: Successful deletion returns 204 No Content # RFC 7592: Successful deletion returns 204 No Content
if response.status_code == 204: if response.status_code == 204:
logger.info( logger.info(
f"Successfully deleted OAuth client: {client_id[:16]}..." "Successfully deleted OAuth client: %s...", client_id[:16]
) )
return True return True
elif response.status_code == 429: elif response.status_code == 429:
@@ -307,42 +311,53 @@ async def delete_client(
retry_after, 2**attempt retry_after, 2**attempt
) # Exponential backoff, max from header ) # Exponential backoff, max from header
logger.warning( logger.warning(
f"Rate limited (429) deleting client {client_id[:16]}..., " "Rate limited (429) deleting client %s..., retrying in %ss (attempt %s/%s)",
f"retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})" client_id[:16],
wait_time,
attempt + 1,
max_retries,
) )
await anyio.sleep(wait_time) await anyio.sleep(wait_time)
continue continue
else: else:
logger.error( 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 return False
elif response.status_code == 401: elif response.status_code == 401:
logger.error( 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 return False
elif response.status_code == 403: elif response.status_code == 403:
logger.error( 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 return False
else: else:
logger.error( 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 return False
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
logger.error( 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 return False
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Unexpected error deleting client {client_id[:16]}...: {e}" "Unexpected error deleting client %s...: %s", client_id[:16], e
) )
return False return False
@@ -390,14 +405,14 @@ async def ensure_oauth_client(
client_data = await storage.get_oauth_client() client_data = await storage.get_oauth_client()
if client_data: if client_data:
logger.info( 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) return ClientInfo.from_dict(client_data)
# Register new client # Register new client
logger.info("Registering new OAuth client...") logger.info("Registering new OAuth client...")
if resource_url: if resource_url:
logger.info(f" with resource_url: {resource_url}") logger.info(" with resource_url: %s", resource_url)
client_info = await register_client( client_info = await register_client(
nextcloud_url=nextcloud_url, nextcloud_url=nextcloud_url,
registration_endpoint=registration_endpoint, registration_endpoint=registration_endpoint,
+14 -12
View File
@@ -89,7 +89,7 @@ class ClientRegistry:
if not cid or not redirect: if not cid or not redirect:
logger.warning( logger.warning(
f"Skipping malformed ALLOWED_MCP_CLIENTS entry: {entry!r}" "Skipping malformed ALLOWED_MCP_CLIENTS entry: %r", entry
) )
continue continue
@@ -97,8 +97,9 @@ class ClientRegistry:
hostname = parsed.hostname hostname = parsed.hostname
if hostname is None: if hostname is None:
logger.warning( logger.warning(
f"Skipping client {cid!r}: cannot parse hostname " "Skipping client %r: cannot parse hostname from %r",
f"from {redirect!r}" cid,
redirect,
) )
continue continue
is_loopback = hostname in ("localhost", "127.0.0.1", "::1") is_loopback = hostname in ("localhost", "127.0.0.1", "::1")
@@ -108,8 +109,9 @@ class ClientRegistry:
or (parsed.scheme == "http" and is_loopback) or (parsed.scheme == "http" and is_loopback)
): ):
logger.warning( logger.warning(
f"Rejecting client {cid!r}: HTTP redirect URIs are only " "Rejecting client %r: HTTP redirect URIs are only allowed for localhost, got %r",
f"allowed for localhost, got {redirect!r}" cid,
redirect,
) )
continue continue
@@ -120,7 +122,7 @@ class ClientRegistry:
allowed_scopes=["*"], allowed_scopes=["*"],
is_public=True, is_public=True,
) )
logger.info(f"Registered static client: {cid}") logger.info("Registered static client: %s", cid)
else: else:
self._clients[entry] = MCPClientInfo( self._clients[entry] = MCPClientInfo(
client_id=entry, client_id=entry,
@@ -129,7 +131,7 @@ class ClientRegistry:
allowed_scopes=["*"], allowed_scopes=["*"],
is_public=True, is_public=True,
) )
logger.info(f"Registered static client: {entry}") logger.info("Registered static client: %s", entry)
if not self._clients: if not self._clients:
logger.warning( logger.warning(
@@ -170,7 +172,7 @@ class ClientRegistry:
if not client: if not client:
if self.allow_dynamic_registration: if self.allow_dynamic_registration:
# In production, would attempt DCR here # 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 return True, None
else: else:
return False, f"Unknown client_id: {client_id}" return False, f"Unknown client_id: {client_id}"
@@ -229,15 +231,15 @@ class ClientRegistry:
True if registered successfully True if registered successfully
""" """
if not self.allow_dynamic_registration: 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 return False
if client_info.client_id in self._clients: 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 return False
self._clients[client_info.client_id] = client_info 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 # In production, would persist to database
return True return True
@@ -263,7 +265,7 @@ class ClientRegistry:
allowed_scopes=["*"], # Nextcloud enforces actual scopes allowed_scopes=["*"], # Nextcloud enforces actual scopes
is_public=True, 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]: def get_client(self, client_id: str) -> Optional[MCPClientInfo]:
""" """
+3 -3
View File
@@ -54,8 +54,8 @@ def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient:
raise ValueError("Username not available in OAuth token context") raise ValueError("Username not available in OAuth token context")
logger.debug( logger.debug(
f"Creating NextcloudClient for user {username} with multi-audience token " "Creating NextcloudClient for user %s with multi-audience token (no exchange needed)",
f"(no exchange needed)" username,
) )
# Token was validated to have MCP audience # 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: 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") logger.error("This may indicate the server is not running in OAuth mode")
raise raise
+6 -5
View File
@@ -129,7 +129,7 @@ class LoginFlowV2Client:
f"Malformed Login Flow v2 initiate response from Nextcloud (missing key: {e})" f"Malformed Login Flow v2 initiate response from Nextcloud (missing key: {e})"
) from 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 return result
def _rewrite_to_nextcloud_host(self, url: str) -> str: def _rewrite_to_nextcloud_host(self, url: str) -> str:
@@ -142,7 +142,7 @@ class LoginFlowV2Client:
""" """
result = rewrite_url_origin(url, self.nextcloud_host) result = rewrite_url_origin(url, self.nextcloud_host)
if result != url: 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 return result
async def poll(self, poll_endpoint: str, poll_token: str) -> LoginFlowPollResult: async def poll(self, poll_endpoint: str, poll_token: str) -> LoginFlowPollResult:
@@ -172,8 +172,9 @@ class LoginFlowV2Client:
if response.status_code == 200: if response.status_code == 200:
data = response.json() data = response.json()
logger.info( logger.info(
f"Login Flow v2 completed: server={data.get('server')}, " "Login Flow v2 completed: server=%s, loginName=%s",
f"loginName={data.get('loginName')}" data.get("server"),
data.get("loginName"),
) )
try: try:
return LoginFlowPollResult( return LoginFlowPollResult(
@@ -193,6 +194,6 @@ class LoginFlowV2Client:
# Any other status indicates the flow has expired or is invalid # Any other status indicates the flow has expired or is invalid
logger.warning( 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") return LoginFlowPollResult(status="expired")
+11 -7
View File
@@ -359,7 +359,8 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
if auth_parsed.query: if auth_parsed.query:
authorization_endpoint += f"?{auth_parsed.query}" authorization_endpoint += f"?{auth_parsed.query}"
logger.info( 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. # 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: if response.status_code != 200:
logger.error( 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( params = urlencode(
{ {
@@ -968,8 +969,8 @@ async def _oauth_callback_as_proxy(
nc_token_response = response.json() nc_token_response = response.json()
logger.info( logger.info(
"AS proxy: Successfully exchanged code for Nextcloud token " "AS proxy: Successfully exchanged code for Nextcloud token (token_type=%s)",
f"(token_type={nc_token_response.get('token_type')})" nc_token_response.get("token_type"),
) )
# Verify the ID token signature + claims before caching the response # 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}" redirect_url = f"{session.client_redirect_uri}?{redirect_params}"
logger.info( 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) return RedirectResponse(redirect_url, status_code=302)
@@ -1294,7 +1296,7 @@ async def _token_refresh(request: Request, form) -> JSONResponse:
if response.status_code != 200: if response.status_code != 200:
logger.error( 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( return JSONResponse(
{ {
@@ -1396,7 +1398,9 @@ async def oauth_register_proxy(request: Request) -> JSONResponse:
if response.status_code not in (200, 201): if response.status_code not in (200, 201):
logger.error( 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( return JSONResponse(
response.json() response.json()
+5 -2
View File
@@ -44,11 +44,14 @@ async def is_nextcloud_admin(request: Request, http_client: AsyncClient) -> bool
# Check if user is in the admin group # Check if user is in the admin group
is_admin = "admin" in user_groups is_admin = "admin" in user_groups
logger.debug( 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 return is_admin
except Exception as e: 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 return False
+16 -13
View File
@@ -90,7 +90,7 @@ async def _poll_and_store(provision_id: str) -> None:
result = await flow_client.poll(poll_endpoint, poll_token) result = await flow_client.poll(poll_endpoint, poll_token)
except Exception as e: except Exception as e:
logger.warning( 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) await anyio.sleep(2)
continue continue
@@ -106,7 +106,8 @@ async def _poll_and_store(provision_id: str) -> None:
if session: if session:
session["status"] = "error" session["status"] = "error"
logger.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 return
await storage.store_app_password_with_scopes( await storage.store_app_password_with_scopes(
@@ -121,8 +122,9 @@ async def _poll_and_store(provision_id: str) -> None:
session["status"] = "completed" session["status"] = "completed"
session["username"] = result.login_name session["username"] = result.login_name
logger.info( logger.info(
f"Login Flow v2 web provision completed for user {effective_user_id} " "Login Flow v2 web provision completed for user %s (provision_id=%s)",
f"(provision_id={provision_id})" effective_user_id,
provision_id,
) )
return return
@@ -131,7 +133,7 @@ async def _poll_and_store(provision_id: str) -> None:
if session: if session:
session["status"] = "expired" session["status"] = "expired"
logger.warning( 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 return
@@ -142,7 +144,7 @@ async def _poll_and_store(provision_id: str) -> None:
if session: if session:
session["status"] = "expired" session["status"] = "expired"
logger.warning( 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: try:
user_id, _token_data = await validate_token_and_get_user(request) user_id, _token_data = await validate_token_and_get_user(request)
except (ValueError, KeyError, AttributeError) as e: 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) return JSONResponse({"error": "Authentication required"}, status_code=401)
_cleanup_expired_sessions() _cleanup_expired_sessions()
@@ -180,14 +182,14 @@ async def provision_page(
) )
if urlparse(redirect_uri).scheme == "http": 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 # Check if user already has an app password — skip straight to redirect
if user_id: if user_id:
storage = await get_shared_storage() storage = await get_shared_storage()
existing = await storage.get_app_password_with_scopes(user_id) existing = await storage.get_app_password_with_scopes(user_id)
if existing: 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) return RedirectResponse(redirect_uri)
# Initiate Login Flow v2 # Initiate Login Flow v2
@@ -206,7 +208,7 @@ async def provision_page(
) )
init_response = await flow_client.initiate() init_response = await flow_client.initiate()
except Exception as e: 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( return HTMLResponse(
content=_render_error( content=_render_error(
"Failed to start login flow. Please try again later." "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) poll_tg.start_soon(_poll_and_store, provision_id)
logger.info( logger.info(
f"Login Flow v2 web provision initiated (provision_id={provision_id}, " "Login Flow v2 web provision initiated (provision_id=%s, user_id=%s), redirecting to NC login",
f"user_id={user_id or 'unknown'}), redirecting to NC login" provision_id,
user_id or "unknown",
) )
# Redirect to Nextcloud's Login Flow v2 login page. # Redirect to Nextcloud's Login Flow v2 login page.
@@ -277,7 +280,7 @@ async def provision_status(request: Request) -> JSONResponse:
try: try:
_user_id, _token_data = await validate_token_and_get_user(request) _user_id, _token_data = await validate_token_and_get_user(request)
except (ValueError, KeyError, AttributeError) as e: 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) return JSONResponse({"error": "Authentication required"}, status_code=401)
provision_id = request.query_params.get("id", "") provision_id = request.query_params.get("id", "")
@@ -89,7 +89,7 @@ def require_provisioning(func: Callable) -> Callable:
if not refresh_data: if not refresh_data:
# User has not completed Flow 2 - provide helpful error # User has not completed Flow 2 - provide helpful error
logger.info( 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( raise McpError(
ErrorData( ErrorData(
@@ -104,7 +104,7 @@ def require_provisioning(func: Callable) -> Callable:
) )
logger.debug( 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 # User has provisioned - allow access
@@ -156,15 +156,14 @@ def require_provisioning_or_suggest(func: Callable) -> Callable:
if not refresh_data: if not refresh_data:
logger.info( logger.info(
f"User {user_id} has not provisioned Nextcloud access. " "User %s has not provisioned Nextcloud access. Some features may not work. Consider running 'provision_nextcloud_access' tool.",
"Some features may not work. Consider running " user_id,
"'provision_nextcloud_access' tool."
) )
else: else:
logger.debug(f"User {user_id} has provisioned access") logger.debug("User %s has provisioned access", user_id)
except Exception as e: 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 # Always proceed with the function
return await func(*args, **kwargs) return await func(*args, **kwargs)
@@ -119,7 +119,7 @@ def require_scopes(*required_scopes: str):
# No context parameter found - likely BasicAuth mode # No context parameter found - likely BasicAuth mode
# In BasicAuth mode, all operations are allowed # In BasicAuth mode, all operations are allowed
logger.debug( 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) return await func(*args, **kwargs)
@@ -131,7 +131,7 @@ def require_scopes(*required_scopes: str):
if access_token is None: if access_token is None:
# No OAuth token — BasicAuth mode bypasses scope checks # No OAuth token — BasicAuth mode bypasses scope checks
logger.debug( 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) return await func(*args, **kwargs)
@@ -210,7 +210,8 @@ def require_scopes(*required_scopes: str):
if stored_scopes == "all": if stored_scopes == "all":
# NULL scopes in DB = legacy app password = all allowed # NULL scopes in DB = legacy app password = all allowed
logger.debug( 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) return await func(*args, **kwargs)
@@ -227,7 +228,7 @@ def require_scopes(*required_scopes: str):
raise InsufficientScopeError(list(missing), error_msg) raise InsufficientScopeError(list(missing), error_msg)
logger.debug( 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) return await func(*args, **kwargs)
@@ -303,7 +304,7 @@ def require_scopes(*required_scopes: str):
# All required scopes present - allow execution # All required scopes present - allow execution
logger.debug( 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) 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 = set(access_token.scopes or [])
scopes = _strip_resource_prefix(scopes) 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 return scopes
@@ -446,7 +447,7 @@ def is_jwt_token() -> bool:
token_string = access_token.token token_string = access_token.token
is_jwt = "." in token_string and token_string.count(".") == 2 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 return is_jwt
+68 -51
View File
@@ -184,12 +184,12 @@ class RefreshTokenStorage:
if has_schema: if has_schema:
logger.info( logger.info(
f"Detected pre-Alembic database at {self.db_path}, " "Detected pre-Alembic database at %s, stamping with initial revision",
"stamping with initial revision" self.db_path,
) )
else: else:
logger.info( 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 # Run migrations in a worker thread using anyio.to_thread
@@ -215,7 +215,7 @@ class RefreshTokenStorage:
os.chmod(self.db_path, 0o600) os.chmod(self.db_path, 0o600)
self._initialized = True 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( async def store_refresh_token(
self, self,
@@ -333,7 +333,7 @@ class RefreshTokenStorage:
) )
await db.commit() 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: async def get_user_profile(self, user_id: str) -> dict[str, Any] | None:
""" """
@@ -419,7 +419,7 @@ class RefreshTokenStorage:
row = await cursor.fetchone() row = await cursor.fetchone()
if not row: 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 duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success") record_db_operation("sqlite", "select", duration, "success")
return None return None
@@ -437,7 +437,9 @@ class RefreshTokenStorage:
# Check expiration # Check expiration
if expires_at is not None and expires_at < time.time(): if expires_at is not None and expires_at < time.time():
logger.warning( 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) await self.delete_refresh_token(user_id)
duration = time.time() - start_time duration = time.time() - start_time
@@ -448,7 +450,9 @@ class RefreshTokenStorage:
scopes = json.loads(scopes_json) if scopes_json else None scopes = json.loads(scopes_json) if scopes_json else None
logger.debug( 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 duration = time.time() - start_time
@@ -467,7 +471,7 @@ class RefreshTokenStorage:
except Exception as e: except Exception as e:
duration = time.time() - start_time duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "error") 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 return None
async def get_refresh_token_by_provisioning_client_id( async def get_refresh_token_by_provisioning_client_id(
@@ -511,7 +515,8 @@ class RefreshTokenStorage:
if not row: if not row:
logger.debug( 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 return None
@@ -529,7 +534,8 @@ class RefreshTokenStorage:
# Check expiration # Check expiration
if expires_at is not None and expires_at < time.time(): if expires_at is not None and expires_at < time.time():
logger.warning( 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 return None
@@ -538,7 +544,9 @@ class RefreshTokenStorage:
scopes = json.loads(scopes_json) if scopes_json else None scopes = json.loads(scopes_json) if scopes_json else None
logger.debug( 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 { return {
@@ -553,7 +561,9 @@ class RefreshTokenStorage:
} }
except Exception as e: except Exception as e:
logger.error( 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 return None
@@ -584,14 +594,14 @@ class RefreshTokenStorage:
record_db_operation("sqlite", "delete", duration, "success") record_db_operation("sqlite", "delete", duration, "success")
if deleted: 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( await self._audit_log(
event="delete_refresh_token", event="delete_refresh_token",
user_id=user_id, user_id=user_id,
auth_method="offline_access", auth_method="offline_access",
) )
else: 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 return deleted
except Exception: except Exception:
@@ -616,7 +626,7 @@ class RefreshTokenStorage:
rows = await cursor.fetchall() rows = await cursor.fetchall()
user_ids = [row[0] for row in rows] 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 return user_ids
async def cleanup_expired_tokens(self) -> int: async def cleanup_expired_tokens(self) -> int:
@@ -640,7 +650,7 @@ class RefreshTokenStorage:
deleted = cursor.rowcount deleted = cursor.rowcount
if deleted > 0: 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 return deleted
@@ -718,8 +728,9 @@ class RefreshTokenStorage:
await db.commit() await db.commit()
logger.info( logger.info(
f"Stored OAuth client credentials (client_id: {client_id[:16]}..., " "Stored OAuth client credentials (client_id: %s..., expires at %s)",
f"expires at {client_secret_expires_at})" client_id[:16],
client_secret_expires_at,
) )
# Audit log # Audit log
@@ -785,7 +796,7 @@ class RefreshTokenStorage:
# Check expiration # Check expiration
if expires_at < time.time(): if expires_at < time.time():
logger.warning( 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() await self.delete_oauth_client()
return None return None
@@ -803,7 +814,7 @@ class RefreshTokenStorage:
redirect_uris = json.loads(redirect_uris_json) redirect_uris = json.loads(redirect_uris_json)
logger.debug( logger.debug(
f"Retrieved OAuth client credentials (client_id: {client_id[:16]}...)" "Retrieved OAuth client credentials (client_id: %s...)", client_id[:16]
) )
return { return {
@@ -817,7 +828,7 @@ class RefreshTokenStorage:
} }
except Exception as e: 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 return None
async def delete_oauth_client(self) -> bool: async def delete_oauth_client(self) -> bool:
@@ -1017,7 +1028,9 @@ class RefreshTokenStorage:
) )
await db.commit() 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: async def get_oauth_session(self, session_id: str) -> dict | None:
""" """
@@ -1043,7 +1056,7 @@ class RefreshTokenStorage:
# Check expiration # Check expiration
if session["expires_at"] < time.time(): 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) await self.delete_oauth_session(session_id)
return None return None
@@ -1077,7 +1090,8 @@ class RefreshTokenStorage:
# Check expiration # Check expiration
if session["expires_at"] < time.time(): if session["expires_at"] < time.time():
logger.debug( 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"]) await self.delete_oauth_session(session["session_id"])
return None return None
@@ -1133,7 +1147,7 @@ class RefreshTokenStorage:
updated = cursor.rowcount > 0 updated = cursor.rowcount > 0
if updated: if updated:
logger.debug(f"Updated OAuth session {session_id}") logger.debug("Updated OAuth session %s", session_id)
return updated return updated
@@ -1155,7 +1169,7 @@ class RefreshTokenStorage:
deleted = cursor.rowcount > 0 deleted = cursor.rowcount > 0
if deleted: if deleted:
logger.debug(f"Deleted OAuth session {session_id}") logger.debug("Deleted OAuth session %s", session_id)
return deleted return deleted
@@ -1179,7 +1193,7 @@ class RefreshTokenStorage:
deleted = cursor.rowcount deleted = cursor.rowcount
if deleted > 0: 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 return deleted
@@ -1339,7 +1353,7 @@ class RefreshTokenStorage:
) )
await db.commit() 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]: async def get_webhooks_by_preset(self, preset_id: str) -> list[int]:
""" """
@@ -1384,7 +1398,7 @@ class RefreshTokenStorage:
deleted = cursor.rowcount > 0 deleted = cursor.rowcount > 0
if deleted: if deleted:
logger.debug(f"Deleted webhook {webhook_id} from tracking") logger.debug("Deleted webhook %s from tracking", webhook_id)
return deleted return deleted
@@ -1430,7 +1444,7 @@ class RefreshTokenStorage:
deleted = cursor.rowcount deleted = cursor.rowcount
if deleted > 0: 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 return deleted
@@ -1482,7 +1496,7 @@ class RefreshTokenStorage:
duration = time.time() - start_time duration = time.time() - start_time
record_db_operation("sqlite", "insert", duration, "success") 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: except Exception:
duration = time.time() - start_time duration = time.time() - start_time
@@ -1525,7 +1539,7 @@ class RefreshTokenStorage:
row = await cursor.fetchone() row = await cursor.fetchone()
if not row: 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 duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success") record_db_operation("sqlite", "select", duration, "success")
return None return None
@@ -1535,14 +1549,14 @@ class RefreshTokenStorage:
duration = time.time() - start_time duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success") 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 return decrypted_password
except Exception as e: except Exception as e:
duration = time.time() - start_time duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "error") 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 return None
async def delete_app_password(self, user_id: str) -> bool: async def delete_app_password(self, user_id: str) -> bool:
@@ -1572,14 +1586,14 @@ class RefreshTokenStorage:
record_db_operation("sqlite", "delete", duration, "success") record_db_operation("sqlite", "delete", duration, "success")
if deleted: 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( await self._audit_log(
event="delete_app_password", event="delete_app_password",
user_id=user_id, user_id=user_id,
auth_method="app_password", auth_method="app_password",
) )
else: 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 return deleted
@@ -1605,7 +1619,7 @@ class RefreshTokenStorage:
rows = await cursor.fetchall() rows = await cursor.fetchall()
user_ids = [row[0] for row in rows] 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 return user_ids
async def cleanup_invalid_app_passwords(self, nextcloud_host: str) -> list[str]: 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): if response.status_code in (401, 403):
logger.info( logger.info(
f"App password for {user_id} is invalid " "App password for %s is invalid (HTTP %s), removing",
f"(HTTP {response.status_code}), removing" user_id,
response.status_code,
) )
await self.delete_app_password(user_id) await self.delete_app_password(user_id)
removed.append(user_id) removed.append(user_id)
else: else:
logger.debug( logger.debug(
f"App password for {user_id} validated " "App password for %s validated (HTTP %s)",
f"(HTTP {response.status_code})" user_id,
response.status_code,
) )
except Exception as e: 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: async with anyio.create_task_group() as tg:
for user_id in user_ids: for user_id in user_ids:
@@ -1745,9 +1761,10 @@ class RefreshTokenStorage:
duration = time.time() - start_time duration = time.time() - start_time
record_db_operation("sqlite", "insert", duration, "success") record_db_operation("sqlite", "insert", duration, "success")
logger.info( logger.info(
f"Stored scoped app password for user {user_id} " "Stored scoped app password for user %s (scopes=%s, username=%s)",
f"(scopes={'all' if scopes is None else len(scopes)}, " user_id,
f"username={username or 'N/A'})" "all" if scopes is None else len(scopes),
username or "N/A",
) )
except Exception: except Exception:
@@ -1793,7 +1810,7 @@ class RefreshTokenStorage:
row = await cursor.fetchone() row = await cursor.fetchone()
if not row: 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 duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success") record_db_operation("sqlite", "select", duration, "success")
return None return None
@@ -1917,7 +1934,7 @@ class RefreshTokenStorage:
duration = time.time() - start_time duration = time.time() - start_time
record_db_operation("sqlite", "insert", duration, "success") 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: except Exception:
duration = time.time() - start_time duration = time.time() - start_time
@@ -1984,7 +2001,7 @@ class RefreshTokenStorage:
duration = time.time() - start_time duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "error") record_db_operation("sqlite", "select", duration, "error")
logger.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 raise
@@ -2014,7 +2031,7 @@ class RefreshTokenStorage:
record_db_operation("sqlite", "delete", duration, "success") record_db_operation("sqlite", "delete", duration, "success")
if deleted: 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( await self._audit_log(
event="delete_login_flow_session", event="delete_login_flow_session",
user_id=user_id, user_id=user_id,
@@ -2052,7 +2069,7 @@ class RefreshTokenStorage:
record_db_operation("sqlite", "delete", duration, "success") record_db_operation("sqlite", "delete", duration, "success")
if count > 0: 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( await self._audit_log(
event="delete_expired_login_flow_sessions", event="delete_expired_login_flow_sessions",
user_id="system", user_id="system",
+39 -29
View File
@@ -56,15 +56,15 @@ class TokenCache:
# Check if token has expired # Check if token has expired
if now >= expiry: if now >= expiry:
del self._cache[user_id] 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 return None
# Check if token will expire soon (refresh early) # Check if token will expire soon (refresh early)
if now >= expiry - self._early_refresh: 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 return None
logger.debug(f"Using cached token for user {user_id}") logger.debug("Using cached token for user %s", user_id)
return token return token
async def set(self, user_id: str, token: str, expires_in: int | None = None): 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 expiry = datetime.now(timezone.utc) + self._ttl
self._cache[user_id] = (token, expiry) 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): async def invalidate(self, user_id: str):
"""Remove token from cache.""" """Remove token from cache."""
async with self._lock: async with self._lock:
if user_id in self._cache: if user_id in self._cache:
del self._cache[user_id] 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: class TokenBrokerService:
@@ -214,7 +214,7 @@ class TokenBrokerService:
# Get stored refresh token # Get stored refresh token
refresh_data = await self.storage.get_refresh_token(user_id) refresh_data = await self.storage.get_refresh_token(user_id)
if not refresh_data: 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 return None
try: try:
@@ -230,7 +230,7 @@ class TokenBrokerService:
return access_token return access_token
except Exception as e: 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 # Invalidate cache on error
await self.cache.invalidate(user_id) await self.cache.invalidate(user_id)
return None return None
@@ -273,19 +273,21 @@ class TokenBrokerService:
cached_token = await self.cache.get(cache_key) cached_token = await self.cache.get(cache_key)
if cached_token: if cached_token:
logger.debug( 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 return cached_token
# Check if another thread is currently refreshing # Check if another thread is currently refreshing
if await self.cache.get(refresh_in_progress_key): 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 await anyio.sleep(0.1) # Brief wait for in-progress refresh
# Check cache one more time after wait # Check cache one more time after wait
cached_token = await self.cache.get(cache_key) cached_token = await self.cache.get(cache_key)
if cached_token: if cached_token:
logger.debug( 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 return cached_token
@@ -296,7 +298,7 @@ class TokenBrokerService:
# Get stored refresh token # Get stored refresh token
refresh_data = await self.storage.get_refresh_token(user_id) refresh_data = await self.storage.get_refresh_token(user_id)
if not refresh_data: 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 return None
# storage.get_refresh_token() returns already-decrypted token # storage.get_refresh_token() returns already-decrypted token
@@ -312,14 +314,18 @@ class TokenBrokerService:
await self.cache.set(cache_key, access_token, expires_in) await self.cache.set(cache_key, access_token, expires_in)
logger.info( 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 return access_token
except Exception as e: except Exception as e:
logger.error( 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, exc_info=True,
) )
await self.cache.invalidate(cache_key) await self.cache.invalidate(cache_key)
@@ -375,7 +381,7 @@ class TokenBrokerService:
if response.status_code != 200: if response.status_code != 200:
logger.error( 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}") raise Exception(f"Token refresh failed: {response.status_code}")
@@ -395,11 +401,11 @@ class TokenBrokerService:
refresh_token=new_refresh_token, refresh_token=new_refresh_token,
expires_at=expires_at, 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 # 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 return access_token, expires_in
async def _refresh_access_token_with_scopes( async def _refresh_access_token_with_scopes(
@@ -446,7 +452,9 @@ class TokenBrokerService:
} }
logger.info( 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( response = await client.post(
@@ -457,9 +465,11 @@ class TokenBrokerService:
if response.status_code != 200: if response.status_code != 200:
logger.error( 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}") raise Exception(f"Token refresh failed: {response.status_code}")
token_data = response.json() token_data = response.json()
@@ -479,12 +489,12 @@ class TokenBrokerService:
refresh_token=new_refresh_token, refresh_token=new_refresh_token,
expires_at=expires_at, 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 # Note: Nextcloud validates token audience on API calls - no need to pre-validate here
logger.info( 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 return access_token, expires_in
@@ -503,7 +513,7 @@ class TokenBrokerService:
""" """
refresh_data = await self.storage.get_refresh_token(user_id) refresh_data = await self.storage.get_refresh_token(user_id)
if not refresh_data: 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 return False
try: try:
@@ -538,7 +548,7 @@ class TokenBrokerService:
) )
if response.status_code != 200: 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 return False
token_data = response.json() token_data = response.json()
@@ -555,7 +565,7 @@ class TokenBrokerService:
refresh_token=new_refresh_token, refresh_token=new_refresh_token,
expires_at=expires_at, 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 # Invalidate cached access token
await self.cache.invalidate(user_id) await self.cache.invalidate(user_id)
@@ -564,7 +574,7 @@ class TokenBrokerService:
return True return True
except Exception as e: 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 return False
async def has_nextcloud_provisioning(self, user_id: str) -> bool: async def has_nextcloud_provisioning(self, user_id: str) -> bool:
@@ -601,7 +611,7 @@ class TokenBrokerService:
refresh_token = refresh_data["refresh_token"] refresh_token = refresh_data["refresh_token"]
await self._revoke_token_at_idp(refresh_token) await self._revoke_token_at_idp(refresh_token)
except Exception as e: 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 # Remove from storage
await self.storage.delete_refresh_token(user_id) await self.storage.delete_refresh_token(user_id)
@@ -609,11 +619,11 @@ class TokenBrokerService:
# Clear cache # Clear cache
await self.cache.invalidate(user_id) 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 return True
except Exception as e: 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 return False
async def _revoke_token_at_idp(self, token: str): async def _revoke_token_at_idp(self, token: str):
@@ -638,7 +648,7 @@ class TokenBrokerService:
if response.status_code == 200: if response.status_code == 200:
logger.info("Token revoked at IdP") logger.info("Token revoked at IdP")
else: else:
logger.warning(f"Token revocation returned {response.status_code}") logger.warning("Token revocation returned %s", response.status_code)
async def close(self): async def close(self):
"""Clean up resources.""" """Clean up resources."""
+34 -25
View File
@@ -69,7 +69,7 @@ class UnifiedTokenVerifier(TokenVerifier):
# JWT verification support # JWT verification support
self.jwks_client: PyJWKClient | None = None self.jwks_client: PyJWKClient | None = None
if hasattr(settings, "jwks_uri") and settings.jwks_uri: 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) self.jwks_client = PyJWKClient(settings.jwks_uri, cache_keys=True)
# Introspection support (for opaque tokens) # Introspection support (for opaque tokens)
@@ -81,7 +81,7 @@ class UnifiedTokenVerifier(TokenVerifier):
and settings.oidc_client_secret and settings.oidc_client_secret
): ):
self.introspection_uri = settings.introspection_uri 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) # Build list of valid issuers (internal + public may differ in Docker)
# AS proxy obtains tokens via internal URL (e.g. http://app:80), while # AS proxy obtains tokens via internal URL (e.g. http://app:80), while
@@ -114,14 +114,16 @@ class UnifiedTokenVerifier(TokenVerifier):
) )
else: else:
logger.info( logger.info(
f"Management API allowlist: {sorted(self._allowed_mgmt_clients)}" "Management API allowlist: %s", sorted(self._allowed_mgmt_clients)
) )
logger.info( logger.info(
f"UnifiedTokenVerifier initialized in {self.mode} mode. " "UnifiedTokenVerifier initialized in %s mode. MCP audience: %s or %s, Nextcloud resource URI: %s, Valid issuers: %s",
f"MCP audience: {settings.oidc_client_id} or {settings.nextcloud_mcp_server_url}, " self.mode,
f"Nextcloud resource URI: {settings.nextcloud_resource_uri}, " settings.oidc_client_id,
f"Valid issuers: {self.valid_issuers}" settings.nextcloud_mcp_server_url,
settings.nextcloud_resource_uri,
self.valid_issuers,
) )
async def verify_token(self, token: str) -> AccessToken | None: async def verify_token(self, token: str) -> AccessToken | None:
@@ -281,9 +283,10 @@ class UnifiedTokenVerifier(TokenVerifier):
if not self._has_mcp_audience(payload): if not self._has_mcp_audience(payload):
audiences = payload.get("aud", []) audiences = payload.get("aud", [])
logger.error( logger.error(
f"Token rejected: Missing MCP audience. " "Token rejected: Missing MCP audience. Got %s, need MCP (%s or %s)",
f"Got {audiences}, need MCP ({self.settings.oidc_client_id} or " audiences,
f"{self.settings.nextcloud_mcp_server_url})" self.settings.oidc_client_id,
self.settings.nextcloud_mcp_server_url,
) )
# Record as invalid due to audience mismatch # Record as invalid due to audience mismatch
record_oauth_token_validation(validation_method, "invalid") record_oauth_token_validation(validation_method, "invalid")
@@ -303,7 +306,7 @@ class UnifiedTokenVerifier(TokenVerifier):
return self._create_access_token(token, payload) return self._create_access_token(token, payload)
except Exception as e: 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") record_oauth_token_validation(validation_method, "error")
return None return None
@@ -368,14 +371,15 @@ class UnifiedTokenVerifier(TokenVerifier):
# Skip audience validation - any valid Nextcloud token is accepted # Skip audience validation - any valid Nextcloud token is accepted
logger.debug( 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 # Cache and return the token
return self._create_access_token_with_cache_key(token, payload, cache_key) return self._create_access_token_with_cache_key(token, payload, cache_key)
except Exception as e: 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") record_oauth_token_validation(validation_method, "error")
return None return None
@@ -478,20 +482,20 @@ class UnifiedTokenVerifier(TokenVerifier):
f"expected one of: {self.valid_issuers}" 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 return payload
except jwt.ExpiredSignatureError: except jwt.ExpiredSignatureError:
logger.info("JWT token has expired") logger.info("JWT token has expired")
return None return None
except jwt.InvalidIssuerError as e: except jwt.InvalidIssuerError as e:
logger.warning(f"JWT issuer validation failed: {e}") logger.warning("JWT issuer validation failed: %s", e)
return None return None
except jwt.InvalidTokenError as e: except jwt.InvalidTokenError as e:
logger.warning(f"JWT validation failed: {e}") logger.warning("JWT validation failed: %s", e)
return None return None
except Exception as e: except Exception as e:
logger.error(f"Unexpected error during JWT verification: {e}") logger.error("Unexpected error during JWT verification: %s", e)
return None return None
async def _introspect_token(self, token: str) -> dict[str, Any] | None: async def _introspect_token(self, token: str) -> dict[str, Any] | None:
@@ -528,20 +532,23 @@ class UnifiedTokenVerifier(TokenVerifier):
return None return None
logger.debug( 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 return introspection_data
elif response.status_code in (400, 401, 403): elif response.status_code in (400, 401, 403):
logger.warning( logger.warning(
f"Token introspection failed: HTTP {response.status_code}. " "Token introspection failed: HTTP %s. Response: %s",
f"Response: {response.text[:200] if response.text else 'empty'}" response.status_code,
response.text[:200] if response.text else "empty",
) )
return None return None
else: else:
logger.warning( logger.warning(
f"Unexpected response from introspection: {response.status_code}. " "Unexpected response from introspection: %s. Response: %s",
f"Response: {response.text[:200] if response.text else 'empty'}" response.status_code,
response.text[:200] if response.text else "empty",
) )
return None return None
@@ -549,10 +556,10 @@ class UnifiedTokenVerifier(TokenVerifier):
logger.error("Timeout while introspecting token") logger.error("Timeout while introspecting token")
return None return None
except httpx.RequestError as e: 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 return None
except Exception as e: except Exception as e:
logger.error(f"Unexpected error during token introspection: {e}") logger.error("Unexpected error during token introspection: %s", e)
return None return None
def _create_access_token( def _create_access_token(
@@ -598,7 +605,9 @@ class UnifiedTokenVerifier(TokenVerifier):
scope_string = payload.get("scope", "") scope_string = payload.get("scope", "")
scopes = scope_string.split() if scope_string else [] scopes = scope_string.split() if scope_string else []
logger.debug( 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 # Extract expiration
+16 -15
View File
@@ -143,7 +143,7 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
indexed_count = count_result.count indexed_count = count_result.count
except Exception as e: 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 # Continue with indexed_count = 0
# Determine status # Determine status
@@ -156,7 +156,7 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
} }
except Exception as e: except Exception as e:
logger.error(f"Error getting processing status: {e}") logger.error("Error getting processing status: %s", e)
return None return None
@@ -243,11 +243,11 @@ async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None:
try: try:
await oauth_client.discover() await oauth_client.discover()
except Exception as e: except Exception as e:
logger.error(f"Failed to discover IdP endpoints: {e}") logger.error("Failed to discover IdP endpoints: %s", e)
return None return None
logger.debug( 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 return oauth_client.userinfo_endpoint
@@ -269,7 +269,8 @@ async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None:
if userinfo_endpoint: if userinfo_endpoint:
logger.debug( logger.debug(
f"Using Nextcloud userinfo endpoint from discovery: {userinfo_endpoint}" "Using Nextcloud userinfo endpoint from discovery: %s",
userinfo_endpoint,
) )
return userinfo_endpoint return userinfo_endpoint
@@ -277,7 +278,7 @@ async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None:
return None return None
except Exception as e: 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 return None
@@ -302,7 +303,7 @@ async def _query_idp_userinfo(
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
except Exception as e: 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 return None
@@ -379,9 +380,9 @@ async def _get_user_info(request: Request) -> dict[str, Any]:
# Include cached profile if available # Include cached profile if available
if profile_data: if profile_data:
user_context["idp_profile"] = 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: 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"] = ( user_context["idp_profile_error"] = (
"Profile not cached. Try logging out and back in." "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 return user_context
except Exception as e: except Exception as e:
logger.error(f"Error retrieving user info: {e}") logger.error("Error retrieving user info: %s", e)
logger.error(f"Traceback: {traceback.format_exc()}") logger.error("Traceback: %s", traceback.format_exc())
return { return {
"error": f"Failed to retrieve user info: {e}", "error": f"Failed to retrieve user info: {e}",
"username": username, "username": username,
@@ -439,7 +440,7 @@ async def user_info_html(request: Request) -> HTMLResponse:
is_admin = await is_nextcloud_admin(request, nc_client._client) is_admin = await is_nextcloud_admin(request, nc_client._client)
await nc_client.close() await nc_client.close()
except Exception as e: 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 # Default to not admin if check fails
# Check for error # Check for error
@@ -697,9 +698,9 @@ async def revoke_session(request: Request) -> HTMLResponse:
try: try:
# Delete the refresh token # 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) 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 # Redirect back to user page
user_page_url = str(request.url_for("user_info_html")) 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: 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") template = _jinja_env.get_template("error.html")
return HTMLResponse( return HTMLResponse(
content=template.render( content=template.render(
+56 -32
View File
@@ -140,8 +140,13 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
doc_types = doc_types_param.split(",") if doc_types_param else None doc_types = doc_types_param.split(",") if doc_types_param else None
logger.info( logger.info(
f"Viz search: user={username}, query='{query}', " "Viz search: user=%s, query='%s', algorithm=%s, fusion=%s, limit=%s, doc_types=%s",
f"algorithm={algorithm}, fusion={fusion}, limit={limit}, doc_types={doc_types}" username,
query,
algorithm,
fusion,
limit,
doc_types,
) )
try: 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 score_range = max_score - min_score if max_score > min_score else 1.0
logger.info( logger.info(
f"Normalizing scores for viz: original range [{min_score:.3f}, {max_score:.3f}] " "Normalizing scores for viz: original range [%s, %s] → [0.0, 1.0]",
f"→ [0.0, 1.0]" format(min_score, ".3f"),
format(max_score, ".3f"),
) )
# Store original score and rescale to 0-1 for visualization # 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, 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) # Build chunk vectors array in search_results order (1:1 mapping)
chunk_vectors = [] chunk_vectors = []
@@ -349,7 +355,8 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
else: else:
# Chunk not found in vectors (shouldn't happen) # Chunk not found in vectors (shouldn't happen)
logger.warning( 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 # Use zero vector as fallback
chunk_vectors.append(np.zeros(embedding_dim)) 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: if search_algo.query_embedding is not None:
query_embedding = search_algo.query_embedding query_embedding = search_algo.query_embedding
logger.info( logger.info(
f"Reusing query embedding from search algorithm " "Reusing query embedding from search algorithm (dimension=%s)",
f"(dimension={len(query_embedding)})" len(query_embedding),
) )
else: else:
# Fallback: generate embedding if not available from search # Fallback: generate embedding if not available from search
embedding_service = get_embedding_service() embedding_service = get_embedding_service()
query_embedding = await embedding_service.embed(query) 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 query_embed_duration = time.perf_counter() - query_embed_start
# Combine query vector with chunk vectors for PCA # 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(): if zero_norm_mask.any():
zero_indices = np.where(zero_norm_mask)[0] zero_indices = np.where(zero_norm_mask)[0]
logger.warning( logger.warning(
f"Found {zero_norm_mask.sum()} zero-norm vectors at indices {zero_indices.tolist()}. " "Found %s zero-norm vectors at indices %s. Replacing with small epsilon to avoid division by zero.",
"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 # Replace zero norms with small epsilon to avoid NaN
norms[zero_norm_mask] = 1e-10 norms[zero_norm_mask] = 1e-10
all_vectors_normalized = all_vectors / norms all_vectors_normalized = all_vectors / norms
logger.info( logger.info(
f"Normalized vectors: query_norm={norms[-1][0]:.3f}, " "Normalized vectors: query_norm=%s, doc_norm_range=[%s, %s]",
f"doc_norm_range=[{norms[:-1].min():.3f}, {norms[:-1].max():.3f}]" 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 # 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(): if nan_mask.any():
nan_rows = np.where(nan_mask.any(axis=1))[0] nan_rows = np.where(nan_mask.any(axis=1))[0]
logger.error( logger.error(
f"Found NaN values in PCA output at {len(nan_rows)} points: {nan_rows.tolist()[:10]}. " "Found NaN values in PCA output at %s points: %s. Replacing NaN with 0.0 to prevent JSON serialization error.",
"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 # Replace NaN with 0 to allow JSON serialization
coords_3d = np.nan_to_num(coords_3d, nan=0.0) 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 chunk_coords_3d = coords_3d[:-1] # All but last are chunks
logger.info( logger.info(
f"PCA explained variance: PC1={pca.explained_variance_ratio_[0]:.3f}, " "PCA explained variance: PC1=%s, PC2=%s, PC3=%s",
f"PC2={pca.explained_variance_ratio_[1]:.3f}, " format(pca.explained_variance_ratio_[0], ".3f"),
f"PC3={pca.explained_variance_ratio_[2]:.3f}" format(pca.explained_variance_ratio_[1], ".3f"),
format(pca.explained_variance_ratio_[2], ".3f"),
) )
logger.info( logger.info(
f"Embedding stats: chunks={len(chunk_vectors)}, " "Embedding stats: chunks=%s, query_dim=%s, chunk_vector_dim=%s",
f"query_dim={len(query_embedding)}, chunk_vector_dim={chunk_vectors.shape[1] if chunk_vectors.size > 0 else 0}" 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) # 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 # Log comprehensive timing metrics
logger.info( logger.info(
f"Viz search timing: total={total_duration * 1000:.1f}ms, " "Viz search timing: total=%sms, search=%sms (%s%%), vector_fetch=%sms (%s%%), query_embed=%sms (%s%%), pca=%sms (%s%%), results=%s, chunk_vectors=%s",
f"search={search_duration * 1000:.1f}ms ({search_duration / total_duration * 100:.1f}%), " format(total_duration * 1000, ".1f"),
f"vector_fetch={vector_fetch_duration * 1000:.1f}ms ({vector_fetch_duration / total_duration * 100:.1f}%), " format(search_duration * 1000, ".1f"),
f"query_embed={query_embed_duration * 1000:.1f}ms ({query_embed_duration / total_duration * 100:.1f}%), " format(search_duration / total_duration * 100, ".1f"),
f"pca={pca_duration * 1000:.1f}ms ({pca_duration / total_duration * 100:.1f}%), " format(vector_fetch_duration * 1000, ".1f"),
f"results={len(search_results)}, chunk_vectors={len(chunk_vectors)}" 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( return JSONResponse(
@@ -511,7 +533,7 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
) )
except Exception as e: 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( return JSONResponse(
{"success": False, "error": str(e)}, {"success": False, "error": str(e)},
status_code=500, status_code=500,
@@ -637,10 +659,12 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
) )
logger.info( logger.info(
f"Fetched chunk context for {doc_type}_{doc_id}: " "Fetched chunk context for %s_%s: chunk_len=%s, before_len=%s, after_len=%s",
f"chunk_len={len(chunk_context.chunk_text)}, " doc_type,
f"before_len={len(chunk_context.before_context)}, " doc_id,
f"after_len={len(chunk_context.after_context)}" 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 # 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, status_code=400,
) )
except Exception as e: 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( return JSONResponse(
{"success": False, "error": str(e)}, {"success": False, "error": str(e)},
status_code=500, status_code=500,
+1 -1
View File
@@ -167,7 +167,7 @@ class NextcloudClient:
""" """
from ..auth import BearerAuth # noqa: PLC0415 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( return cls(
base_url=base_url, base_url=base_url,
username=username, username=username,
+15 -5
View File
@@ -43,7 +43,8 @@ def retry_on_429(func):
# error we wait a couple of seconds and do a retry # error we wait a couple of seconds and do a retry
if e.response.status_code == codes.TOO_MANY_REQUESTS: if e.response.status_code == codes.TOO_MANY_REQUESTS:
logger.warning( 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) # Record retry metric (extract app name from args if available)
if len(args) > 0 and hasattr(args[0], "app_name"): 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) # 404 errors are often expected (e.g., checking if attachments exist)
# Log as debug instead of warning # Log as debug instead of warning
logger.debug( 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 raise
else: else:
logger.warning( 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 raise
except RequestError as e: except RequestError as e:
logger.warning( 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 raise
@@ -127,7 +137,7 @@ class BaseNextcloudClient(ABC):
Response object Response object
""" """
url = self._resolve_url(url) 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 timer for metrics
start_time = time.time() start_time = time.time()
+50 -39
View File
@@ -131,23 +131,32 @@ class CalendarClient:
max_attempts: Maximum polling attempts (default: 40) max_attempts: Maximum polling attempts (default: 40)
initial_delay_ms: Initial delay between attempts in ms (default: 100ms) 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 delay_ms = initial_delay_ms
for attempt in range(max_attempts): for attempt in range(max_attempts):
try: try:
logger.debug( 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() calendars = await self.list_calendars()
if any(cal["name"] == calendar_name for cal in calendars): if any(cal["name"] == calendar_name for cal in calendars):
logger.info( logger.info(
f"Calendar '{calendar_name}' became available after {attempt + 1} attempts" "Calendar '%s' became available after %s attempts",
calendar_name,
attempt + 1,
) )
return return
except Exception as e: except Exception as e:
logger.warning( 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: if attempt < max_attempts - 1:
@@ -156,7 +165,9 @@ class CalendarClient:
delay_ms = min(delay_ms * 2, 2000) delay_ms = min(delay_ms * 2, 2000)
logger.error( 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 ============= # ============= Calendar Operations =============
@@ -243,7 +254,7 @@ class CalendarClient:
} }
) )
logger.debug(f"Found {len(result)} calendars") logger.debug("Found %s calendars", len(result))
return result return result
async def create_calendar( async def create_calendar(
@@ -285,7 +296,7 @@ class CalendarClient:
f"Failed to create calendar '{calendar_name}': HTTP {response.status}" 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) # Wait for calendar to be queryable (Nextcloud eventual consistency)
await self._wait_for_calendar_propagation(calendar_name) await self._wait_for_calendar_propagation(calendar_name)
@@ -306,7 +317,7 @@ class CalendarClient:
) )
await self._dav_client.delete(calendar_url) 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} return {"status_code": 204}
# ============= Event Operations ============= # ============= Event Operations =============
@@ -467,7 +478,7 @@ class CalendarClient:
# caldav v3's _async_put raises PutError on HTTP failure # caldav v3's _async_put raises PutError on HTTP failure
event = await calendar.save_event(ical=ical_content) # type: ignore[misc] # dual-mode 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 { return {
"uid": event_uid, "uid": event_uid,
@@ -498,7 +509,7 @@ class CalendarClient:
await _maybe_await(event.save()) await _maybe_await(event.save())
logger.debug(f"Updated event {event_uid}") logger.debug("Updated event %s", event_uid)
return { return {
"uid": event_uid, "uid": event_uid,
"href": str(event.url), "href": str(event.url),
@@ -515,10 +526,10 @@ class CalendarClient:
calendar, event_uid, cdav.CompFilter("VEVENT") calendar, event_uid, cdav.CompFilter("VEVENT")
) )
await _maybe_await(event.delete()) await _maybe_await(event.delete())
logger.debug(f"Deleted event {event_uid}") logger.debug("Deleted event %s", event_uid)
return {"status_code": 204} return {"status_code": 204}
except caldav_error.NotFoundError as e: 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} return {"status_code": 404}
async def get_event( async def get_event(
@@ -539,7 +550,7 @@ class CalendarClient:
event_data["href"] = str(event.url) event_data["href"] = str(event.url)
event_data["etag"] = "" event_data["etag"] = ""
logger.debug(f"Retrieved event {event_uid}") logger.debug("Retrieved event %s", event_uid)
return event_data, "" return event_data, ""
async def search_events_across_calendars( async def search_events_across_calendars(
@@ -573,14 +584,14 @@ class CalendarClient:
all_events.extend(events) all_events.extend(events)
except Exception as e: except Exception as e:
logger.warning( logger.warning(
f"Error getting events from calendar {calendar['name']}: {e}" "Error getting events from calendar %s: %s", calendar["name"], e
) )
continue continue
return all_events return all_events
except Exception as e: except Exception as e:
logger.error(f"Error searching events across calendars: {e}") logger.error("Error searching events across calendars: %s", e)
raise raise
# ============= Todo/Task Operations (NEW) ============= # ============= Todo/Task Operations (NEW) =============
@@ -611,7 +622,7 @@ class CalendarClient:
if not filters or self._todo_matches_filters(todo_dict, filters): if not filters or self._todo_matches_filters(todo_dict, filters):
result.append(todo_dict) result.append(todo_dict)
logger.debug(f"Found {len(result)} todos") logger.debug("Found %s todos", len(result))
return result return result
async def create_todo( async def create_todo(
@@ -626,7 +637,7 @@ class CalendarClient:
# caldav v3's _async_put raises PutError on HTTP failure # caldav v3's _async_put raises PutError on HTTP failure
todo = await calendar.save_todo(ical=ical_content) # type: ignore[misc] # dual-mode 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 { return {
"uid": todo_uid, "uid": todo_uid,
@@ -653,7 +664,7 @@ class CalendarClient:
await _maybe_await(todo.load(only_if_unloaded=True)) await _maybe_await(todo.load(only_if_unloaded=True))
logger.debug( 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 # Merge updates into existing iCal data
@@ -662,14 +673,14 @@ class CalendarClient:
todo_data, todo_data,
todo_uid, todo_uid,
) )
logger.debug(f"Merged iCal data length: {len(updated_ical)}") logger.debug("Merged iCal data length: %s", len(updated_ical))
logger.debug(f"Updated iCal content:\n{updated_ical}") logger.debug("Updated iCal content:\\n%s", updated_ical)
todo.data = updated_ical todo.data = updated_ical
await _maybe_await(todo.save()) await _maybe_await(todo.save())
logger.debug(f"Updated todo {todo_uid}") logger.debug("Updated todo %s", todo_uid)
return { return {
"uid": todo_uid, "uid": todo_uid,
"href": str(todo.url), "href": str(todo.url),
@@ -677,7 +688,7 @@ class CalendarClient:
"status_code": 200, "status_code": 200,
} }
except Exception as e: 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 raise
async def delete_todo(self, calendar_name: str, todo_uid: str) -> dict[str, Any]: 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") calendar, todo_uid, cdav.CompFilter("VTODO")
) )
await _maybe_await(todo.delete()) await _maybe_await(todo.delete())
logger.debug(f"Deleted todo {todo_uid}") logger.debug("Deleted todo %s", todo_uid)
return {"status_code": 204} return {"status_code": 204}
except caldav_error.NotFoundError as e: 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} return {"status_code": 404}
async def search_todos_across_calendars( async def search_todos_across_calendars(
@@ -717,14 +728,14 @@ class CalendarClient:
all_todos.extend(todos) all_todos.extend(todos)
except Exception as e: except Exception as e:
logger.warning( logger.warning(
f"Error getting todos from calendar {calendar['name']}: {e}" "Error getting todos from calendar %s: %s", calendar["name"], e
) )
continue continue
return all_todos return all_todos
except Exception as e: except Exception as e:
logger.error(f"Error searching todos across calendars: {e}") logger.error("Error searching todos across calendars: %s", e)
raise raise
# ============= Helper Methods - Event iCalendar ============= # ============= Helper Methods - Event iCalendar =============
@@ -935,7 +946,7 @@ class CalendarClient:
return self._extract_vevent_data(component) return self._extract_vevent_data(component)
return None return None
except Exception as e: except Exception as e:
logger.error(f"Error parsing iCalendar event: {e}") logger.error("Error parsing iCalendar event: %s", e)
return None return None
def _merge_ical_properties( def _merge_ical_properties(
@@ -1060,7 +1071,7 @@ class CalendarClient:
return cal.to_ical().decode("utf-8") return cal.to_ical().decode("utf-8")
except Exception as e: 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) return self._create_ical_event(event_data, event_uid)
# ============= Helper Methods - Todo iCalendar ============= # ============= Helper Methods - Todo iCalendar =============
@@ -1184,7 +1195,7 @@ class CalendarClient:
return None return None
except Exception as e: except Exception as e:
logger.error(f"Error parsing iCalendar todo: {e}") logger.error("Error parsing iCalendar todo: %s", e)
return None return None
def _merge_ical_todo_properties( def _merge_ical_todo_properties(
@@ -1193,7 +1204,7 @@ class CalendarClient:
"""Merge new todo data into existing raw iCal while preserving all properties.""" """Merge new todo data into existing raw iCal while preserving all properties."""
try: try:
logger.debug( 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) cal = Calendar.from_ical(raw_ical)
@@ -1207,13 +1218,13 @@ class CalendarClient:
if "status" in todo_data: if "status" in todo_data:
status_value = todo_data["status"].upper() status_value = todo_data["status"].upper()
component["STATUS"] = status_value 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: if "priority" in todo_data:
component["PRIORITY"] = todo_data["priority"] component["PRIORITY"] = todo_data["priority"]
if "percent_complete" in todo_data: if "percent_complete" in todo_data:
percent_value = todo_data["percent_complete"] percent_value = todo_data["percent_complete"]
component["PERCENT-COMPLETE"] = percent_value 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 # Handle due date
if "due" in todo_data: if "due" in todo_data:
@@ -1221,7 +1232,7 @@ class CalendarClient:
if due_str: if due_str:
due_dt = self._ensure_timezone_aware(due_str) due_dt = self._ensure_timezone_aware(due_str)
component["DUE"] = vDDDTypes(due_dt) component["DUE"] = vDDDTypes(due_dt)
logger.debug(f"Set DUE to {due_dt}") logger.debug("Set DUE to %s", due_dt)
# Handle start date # Handle start date
if "dtstart" in todo_data: if "dtstart" in todo_data:
@@ -1229,7 +1240,7 @@ class CalendarClient:
if dtstart_str: if dtstart_str:
dtstart_dt = self._ensure_timezone_aware(dtstart_str) dtstart_dt = self._ensure_timezone_aware(dtstart_str)
component["DTSTART"] = vDDDTypes(dtstart_dt) component["DTSTART"] = vDDDTypes(dtstart_dt)
logger.debug(f"Set DTSTART to {dtstart_dt}") logger.debug("Set DTSTART to %s", dtstart_dt)
# Handle completed date # Handle completed date
if "completed" in todo_data: if "completed" in todo_data:
@@ -1237,7 +1248,7 @@ class CalendarClient:
if completed_str: if completed_str:
completed_dt = self._ensure_timezone_aware(completed_str) completed_dt = self._ensure_timezone_aware(completed_str)
component["COMPLETED"] = vDDDTypes(completed_dt) component["COMPLETED"] = vDDDTypes(completed_dt)
logger.debug(f"Set COMPLETED to {completed_dt}") logger.debug("Set COMPLETED to %s", completed_dt)
# Handle categories # Handle categories
if "categories" in todo_data: if "categories" in todo_data:
@@ -1246,7 +1257,7 @@ class CalendarClient:
component["CATEGORIES"] = [ component["CATEGORIES"] = [
c.strip() for c in categories_str.split(",") 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 # Update timestamps
now = dt.datetime.now(dt.UTC) now = dt.datetime.now(dt.UTC)
@@ -1258,7 +1269,7 @@ class CalendarClient:
return cal.to_ical().decode("utf-8") return cal.to_ical().decode("utf-8")
except Exception as e: 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) return self._create_ical_todo(todo_data, todo_uid)
# ============= Helper Methods - Filtering ============= # ============= Helper Methods - Filtering =============
@@ -1290,7 +1301,7 @@ class CalendarClient:
return categories_obj.to_ical().decode("utf-8") return categories_obj.to_ical().decode("utf-8")
return str(categories_obj) return str(categories_obj)
except Exception as e: except Exception as e:
logger.warning(f"Error extracting categories: {e}") logger.warning("Error extracting categories: %s", e)
return str(categories_obj) return str(categories_obj)
def _apply_event_filters( def _apply_event_filters(
@@ -1437,7 +1448,7 @@ class CalendarClient:
} }
except Exception as e: except Exception as e:
logger.error(f"Error in bulk update: {e}") logger.error("Error in bulk update: %s", e)
raise raise
async def find_availability( async def find_availability(
+5 -5
View File
@@ -89,7 +89,7 @@ class ContactsClient(BaseNextcloudClient):
} }
) )
logger.debug(f"Found {len(addressbooks)} addressbooks") logger.debug("Found %s addressbooks", len(addressbooks))
return addressbooks return addressbooks
async def create_addressbook(self, *, name: str, display_name: str): async def create_addressbook(self, *, name: str, display_name: str):
@@ -166,7 +166,7 @@ class ContactsClient(BaseNextcloudClient):
except Exception: except Exception:
# Fall back to creating new vCard if we can't get existing # Fall back to creating new vCard if we can't get existing
logger.warning( 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 = "" 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 return contacts
async def _get_raw_vcard(self, addressbook: str, uid: str) -> tuple[str, str]: 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", "") etag = response.headers.get("etag", "")
return response.text, etag return response.text, etag
except Exception as e: 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 raise
def _merge_vcard_properties( def _merge_vcard_properties(
@@ -428,7 +428,7 @@ class ContactsClient(BaseNextcloudClient):
return "\n".join(updated_lines) return "\n".join(updated_lines)
except Exception as e: 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 # Fallback to creating basic vCard matching Nextcloud format
basic_vcard = f"""BEGIN:VCARD basic_vcard = f"""BEGIN:VCARD
VERSION:3.0 VERSION:3.0
+1 -1
View File
@@ -130,7 +130,7 @@ class CookbookClient(BaseNextcloudClient):
Returns: Returns:
Full imported recipe data 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( response = await self._make_request(
"POST", "POST",
"/apps/cookbook/api/v1/import", "/apps/cookbook/api/v1/import",
+3 -3
View File
@@ -67,7 +67,7 @@ class GroupsClient(BaseNextcloudClient):
headers={"OCS-APIRequest": "true", "Accept": "application/json"}, headers={"OCS-APIRequest": "true", "Accept": "application/json"},
) )
response.raise_for_status() response.raise_for_status()
logger.info(f"Created group: {groupid}") logger.info("Created group: %s", groupid)
@retry_on_429 @retry_on_429
async def delete_group(self, groupid: str) -> None: async def delete_group(self, groupid: str) -> None:
@@ -85,7 +85,7 @@ class GroupsClient(BaseNextcloudClient):
headers={"OCS-APIRequest": "true", "Accept": "application/json"}, headers={"OCS-APIRequest": "true", "Accept": "application/json"},
) )
response.raise_for_status() response.raise_for_status()
logger.info(f"Deleted group: {groupid}") logger.info("Deleted group: %s", groupid)
@retry_on_429 @retry_on_429
async def get_group_members(self, groupid: str) -> List[str]: async def get_group_members(self, groupid: str) -> List[str]:
@@ -150,4 +150,4 @@ class GroupsClient(BaseNextcloudClient):
headers={"OCS-APIRequest": "true", "Accept": "application/json"}, headers={"OCS-APIRequest": "true", "Accept": "application/json"},
) )
response.raise_for_status() response.raise_for_status()
logger.info(f"Updated group {groupid} displayname to: {displayname}") logger.info("Updated group %s displayname to: %s", groupid, displayname)
+26 -16
View File
@@ -93,13 +93,14 @@ class NotesClient(BaseNextcloudClient):
for note in response_data: for note in response_data:
note_id = note.get("id") note_id = note.get("id")
if note_id is None: if note_id is None:
logger.warning(f"Skipping note without ID: {note}") logger.warning("Skipping note without ID: %s", note)
continue continue
# Skip duplicates (API returns all IDs in last chunk for deletion detection) # Skip duplicates (API returns all IDs in last chunk for deletion detection)
if note_id in seen_ids: if note_id in seen_ids:
logger.debug( 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 continue
@@ -152,10 +153,10 @@ class NotesClient(BaseNextcloudClient):
if category is not None: if category is not None:
old_note = await self.get_note(note_id) old_note = await self.get_note(note_id)
old_category = old_note.get("category", "") 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: except Exception as e:
logger.warning( 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 old_note = None
@@ -169,7 +170,7 @@ class NotesClient(BaseNextcloudClient):
body["category"] = category body["category"] = category
logger.info( 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( response = await self._make_request(
@@ -180,7 +181,7 @@ class NotesClient(BaseNextcloudClient):
) )
logger.info( 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") updated_note = _expect_note_object(response.json(), operation="update_note")
@@ -191,7 +192,9 @@ class NotesClient(BaseNextcloudClient):
and old_note.get("category", "") != category and old_note.get("category", "") != category
): ):
logger.info( 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: try:
webdav_client = WebDAVClient(self._client, self.username) webdav_client = WebDAVClient(self._client, self.username)
@@ -200,7 +203,9 @@ class NotesClient(BaseNextcloudClient):
) )
except Exception as e: except Exception as e:
logger.error( 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 return updated_note
@@ -220,20 +225,23 @@ class NotesClient(BaseNextcloudClient):
potential_categories.append("") # Empty category potential_categories.append("") # Empty category
logger.info( 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: except Exception as e:
logger.warning( 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 potential_categories = ["", "Unknown"] # Try common categories
# Delete the note via API # 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( response = await self._make_request(
"DELETE", f"/apps/notes/api/v1/notes/{note_id}" "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() json_response = response.json()
# Clean up attachment directories # Clean up attachment directories
@@ -245,16 +253,16 @@ class NotesClient(BaseNextcloudClient):
await webdav_client.cleanup_note_attachments(note_id, cat) await webdav_client.cleanup_note_attachments(note_id, cat)
except Exception as e: except Exception as e:
logger.warning( 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: except Exception as e:
logger.warning(f"Error during attachment cleanup: {e}") logger.warning("Error during attachment cleanup: %s", e)
return json_response return json_response
async def append_content(self, note_id: int, content: str) -> Dict[str, Any]: async def append_content(self, note_id: int, content: str) -> Dict[str, Any]:
"""Append content to an existing note with a separator.""" """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 # Get current note
current_note = await self.get_note(note_id) current_note = await self.get_note(note_id)
@@ -270,7 +278,9 @@ class NotesClient(BaseNextcloudClient):
new_content = content # No separator needed for empty notes new_content = content # No separator needed for empty notes
logger.info( 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 # Update with combined content
+8 -4
View File
@@ -72,8 +72,12 @@ class SharingClient(BaseNextcloudClient):
) )
logger.info( logger.info(
f"Created share {share_data['id']}: {path} -> {share_with} " "Created share %s: %s -> %s (type=%s, permissions=%s)",
f"(type={share_type}, permissions={permissions})" share_data["id"],
path,
share_with,
share_type,
permissions,
) )
return share_data return share_data
@@ -99,7 +103,7 @@ class SharingClient(BaseNextcloudClient):
f"OCS API error: {data['ocs']['meta'].get('message', 'Unknown error')}" 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 @retry_on_429
async def get_share(self, share_id: int) -> dict[str, Any]: 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')}" 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"] return result["ocs"]["data"]
+110 -67
View File
@@ -28,7 +28,7 @@ class WebDAVClient(BaseNextcloudClient):
path_with_slash = path path_with_slash = path
webdav_path = f"{self._get_webdav_base_path()}/{path_with_slash.lstrip('/')}" 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"} headers = {"OCS-APIRequest": "true"}
try: try:
@@ -39,28 +39,30 @@ class WebDAVClient(BaseNextcloudClient):
"PROPFIND", webdav_path, headers=propfind_headers "PROPFIND", webdav_path, headers=propfind_headers
) )
logger.debug( logger.debug(
f"Resource exists check status: {propfind_resp.status_code}" "Resource exists check status: %s", propfind_resp.status_code
) )
except HTTPStatusError as e: except HTTPStatusError as e:
if e.response.status_code == 404: 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} return {"status_code": 404}
# For other errors, continue with deletion attempt # For other errors, continue with deletion attempt
# Proceed with deletion # Proceed with deletion
response = await self._make_request("DELETE", webdav_path, headers=headers) 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} return {"status_code": response.status_code}
except HTTPStatusError as e: except HTTPStatusError as e:
if e.response.status_code == 404: 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} return {"status_code": 404}
else: else:
logger.error(f"HTTP error deleting WebDAV resource '{path}': {e}") logger.error("HTTP error deleting WebDAV resource '%s': %s", path, e)
raise e raise e
except Exception as 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 raise e
async def cleanup_old_attachment_directory( async def cleanup_old_attachment_directory(
@@ -72,13 +74,15 @@ class WebDAVClient(BaseNextcloudClient):
f"Notes/{old_category_path_part}.attachments.{note_id}/" 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: try:
delete_result = await self.delete_resource(path=old_attachment_dir_path) 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 return delete_result
except Exception as e: 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 raise e
async def cleanup_note_attachments( async def cleanup_note_attachments(
@@ -89,14 +93,14 @@ class WebDAVClient(BaseNextcloudClient):
attachment_dir_path = f"Notes/{cat_path_part}.attachments.{note_id}/" attachment_dir_path = f"Notes/{cat_path_part}.attachments.{note_id}/"
logger.debug( 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: try:
delete_result = await self.delete_resource(path=attachment_dir_path) 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 return delete_result
except Exception as e: 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 raise e
async def add_note_attachment( async def add_note_attachment(
@@ -118,7 +122,7 @@ class WebDAVClient(BaseNextcloudClient):
parent_dir_path = f"{webdav_base}/{parent_dir_webdav_rel_path}" parent_dir_path = f"{webdav_base}/{parent_dir_webdav_rel_path}"
attachment_path = f"{parent_dir_path}/{filename}" 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: if not mime_type:
mime_type, _ = mimetypes.guess_type(filename) mime_type, _ = mimetypes.guess_type(filename)
@@ -143,7 +147,8 @@ class WebDAVClient(BaseNextcloudClient):
) )
elif notes_dir_response.status_code >= 400: elif notes_dir_response.status_code >= 400:
logger.error( 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() 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) # MKCOL should return 201 Created or 405 Method Not Allowed (if directory already exists)
if mkcol_response.status_code not in [201, 405]: if mkcol_response.status_code not in [201, 405]:
logger.error( 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() mkcol_response.raise_for_status()
@@ -166,18 +172,24 @@ class WebDAVClient(BaseNextcloudClient):
) )
response.raise_for_status() response.raise_for_status()
logger.debug( 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} return {"status_code": response.status_code}
except HTTPStatusError as e: except HTTPStatusError as e:
logger.error( 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 raise e
except Exception as e: except Exception as e:
logger.error( 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 raise e
@@ -190,7 +202,7 @@ class WebDAVClient(BaseNextcloudClient):
attachment_dir_segment = f".attachments.{note_id}" attachment_dir_segment = f".attachments.{note_id}"
attachment_path = f"{webdav_base}/Notes/{category_path_part}{attachment_dir_segment}/{filename}" 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: try:
response = await self._make_request("GET", attachment_path) 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") mime_type = response.headers.get("content-type", "application/octet-stream")
logger.debug( logger.debug(
f"Successfully fetched attachment '{filename}' ({len(content)} bytes)" "Successfully fetched attachment '%s' (%s bytes)",
filename,
len(content),
) )
return content, mime_type return content, mime_type
except HTTPStatusError as e: except HTTPStatusError as e:
if e.response.status_code == 404: 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: else:
logger.error( 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 raise e
except Exception as e: except Exception as e:
logger.error( 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 raise e
@@ -224,7 +244,7 @@ class WebDAVClient(BaseNextcloudClient):
if not webdav_path.endswith("/"): if not webdav_path.endswith("/"):
webdav_path += "/" webdav_path += "/"
logger.debug(f"Listing directory: {path}") logger.debug("Listing directory: %s", path)
propfind_body = """<?xml version="1.0"?> propfind_body = """<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:"> <d:propfind xmlns:d="DAV:">
@@ -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 return items
except HTTPStatusError as e: 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 raise e
except Exception as 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 raise e
async def read_file(self, path: str) -> Tuple[bytes, str]: async def read_file(self, path: str) -> Tuple[bytes, str]:
"""Read a file's content via WebDAV GET.""" """Read a file's content via WebDAV GET."""
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}" webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
logger.debug(f"Reading file: {path}") logger.debug("Reading file: %s", path)
try: try:
response = await self._make_request("GET", webdav_path) response = await self._make_request("GET", webdav_path)
@@ -333,14 +353,14 @@ class WebDAVClient(BaseNextcloudClient):
"content-type", "application/octet-stream" "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 return content, content_type
except HTTPStatusError as e: 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 raise e
except Exception as 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 raise e
async def write_file( async def write_file(
@@ -349,7 +369,7 @@ class WebDAVClient(BaseNextcloudClient):
"""Write content to a file via WebDAV PUT.""" """Write content to a file via WebDAV PUT."""
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}" 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: if not content_type:
content_type, _ = mimetypes.guess_type(path) content_type, _ = mimetypes.guess_type(path)
@@ -364,14 +384,14 @@ class WebDAVClient(BaseNextcloudClient):
) )
response.raise_for_status() response.raise_for_status()
logger.debug(f"Successfully wrote file '{path}'") logger.debug("Successfully wrote file '%s'", path)
return {"status_code": response.status_code} return {"status_code": response.status_code}
except HTTPStatusError as e: 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 raise e
except Exception as 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 raise e
async def create_directory( async def create_directory(
@@ -382,7 +402,7 @@ class WebDAVClient(BaseNextcloudClient):
if not webdav_path.endswith("/"): if not webdav_path.endswith("/"):
webdav_path += "/" webdav_path += "/"
logger.debug(f"Creating directory: {path}") logger.debug("Creating directory: %s", path)
headers = {"OCS-APIRequest": "true"} headers = {"OCS-APIRequest": "true"}
@@ -390,13 +410,13 @@ class WebDAVClient(BaseNextcloudClient):
response = await self._make_request("MKCOL", webdav_path, headers=headers) response = await self._make_request("MKCOL", webdav_path, headers=headers)
response.raise_for_status() response.raise_for_status()
logger.debug(f"Successfully created directory '{path}'") logger.debug("Successfully created directory '%s'", path)
return {"status_code": response.status_code} return {"status_code": response.status_code}
except HTTPStatusError as e: except HTTPStatusError as e:
# Method Not Allowed - directory already exists # Method Not Allowed - directory already exists
if e.response.status_code == 405: 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"} return {"status_code": 405, "message": "Directory already exists"}
# File Conflict - parent directory does not exist # File Conflict - parent directory does not exist
@@ -406,20 +426,21 @@ class WebDAVClient(BaseNextcloudClient):
if len(path_parts) > 1: if len(path_parts) > 1:
parent_dir = "/".join(path_parts[:-1]) parent_dir = "/".join(path_parts[:-1])
logger.debug( 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) await self.create_directory(parent_dir, recursive)
# Now try to create the original directory again # Now try to create the original directory again
return await self.create_directory(path, recursive) return await self.create_directory(path, recursive)
else: else:
# This shouldn't happen for single-level directories under root # 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 raise e
logger.error(f"HTTP error creating directory '{path}': {e}") logger.error("HTTP error creating directory '%s': %s", path, e)
raise e raise e
except Exception as 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 raise e
async def move_resource( async def move_resource(
@@ -446,7 +467,7 @@ class WebDAVClient(BaseNextcloudClient):
elif not source_path.endswith("/") and destination_path.endswith("/"): elif not source_path.endswith("/") and destination_path.endswith("/"):
source_webdav_path += "/" 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 = { headers = {
"OCS-APIRequest": "true", "OCS-APIRequest": "true",
@@ -461,17 +482,20 @@ class WebDAVClient(BaseNextcloudClient):
response.raise_for_status() response.raise_for_status()
logger.debug( 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} return {"status_code": response.status_code}
except HTTPStatusError as e: except HTTPStatusError as e:
if e.response.status_code == 404: 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"} return {"status_code": 404, "message": "Source resource not found"}
elif e.response.status_code == 412: elif e.response.status_code == 412:
logger.debug( logger.debug(
f"Destination '{destination_path}' already exists and overwrite is false" "Destination '%s' already exists and overwrite is false",
destination_path,
) )
return { return {
"status_code": 412, "status_code": 412,
@@ -479,7 +503,8 @@ class WebDAVClient(BaseNextcloudClient):
} }
elif e.response.status_code == 409: elif e.response.status_code == 409:
logger.debug( logger.debug(
f"Parent directory of destination '{destination_path}' doesn't exist" "Parent directory of destination '%s' doesn't exist",
destination_path,
) )
return { return {
"status_code": 409, "status_code": 409,
@@ -487,12 +512,18 @@ class WebDAVClient(BaseNextcloudClient):
} }
else: else:
logger.error( 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 raise e
except Exception as e: except Exception as e:
logger.error( 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 raise e
@@ -520,7 +551,9 @@ class WebDAVClient(BaseNextcloudClient):
elif not source_path.endswith("/") and destination_path.endswith("/"): elif not source_path.endswith("/") and destination_path.endswith("/"):
source_webdav_path += "/" 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 = { headers = {
"OCS-APIRequest": "true", "OCS-APIRequest": "true",
@@ -535,17 +568,20 @@ class WebDAVClient(BaseNextcloudClient):
response.raise_for_status() response.raise_for_status()
logger.debug( 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} return {"status_code": response.status_code}
except HTTPStatusError as e: except HTTPStatusError as e:
if e.response.status_code == 404: 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"} return {"status_code": 404, "message": "Source resource not found"}
elif e.response.status_code == 412: elif e.response.status_code == 412:
logger.debug( logger.debug(
f"Destination '{destination_path}' already exists and overwrite is false" "Destination '%s' already exists and overwrite is false",
destination_path,
) )
return { return {
"status_code": 412, "status_code": 412,
@@ -553,7 +589,8 @@ class WebDAVClient(BaseNextcloudClient):
} }
elif e.response.status_code == 409: elif e.response.status_code == 409:
logger.debug( logger.debug(
f"Parent directory of destination '{destination_path}' doesn't exist" "Parent directory of destination '%s' doesn't exist",
destination_path,
) )
return { return {
"status_code": 409, "status_code": 409,
@@ -561,12 +598,18 @@ class WebDAVClient(BaseNextcloudClient):
} }
else: else:
logger.error( 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 raise e
except Exception as e: except Exception as e:
logger.error( 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 raise e
@@ -615,7 +658,7 @@ class WebDAVClient(BaseNextcloudClient):
headers = {"Content-Type": "text/xml", "OCS-APIRequest": "true"} 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: try:
response = await self._make_request( response = await self._make_request(
@@ -626,14 +669,14 @@ class WebDAVClient(BaseNextcloudClient):
# Parse the XML response # Parse the XML response
results = self._parse_search_response(response.content, scope) 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 return results
except HTTPStatusError as e: except HTTPStatusError as e:
logger.error(f"HTTP error during search: {e}") logger.error("HTTP error during search: %s", e)
raise e raise e
except Exception as e: except Exception as e:
logger.error(f"Unexpected error during search: {e}") logger.error("Unexpected error during search: %s", e)
raise e raise e
def _build_search_xml( def _build_search_xml(
@@ -1129,7 +1172,7 @@ class WebDAVClient(BaseNextcloudClient):
"file_id": file_id, "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 return file_info
async def get_tag_by_name(self, tag_name: str) -> dict[str, Any] | None: 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, "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 return file_info
async def create_tag( async def create_tag(
@@ -1500,7 +1543,7 @@ class WebDAVClient(BaseNextcloudClient):
"userAssignable": user_assignable, "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 return tag_info
async def get_or_create_tag( async def get_or_create_tag(
@@ -1522,7 +1565,7 @@ class WebDAVClient(BaseNextcloudClient):
# First try to get existing tag # First try to get existing tag
existing_tag = await self.get_tag_by_name(name) existing_tag = await self.get_tag_by_name(name)
if existing_tag: 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 return existing_tag
# Create new tag # Create new tag
@@ -1558,7 +1601,7 @@ class WebDAVClient(BaseNextcloudClient):
# 201 = Created (new assignment), 409 = Conflict (already assigned) # 201 = Created (new assignment), 409 = Conflict (already assigned)
if response.status_code in (201, 409): 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 return True
response.raise_for_status() response.raise_for_status()
@@ -1584,7 +1627,7 @@ class WebDAVClient(BaseNextcloudClient):
# 204 = No Content (removed), 404 = Not Found (wasn't assigned) # 204 = No Content (removed), 404 = Not Found (wasn't assigned)
if response.status_code in (204, 404): 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 return True
response.raise_for_status() response.raise_for_status()
+2 -2
View File
@@ -604,8 +604,8 @@ class Settings:
if self.document_chunk_size < 512: if self.document_chunk_size < 512:
logger.warning( logger.warning(
f"DOCUMENT_CHUNK_SIZE is set to {self.document_chunk_size} characters, which is quite small. " "DOCUMENT_CHUNK_SIZE is set to %s characters, which is quite small. Smaller chunks may lose context. Consider using at least 1024 characters.",
f"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 --- # --- ADR-022 follow-up: deployment mode is the single source of truth ---
+6 -7
View File
@@ -216,7 +216,7 @@ def detect_auth_mode(settings: Settings) -> AuthMode:
) )
explicit_mode = mode_map[mode_str] 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 return explicit_mode
# Auto-detection (no explicit deployment_mode). # Auto-detection (no explicit deployment_mode).
@@ -247,7 +247,7 @@ def validate_configuration(settings: Settings) -> tuple[AuthMode, list[str]]:
requirements = MODE_REQUIREMENTS[mode] requirements = MODE_REQUIREMENTS[mode]
errors: list[str] = [] errors: list[str] = []
logger.debug(f"Validating configuration for mode: {mode.value}") logger.debug("Validating configuration for mode: %s", mode.value)
# Check required variables # Check required variables
for var in requirements.required: 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) # (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: if not settings.oidc_client_id or not settings.oidc_client_secret:
logger.info( logger.info(
f"[{mode.value}] OAuth credentials not configured. " "[%s] OAuth credentials not configured. Will attempt Dynamic Client Registration (DCR) at startup.",
"Will attempt Dynamic Client Registration (DCR) at startup." mode.value,
) )
if mode == AuthMode.MULTI_USER_BASIC: 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 settings.enable_offline_access:
if not settings.oidc_client_id or not settings.oidc_client_secret: if not settings.oidc_client_id or not settings.oidc_client_secret:
logger.info( logger.info(
f"[{mode.value}] OAuth credentials not configured. " "[%s] OAuth credentials not configured. Will attempt Dynamic Client Registration (DCR) at startup (required for app password retrieval via Astrolabe).",
"Will attempt Dynamic Client Registration (DCR) at startup " mode.value,
"(required for app password retrieval via Astrolabe)."
) )
# Note: Vector sync no longer requires explicit ENABLE_OFFLINE_ACCESS setting # Note: Vector sync no longer requires explicit ENABLE_OFFLINE_ACCESS setting
+4 -2
View File
@@ -139,7 +139,9 @@ def _get_client_from_basic_auth(ctx: Context) -> NextcloudClient:
raise ValueError("Invalid BasicAuth credentials - missing username or password") raise ValueError("Invalid BasicAuth credentials - missing username or password")
logger.debug( 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 # 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 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( return NextcloudClient(
base_url=nextcloud_host, base_url=nextcloud_host,
@@ -55,7 +55,7 @@ class CustomHTTPProcessor(DocumentProcessor):
self._name = name self._name = name
self._supported_types = supported_types or set() 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 @property
def name(self) -> str: def name(self) -> str:
@@ -114,7 +114,9 @@ class CustomHTTPProcessor(DocumentProcessor):
metadata = result.get("metadata", {}) metadata = result.get("metadata", {})
logger.debug( logger.debug(
f"Custom processor '{self.name}' extracted {len(text)} characters" "Custom processor '%s' extracted %s characters",
self.name,
len(text),
) )
return ProcessingResult( return ProcessingResult(
@@ -125,10 +127,10 @@ class CustomHTTPProcessor(DocumentProcessor):
) )
except httpx.HTTPError as e: 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 raise ProcessorError(f"API call failed: {str(e)}") from e
except Exception as 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 raise ProcessorError(f"Processing failed: {str(e)}") from e
async def health_check(self) -> bool: async def health_check(self) -> bool:
@@ -146,5 +148,7 @@ class CustomHTTPProcessor(DocumentProcessor):
) )
return response.status_code < 500 return response.status_code < 500
except Exception as e: 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 return False
@@ -59,7 +59,8 @@ class PyMuPDFProcessor(DocumentProcessor):
if self.extract_images: if self.extract_images:
self.image_dir.mkdir(exist_ok=True, parents=True) self.image_dir.mkdir(exist_ok=True, parents=True)
logger.info( logger.info(
f"Initialized PyMuPDFProcessor with image extraction to {self.image_dir}" "Initialized PyMuPDFProcessor with image extraction to %s",
self.image_dir,
) )
else: else:
logger.info("Initialized PyMuPDFProcessor without image extraction") logger.info("Initialized PyMuPDFProcessor without image extraction")
@@ -175,9 +176,11 @@ class PyMuPDFProcessor(DocumentProcessor):
await progress_callback(100, 100, "Processing complete") await progress_callback(100, 100, "Processing complete")
logger.info( logger.info(
f"Successfully processed PDF {filename or '<bytes>'}: " "Successfully processed PDF %s: %s pages, %s chars, %s images",
f"{metadata['page_count']} pages, {len(md_text)} chars, " filename or "<bytes>",
f"{metadata.get('image_count', 0)} images" metadata["page_count"],
len(md_text),
metadata.get("image_count", 0),
) )
return ProcessingResult( return ProcessingResult(
@@ -250,5 +253,5 @@ class PyMuPDFProcessor(DocumentProcessor):
test_doc.close() test_doc.close()
return True return True
except Exception as e: except Exception as e:
logger.error(f"PyMuPDF health check failed: {e}") logger.error("PyMuPDF health check failed: %s", e)
return False return False
@@ -41,7 +41,7 @@ class ProcessorRegistry:
name = processor.name name = processor.name
if name in self._processors: 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) self._processors[name] = (processor, priority)
@@ -62,8 +62,10 @@ class ProcessorRegistry:
self._priority_order.append(name) self._priority_order.append(name)
logger.info( logger.info(
f"Registered processor: {name} " "Registered processor: %s (priority=%s, supports=%s types)",
f"(priority={priority}, supports={len(processor.supported_mime_types)} types)" name,
priority,
len(processor.supported_mime_types),
) )
def get_processor(self, name: str) -> Optional[DocumentProcessor]: def get_processor(self, name: str) -> Optional[DocumentProcessor]:
@@ -93,10 +95,10 @@ class ProcessorRegistry:
for name in self._priority_order: for name in self._priority_order:
processor = self._processors[name][0] processor = self._processors[name][0]
if processor.supports(content_type): 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 return processor
logger.debug(f"No processor found for type '{content_type}'") logger.debug("No processor found for type '%s'", content_type)
return None return None
def list_processors(self) -> list[str]: def list_processors(self) -> list[str]:
@@ -150,7 +152,7 @@ class ProcessorRegistry:
f"Registered processors: {', '.join(self.list_processors())}" f"Registered processors: {', '.join(self.list_processors())}"
) )
logger.info(f"Processing with '{processor.name}' processor") logger.info("Processing with '%s' processor", processor.name)
# Process # Process
return await processor.process( return await processor.process(
@@ -71,7 +71,7 @@ class TesseractProcessor(DocumentProcessor):
) )
self.default_lang = default_lang self.default_lang = default_lang
logger.info(f"Initialized TesseractProcessor: lang={default_lang}") logger.info("Initialized TesseractProcessor: lang=%s", default_lang)
@property @property
def name(self) -> str: def name(self) -> str:
@@ -137,8 +137,9 @@ class TesseractProcessor(DocumentProcessor):
} }
logger.debug( logger.debug(
f"Tesseract OCR completed: {len(text)} chars, " "Tesseract OCR completed: %s chars, confidence=%s%%",
f"confidence={avg_confidence:.1f}%" len(text),
format(avg_confidence, ".1f"),
) )
return ProcessingResult( return ProcessingResult(
@@ -149,7 +150,7 @@ class TesseractProcessor(DocumentProcessor):
) )
except Exception as e: 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 raise ProcessorError(f"OCR failed: {str(e)}") from e
async def health_check(self) -> bool: async def health_check(self) -> bool:
@@ -68,9 +68,11 @@ class UnstructuredProcessor(DocumentProcessor):
self.progress_interval = progress_interval self.progress_interval = progress_interval
logger.info( logger.info(
f"Initialized UnstructuredProcessor: {api_url}, " "Initialized UnstructuredProcessor: %s, strategy=%s, languages=%s, progress_interval=%ss",
f"strategy={default_strategy}, languages={self.default_languages}, " api_url,
f"progress_interval={progress_interval}s" default_strategy,
self.default_languages,
progress_interval,
) )
@property @property
@@ -117,9 +119,9 @@ class UnstructuredProcessor(DocumentProcessor):
total=None, # Unknown total duration # type: ignore total=None, # Unknown total duration # type: ignore
message=message, # 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: 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") logger.debug("Progress poller stopped")
async def _make_api_request( async def _make_api_request(
@@ -165,7 +167,9 @@ class UnstructuredProcessor(DocumentProcessor):
data["extract_image_block_types"] = ",".join(extract_image_block_types) data["extract_image_block_types"] = ",".join(extract_image_block_types)
logger.debug( logger.debug(
f"Processing with Unstructured API: strategy={strategy}, languages={languages}" "Processing with Unstructured API: strategy=%s, languages=%s",
strategy,
languages,
) )
try: try:
@@ -202,8 +206,9 @@ class UnstructuredProcessor(DocumentProcessor):
} }
logger.debug( logger.debug(
f"Successfully processed: {len(elements)} elements, " "Successfully processed: %s elements, %s characters",
f"{len(parsed_text)} characters" len(elements),
len(parsed_text),
) )
return ProcessingResult( return ProcessingResult(
@@ -214,10 +219,10 @@ class UnstructuredProcessor(DocumentProcessor):
) )
except httpx.HTTPError as e: 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 raise ProcessorError(f"HTTP error: {str(e)}") from e
except Exception as 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 raise ProcessorError(f"Processing failed: {str(e)}") from e
async def process( async def process(
@@ -306,5 +311,5 @@ class UnstructuredProcessor(DocumentProcessor):
response = await client.get(f"{self.api_url}/healthcheck") response = await client.get(f"{self.api_url}/healthcheck")
return response.status_code == 200 return response.status_code == 200
except Exception as e: except Exception as e:
logger.warning(f"Unstructured health check failed: {e}") logger.warning("Unstructured health check failed: %s", e)
return False return False
@@ -30,11 +30,11 @@ class BM25SparseEmbeddingProvider:
model_name: FastEmbed BM25 model name (default: Qdrant/bm25) model_name: FastEmbed BM25 model name (default: Qdrant/bm25)
""" """
self.model_name = model_name 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 # Initialize FastEmbed sparse embedding model
self.model = SparseTextEmbedding(model_name=model_name) 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]: def encode(self, text: str) -> dict[str, Any]:
""" """
@@ -33,7 +33,10 @@ class OllamaEmbeddingProvider(EmbeddingProvider):
self.client = httpx.AsyncClient(verify=verify_ssl, timeout=timeout) self.client = httpx.AsyncClient(verify=verify_ssl, timeout=timeout)
self._dimension: int | None = None # Will be detected dynamically self._dimension: int | None = None # Will be detected dynamically
logger.info( 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) self._check_model_is_loaded(autoload=True)
@@ -82,11 +85,13 @@ class OllamaEmbeddingProvider(EmbeddingProvider):
instead of relying on hardcoded values. instead of relying on hardcoded values.
""" """
if self._dimension is None: 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") test_embedding = await self.embed("test")
self._dimension = len(test_embedding) self._dimension = len(test_embedding)
logger.info( 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: def get_dimension(self) -> int:
+8 -8
View File
@@ -52,8 +52,8 @@ def get_alembic_config(database_path: str | Path | None = None) -> Config:
url = f"sqlite+aiosqlite:///{db_path}" url = f"sqlite+aiosqlite:///{db_path}"
config.set_main_option("sqlalchemy.url", url) config.set_main_option("sqlalchemy.url", url)
logger.debug(f"Alembic script location: {script_location}") logger.debug("Alembic script location: %s", script_location)
logger.debug(f"Database: {db_path}") logger.debug("Database: %s", db_path)
return config return config
@@ -69,7 +69,7 @@ def upgrade_database(
revision: Target revision (default: "head" for latest) revision: Target revision (default: "head" for latest)
""" """
config = get_alembic_config(database_path) 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) command.upgrade(config, revision)
logger.info("Database upgrade completed successfully") logger.info("Database upgrade completed successfully")
@@ -85,7 +85,7 @@ def downgrade_database(
revision: Target revision (default: "-1" for previous version) revision: Target revision (default: "-1" for previous version)
""" """
config = get_alembic_config(database_path) 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) command.downgrade(config, revision)
logger.info("Database downgrade completed successfully") 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() db_path = Path(database_path).resolve()
if not db_path.exists(): 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 return None
try: try:
@@ -133,7 +133,7 @@ def get_current_revision(database_path: str | Path | None = None) -> str | None:
return row[0] if row else None return row[0] if row else None
except Exception as e: except Exception as e:
logger.error(f"Failed to get current revision: {e}") logger.error("Failed to get current revision: %s", e)
return None return None
@@ -152,7 +152,7 @@ def stamp_database(
revision: Revision to stamp (default: "head" for latest) revision: Revision to stamp (default: "head" for latest)
""" """
config = get_alembic_config(database_path) 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) command.stamp(config, revision)
logger.info("Database stamped successfully") logger.info("Database stamped successfully")
@@ -181,7 +181,7 @@ def create_migration(message: str, autogenerate: bool = False) -> None:
and migrations must be written manually. and migrations must be written manually.
""" """
config = get_alembic_config() config = get_alembic_config()
logger.info(f"Creating new migration: {message}") logger.info("Creating new migration: %s", message)
if autogenerate: if autogenerate:
logger.warning( logger.warning(
@@ -177,8 +177,10 @@ def setup_logging(
configure_component_loggers(log_level) configure_component_loggers(log_level)
root_logger.info( root_logger.info(
f"Logging configured: format={log_format}, level={log_level}, " "Logging configured: format=%s, level=%s, trace_context=%s",
f"trace_context={include_trace_context}" log_format,
log_level,
include_trace_context,
) )
@@ -231,14 +231,15 @@ def setup_metrics(port: int = 9090) -> None:
""" """
try: try:
start_http_server(port) 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: except OSError as e:
if "Address already in use" in str(e): if "Address already in use" in str(e):
logger.warning( 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: 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 raise
@@ -127,7 +127,9 @@ class ObservabilityMiddleware(BaseHTTPMiddleware):
) )
logger.error( logger.error(
f"Request failed: {method} {path}", "Request failed: %s %s",
method,
path,
exc_info=True, exc_info=True,
extra={ extra={
"method": method, "method": method,
@@ -212,7 +214,10 @@ class ObservabilityMiddleware(BaseHTTPMiddleware):
# Log slow requests (>1 second) # Log slow requests (>1 second)
if duration > 1.0: if duration > 1.0:
logger.warning( logger.warning(
f"Slow request: {method} {endpoint} took {duration:.3f}s", "Slow request: %s %s took %ss",
method,
endpoint,
format(duration, ".3f"),
extra={ extra={
"method": method, "method": method,
"endpoint": endpoint, "endpoint": endpoint,
@@ -73,11 +73,12 @@ def setup_tracing(
span_processor = BatchSpanProcessor(otlp_exporter) span_processor = BatchSpanProcessor(otlp_exporter)
provider.add_span_processor(span_processor) provider.add_span_processor(span_processor)
logger.info( logger.info(
f"OpenTelemetry tracing enabled with OTLP endpoint: {otlp_endpoint}" "OpenTelemetry tracing enabled with OTLP endpoint: %s", otlp_endpoint
) )
except Exception as e: except Exception as e:
logger.warning( 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: else:
logger.info( logger.info(
@@ -93,7 +94,7 @@ def setup_tracing(
# Get and store tracer # Get and store tracer
_tracer = trace.get_tracer(__name__) _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 return _tracer
+1 -1
View File
@@ -30,7 +30,7 @@ class AnthropicProvider(Provider):
self.client = AsyncAnthropic(api_key=api_key) self.client = AsyncAnthropic(api_key=api_key)
self.model = generation_model self.model = generation_model
logger.info(f"Initialized Anthropic provider (model={self.model})") logger.info("Initialized Anthropic provider (model=%s)", self.model)
@property @property
def supports_embeddings(self) -> bool: def supports_embeddings(self) -> bool:
+18 -15
View File
@@ -75,8 +75,10 @@ class BedrockProvider(Provider):
self.client = boto3.client("bedrock-runtime", **client_kwargs) self.client = boto3.client("bedrock-runtime", **client_kwargs)
logger.info( logger.info(
f"Initialized Bedrock provider in region {region_name or 'default'} " "Initialized Bedrock provider in region %s (embedding_model=%s, generation_model=%s)",
f"(embedding_model={embedding_model}, generation_model={generation_model})" region_name or "default",
embedding_model,
generation_model,
) )
@property @property
@@ -115,8 +117,8 @@ class BedrockProvider(Provider):
# Unknown model - try Titan format as default # Unknown model - try Titan format as default
else: else:
logger.warning( logger.warning(
f"Unknown embedding model format for {self.embedding_model}, " "Unknown embedding model format for %s, using Titan format as default",
"using Titan format as default" self.embedding_model,
) )
return {"inputText": text} return {"inputText": text}
@@ -143,8 +145,8 @@ class BedrockProvider(Provider):
# Unknown model - try Titan format as default # Unknown model - try Titan format as default
else: else:
logger.warning( logger.warning(
f"Unknown embedding response format for {self.embedding_model}, " "Unknown embedding response format for %s, trying Titan format",
"trying Titan format" self.embedding_model,
) )
return response.get("embedding", response.get("embeddings", [None])[0]) return response.get("embedding", response.get("embeddings", [None])[0])
@@ -183,7 +185,7 @@ class BedrockProvider(Provider):
return embedding return embedding
except (BotoCoreError, ClientError) as e: except (BotoCoreError, ClientError) as e:
logger.error(f"Bedrock embedding error: {e}") logger.error("Bedrock embedding error: %s", e)
raise raise
async def embed_batch(self, texts: list[str]) -> list[list[float]]: 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: if self._dimension is None and self.supports_embeddings:
logger.debug( 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") test_embedding = await self.embed("test")
self._dimension = len(test_embedding) self._dimension = len(test_embedding)
logger.info( logger.info(
f"Detected embedding dimension: {self._dimension} " "Detected embedding dimension: %s for model %s",
f"for model {self.embedding_model}" self._dimension,
self.embedding_model,
) )
def get_dimension(self) -> int: def get_dimension(self) -> int:
@@ -300,8 +303,8 @@ class BedrockProvider(Provider):
# Unknown model - try Claude format as default # Unknown model - try Claude format as default
else: else:
logger.warning( logger.warning(
f"Unknown generation model format for {self.generation_model}, " "Unknown generation model format for %s, using Claude format as default",
"using Claude format as default" self.generation_model,
) )
return { return {
"anthropic_version": "bedrock-2023-05-31", "anthropic_version": "bedrock-2023-05-31",
@@ -343,8 +346,8 @@ class BedrockProvider(Provider):
# Unknown model - try common response fields # Unknown model - try common response fields
else: else:
logger.warning( logger.warning(
f"Unknown generation response format for {self.generation_model}, " "Unknown generation response format for %s, trying common fields",
"trying common fields" self.generation_model,
) )
# Try common response field names # Try common response field names
for field in ["text", "generation", "outputText", "completion"]: for field in ["text", "generation", "outputText", "completion"]:
@@ -389,7 +392,7 @@ class BedrockProvider(Provider):
return text return text
except (BotoCoreError, ClientError) as e: except (BotoCoreError, ClientError) as e:
logger.error(f"Bedrock generation error: {e}") logger.error("Bedrock generation error: %s", e)
raise raise
async def close(self) -> None: async def close(self) -> None:
+9 -6
View File
@@ -46,9 +46,11 @@ class OllamaProvider(Provider):
self._dimension: int | None = None # Detected dynamically for embeddings self._dimension: int | None = None # Detected dynamically for embeddings
logger.info( logger.info(
f"Initialized Ollama provider: {base_url} " "Initialized Ollama provider: %s (embedding_model=%s, generation_model=%s, verify_ssl=%s)",
f"(embedding_model={embedding_model}, generation_model={generation_model}, " base_url,
f"verify_ssl={verify_ssl})" embedding_model,
generation_model,
verify_ssl,
) )
# Pre-check and auto-load models # Pre-check and auto-load models
@@ -140,13 +142,14 @@ class OllamaProvider(Provider):
""" """
if self._dimension is None and self.supports_embeddings: if self._dimension is None and self.supports_embeddings:
logger.debug( 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") test_embedding = await self.embed("test")
self._dimension = len(test_embedding) self._dimension = len(test_embedding)
logger.info( logger.info(
f"Detected embedding dimension: {self._dimension} " "Detected embedding dimension: %s for model %s",
f"for model {self.embedding_model}" self._dimension,
self.embedding_model,
) )
def get_dimension(self) -> int: def get_dimension(self) -> int:
+2 -2
View File
@@ -120,11 +120,11 @@ async def get_indexed_doc_types(user_id: str) -> set[str]:
if point.payload and point.payload.get("doc_type") 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 return doc_types
except Exception as e: 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() return set()
+16 -11
View File
@@ -100,9 +100,13 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
score_threshold = kwargs.get("score_threshold", self.score_threshold) score_threshold = kwargs.get("score_threshold", self.score_threshold)
logger.info( logger.info(
f"BM25 hybrid search: query='{query}', user={user_id}, " "BM25 hybrid search: query='%s', user=%s, limit=%s, score_threshold=%s, doc_type=%s, fusion=%s",
f"limit={limit}, score_threshold={score_threshold}, doc_type={doc_type}, " query,
f"fusion={self.fusion_name}" user_id,
limit,
score_threshold,
doc_type,
self.fusion_name,
) )
# Generate dense embedding for semantic search # Generate dense embedding for semantic search
@@ -112,7 +116,7 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
dense_embedding = await embedding_service.embed(query) dense_embedding = await embedding_service.embed(query)
# Store for reuse by callers (e.g., viz_routes PCA visualization) # Store for reuse by callers (e.g., viz_routes PCA visualization)
self.query_embedding = dense_embedding 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 # Generate sparse embedding for BM25 keyword search
with trace_operation("search.get_bm25_service"): with trace_operation("search.get_bm25_service"):
@@ -120,8 +124,8 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
with trace_operation("search.sparse_embedding_bm25"): with trace_operation("search.sparse_embedding_bm25"):
sparse_embedding = await bm25_service.encode_async(query) sparse_embedding = await bm25_service.encode_async(query)
logger.debug( logger.debug(
f"Generated sparse embedding " "Generated sparse embedding (%s non-zero terms)",
f"({len(sparse_embedding['indices'])} non-zero terms)" len(sparse_embedding["indices"]),
) )
# Build Qdrant filter # Build Qdrant filter
@@ -189,15 +193,16 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
raise raise
logger.info( logger.info(
f"Qdrant {self.fusion_name.upper()} fusion returned {len(search_response.points)} results " "Qdrant %s fusion returned %s results (before deduplication)",
f"(before deduplication)" self.fusion_name.upper(),
len(search_response.points),
) )
if search_response.points: if search_response.points:
# Log top 3 fusion scores to help with threshold tuning # Log top 3 fusion scores to help with threshold tuning
top_scores = [p.score for p in search_response.points[:3]] top_scores = [p.score for p in search_response.points[:3]]
logger.debug( 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) # Deduplicate by (doc_id, doc_type, chunk_start, chunk_end)
@@ -233,12 +238,12 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
if len(results) >= limit: if len(results) >= limit:
break break
logger.info(f"Returning {len(results)} unverified results after deduplication") logger.info("Returning %s unverified results after deduplication", len(results))
if results: if results:
result_details = [ result_details = [
f"{r.doc_type}_{r.id} (score={r.score:.3f}, title='{r.title}')" f"{r.doc_type}_{r.id} (score={r.score:.3f}, title='{r.title}')"
for r in results[:5] # Show top 5 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 return results
+64 -32
View File
@@ -67,20 +67,26 @@ async def _get_chunk_from_qdrant(
excerpt = point.payload.get("excerpt") excerpt = point.payload.get("excerpt")
if excerpt: if excerpt:
logger.debug( logger.debug(
f"Retrieved chunk from Qdrant for {doc_type} {doc_id}: " "Retrieved chunk from Qdrant for %s %s: %s chars",
f"{len(excerpt)} chars" doc_type,
doc_id,
len(excerpt),
) )
return str(excerpt) return str(excerpt)
logger.debug( logger.debug(
f"Chunk not found in Qdrant for {doc_type} {doc_id}, " "Chunk not found in Qdrant for %s %s, chunk [%s:%s]. Will fall back to document fetch.",
f"chunk [{chunk_start}:{chunk_end}]. Will fall back to document fetch." doc_type,
doc_id,
chunk_start,
chunk_end,
) )
return None return None
except Exception as e: except Exception as e:
logger.error( 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, exc_info=True,
) )
return None return None
@@ -129,8 +135,11 @@ async def _get_chunk_by_index_from_qdrant(
excerpt = point.payload.get("excerpt") excerpt = point.payload.get("excerpt")
if excerpt: if excerpt:
logger.debug( logger.debug(
f"Retrieved adjacent chunk {chunk_index} from Qdrant for " "Retrieved adjacent chunk %s from Qdrant for %s %s: %s chars",
f"{doc_type} {doc_id}: {len(excerpt)} chars" chunk_index,
doc_type,
doc_id,
len(excerpt),
) )
return str(excerpt) return str(excerpt)
@@ -138,8 +147,11 @@ async def _get_chunk_by_index_from_qdrant(
except Exception as e: except Exception as e:
logger.debug( logger.debug(
f"Could not retrieve adjacent chunk {chunk_index} for " "Could not retrieve adjacent chunk %s for %s %s: %s",
f"{doc_type} {doc_id}: {e}" chunk_index,
doc_type,
doc_id,
e,
) )
return None return None
@@ -181,19 +193,21 @@ async def _get_deck_metadata_from_qdrant(
stack_id = point.payload.get("stack_id") stack_id = point.payload.get("stack_id")
if board_id is not None and stack_id is not None: if board_id is not None and stack_id is not None:
logger.debug( logger.debug(
f"Retrieved deck metadata for card {card_id}: " "Retrieved deck metadata for card %s: board_id=%s, stack_id=%s",
f"board_id={board_id}, stack_id={stack_id}" card_id,
board_id,
stack_id,
) )
return {"board_id": int(board_id), "stack_id": int(stack_id)} return {"board_id": int(board_id), "stack_id": int(stack_id)}
logger.debug( logger.debug(
f"Could not find deck metadata in Qdrant for card {card_id} " "Could not find deck metadata in Qdrant for card %s (might be legacy data without board_id/stack_id)",
f"(might be legacy data without board_id/stack_id)" card_id,
) )
return None return None
except Exception as e: 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 return None
@@ -389,8 +403,9 @@ async def get_chunk_with_context(
if chunk_text: if chunk_text:
logger.info( logger.info(
f"Retrieved chunk from Qdrant cache for {doc_type} {doc_id} " "Retrieved chunk from Qdrant cache for %s %s (avoids document re-fetch/re-parse)",
f"(avoids document re-fetch/re-parse)" doc_type,
doc_id,
) )
# Fetch adjacent chunks for context expansion # Fetch adjacent chunks for context expansion
@@ -495,24 +510,30 @@ async def get_chunk_with_context(
return None return None
logger.info( logger.info(
f"Falling back to document fetch for {doc_type} {doc_id} " "Falling back to document fetch for %s %s (Qdrant cache miss, possibly legacy data)",
f"(Qdrant cache miss, possibly legacy data)" doc_type,
doc_id,
) )
# Fetch full document text (notes, deck cards, news items, etc.) # Fetch full document text (notes, deck cards, news items, etc.)
full_text = await _fetch_document_text(nc_client, doc_id, doc_type, user_id) full_text = await _fetch_document_text(nc_client, doc_id, doc_type, user_id)
if full_text is None: if full_text is None:
logger.warning( logger.warning(
f"Could not fetch document text for {doc_type} {doc_id}, " "Could not fetch document text for %s %s, skipping context expansion",
"skipping context expansion" doc_type,
doc_id,
) )
return None return None
# Validate offsets # Validate offsets
if chunk_start < 0 or chunk_end > len(full_text) or chunk_start >= chunk_end: if chunk_start < 0 or chunk_end > len(full_text) or chunk_start >= chunk_end:
logger.warning( logger.warning(
f"Invalid chunk offsets for {doc_type} {doc_id}: " "Invalid chunk offsets for %s %s: start=%s, end=%s, doc_len=%s",
f"start={chunk_start}, end={chunk_end}, doc_len={len(full_text)}" doc_type,
doc_id,
chunk_start,
chunk_end,
len(full_text),
) )
return None return None
@@ -650,13 +671,18 @@ async def _fetch_document_text(
board_id=board_id, stack_id=stack_id, card_id=int(doc_id) board_id=board_id, stack_id=stack_id, card_id=int(doc_id)
) )
logger.debug( logger.debug(
f"Retrieved deck card {doc_id} using metadata " "Retrieved deck card %s using metadata (board_id=%s, stack_id=%s)",
f"(board_id={board_id}, stack_id={stack_id})" doc_id,
board_id,
stack_id,
) )
except Exception as e: except Exception as e:
logger.warning( logger.warning(
f"Failed to fetch card with metadata (board_id={board_id}, " "Failed to fetch card with metadata (board_id=%s, stack_id=%s, card_id=%s): %s, falling back to iteration",
f"stack_id={stack_id}, card_id={doc_id}): {e}, 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) # 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) # Skip deleted boards (soft delete: deletedAt > 0)
if board.deletedAt > 0: if board.deletedAt > 0:
logger.debug( 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 continue
@@ -686,13 +714,15 @@ async def _fetch_document_text(
card = c card = c
card_found = True card_found = True
logger.debug( logger.debug(
f"Found deck card {doc_id} in board {board.id}, " "Found deck card %s in board %s, stack %s (fallback iteration)",
f"stack {stack.id} (fallback iteration)" doc_id,
board.id,
stack.id,
) )
break break
if not card_found: 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 return None
# Type narrowing: card is set if we reach here # Type narrowing: card is set if we reach here
@@ -705,10 +735,12 @@ async def _fetch_document_text(
content_parts.append(card.description) content_parts.append(card.description)
return "\n\n".join(content_parts) return "\n\n".join(content_parts)
else: 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 return None
except Exception as e: 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 return None
+64 -37
View File
@@ -118,7 +118,7 @@ class PDFHighlighter:
try: try:
shutil.rmtree(temp_dir) shutil.rmtree(temp_dir)
except Exception as e: 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 return full_text, page_boundaries
@@ -202,7 +202,7 @@ class PDFHighlighter:
try: try:
page_words = page.get_text("words") page_words = page.get_text("words")
except Exception as e: 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 return 0
if not page_words: if not page_words:
@@ -225,8 +225,12 @@ class PDFHighlighter:
) )
] ]
logger.debug( logger.debug(
f"Filtered to {len(page_words)} words in region " "Filtered to %s words in region (%s, %s, %s, %s)",
f"({rx0:.0f}, {ry0:.0f}, {rx1:.0f}, {ry1:.0f})" len(page_words),
format(rx0, ".0f"),
format(ry0, ".0f"),
format(rx1, ".0f"),
format(ry1, ".0f"),
) )
if not page_words: if not page_words:
@@ -286,17 +290,19 @@ class PDFHighlighter:
if len(current_matches) >= len(chunk_words) * 0.5: if len(current_matches) >= len(chunk_words) * 0.5:
matches = current_matches matches = current_matches
logger.debug( logger.debug(
f"Found match at position {start_pos}: " "Found match at position %s: %s/%s words",
f"{len(matches)}/{len(chunk_words)} words" start_pos,
len(matches),
len(chunk_words),
) )
break # Take FIRST match, not best/longest break # Take FIRST match, not best/longest
if not matches: 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 return 0
logger.debug( 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 # Build rectangles from matched words
@@ -324,8 +330,9 @@ class PDFHighlighter:
# A chunk should be mostly contiguous text # A chunk should be mostly contiguous text
if large_gaps > len(matches) * 0.3: # More than 30% have gaps if large_gaps > len(matches) * 0.3: # More than 30% have gaps
logger.debug( logger.debug(
f"Rejecting scattered matches: {large_gaps} large gaps " "Rejecting scattered matches: %s large gaps out of %s matches",
f"out of {len(matches)} matches" large_gaps,
len(matches),
) )
return 0 return 0
@@ -512,12 +519,12 @@ class PDFHighlighter:
rects = page.search_for(phrase.strip()) rects = page.search_for(phrase.strip())
if rects: if rects:
anchor_rect = rects[0] # Use first match 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 break
if not anchor_rect: if not anchor_rect:
page_num = page.number + 1 if page.number is not None else "unknown" 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 return 0
# Calculate chunk height based on character count # Calculate chunk height based on character count
@@ -558,8 +565,10 @@ class PDFHighlighter:
fill_shape.commit() fill_shape.commit()
logger.debug( logger.debug(
f"Added bounding box at y={chunk_rect.y0:.0f}-{chunk_rect.y1:.0f} " "Added bounding box at y=%s-%s (estimated %s lines)",
f"(estimated {estimated_lines:.1f} lines)" format(chunk_rect.y0, ".0f"),
format(chunk_rect.y1, ".0f"),
format(estimated_lines, ".1f"),
) )
return 1 return 1
@@ -626,7 +635,9 @@ class PDFHighlighter:
# Log if page differs from stored metadata # Log if page differs from stored metadata
if stored_page_number and stored_page_number != page_num: if stored_page_number and stored_page_number != page_num:
logger.info( 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 # Extract page text
@@ -644,8 +655,12 @@ class PDFHighlighter:
page_text_length = page_end - page_start page_text_length = page_end - page_start
logger.debug( logger.debug(
f"Extracted {len(chunk_text)} chars on page {page_num} " "Extracted %s chars on page %s (offsets %s-%s of %s)",
f"(offsets {page_relative_start}-{page_relative_end} of {page_text_length})" len(chunk_text),
page_num,
page_relative_start,
page_relative_end,
page_text_length,
) )
# Get page and add highlights # Get page and add highlights
@@ -672,13 +687,15 @@ class PDFHighlighter:
doc.close() doc.close()
logger.info( 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) return (png_bytes, page_num, highlight_count)
except Exception as e: 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 return None
finally: finally:
@@ -688,7 +705,9 @@ class PDFHighlighter:
shutil.rmtree(temp_pdf_path.parent) shutil.rmtree(temp_pdf_path.parent)
except Exception as e: except Exception as e:
logger.warning( 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 @staticmethod
@@ -791,11 +810,11 @@ class PDFHighlighter:
) )
results[chunk_index] = ([normalized], page_num) 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 return results
except Exception as e: 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 return results
finally: finally:
@@ -805,7 +824,7 @@ class PDFHighlighter:
try: try:
shutil.rmtree(temp_pdf_path.parent) shutil.rmtree(temp_pdf_path.parent)
except Exception as e: 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 @staticmethod
def highlight_chunks_batch( def highlight_chunks_batch(
@@ -853,8 +872,9 @@ class PDFHighlighter:
doc = pymupdf.open(temp_pdf_path) doc = pymupdf.open(temp_pdf_path)
logger.debug( logger.debug(
f"Batch highlighting: {len(chunks)} chunks, " "Batch highlighting: %s chunks, %s pages",
f"{len(page_boundaries)} pages" len(chunks),
len(page_boundaries),
) )
# Group chunks by their target page for efficient rendering # Group chunks by their target page for efficient rendering
@@ -873,7 +893,7 @@ class PDFHighlighter:
) )
if not chunk_page_info: 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 continue
page_num = chunk_page_info["page_num"] page_num = chunk_page_info["page_num"]
@@ -881,8 +901,10 @@ class PDFHighlighter:
# Log if page differs from stored metadata # Log if page differs from stored metadata
if stored_page_num and stored_page_num != page_num: if stored_page_num and stored_page_num != page_num:
logger.debug( logger.debug(
f"Chunk {chunk_index}: found on page {page_num}, " "Chunk %s: found on page %s, metadata says %s",
f"metadata says {stored_page_num}" chunk_index,
page_num,
stored_page_num,
) )
# Extract page-relative portion of chunk text # Extract page-relative portion of chunk text
@@ -905,7 +927,7 @@ class PDFHighlighter:
) )
logger.debug( 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 # OPTIMIZATION: Render each page ONCE, then draw highlights using PIL
@@ -929,7 +951,9 @@ class PDFHighlighter:
page_rect = page.rect page_rect = page.rect
logger.debug( 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 ( for (
@@ -949,7 +973,7 @@ class PDFHighlighter:
) )
if bbox is None: 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 continue
# Copy base image for this chunk # Copy base image for this chunk
@@ -985,24 +1009,27 @@ class PDFHighlighter:
results[chunk_index] = (png_bytes, page_num, 1) results[chunk_index] = (png_bytes, page_num, 1)
logger.debug( logger.debug(
f"Chunk {chunk_index}: {len(png_bytes):,} bytes, " "Chunk %s: %s bytes, page %s, bbox %s",
f"page {page_num}, bbox {pil_bbox}" chunk_index,
format(len(png_bytes), ","),
page_num,
pil_bbox,
) )
except Exception as e: except Exception as e:
logger.error(f"Chunk {chunk_index}: error - {e}") logger.error("Chunk %s: error - %s", chunk_index, e)
continue continue
doc.close() doc.close()
logger.info( logger.info(
f"Batch highlighted {len(results)}/{len(chunks)} chunks successfully" "Batch highlighted %s/%s chunks successfully", len(results), len(chunks)
) )
return results return results
except Exception as e: 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 return results
finally: finally:
@@ -1011,4 +1038,4 @@ class PDFHighlighter:
try: try:
shutil.rmtree(temp_pdf_path.parent) shutil.rmtree(temp_pdf_path.parent)
except Exception as e: except Exception as e:
logger.warning(f"Failed to clean up temp dir: {e}") logger.warning("Failed to clean up temp dir: %s", e)
+12 -8
View File
@@ -77,8 +77,12 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
score_threshold = kwargs.get("score_threshold", self.score_threshold) score_threshold = kwargs.get("score_threshold", self.score_threshold)
logger.info( logger.info(
f"Semantic search: query='{query}', user={user_id}, " "Semantic search: query='%s', user=%s, limit=%s, score_threshold=%s, doc_type=%s",
f"limit={limit}, score_threshold={score_threshold}, doc_type={doc_type}" query,
user_id,
limit,
score_threshold,
doc_type,
) )
# Generate embedding for query # Generate embedding for query
@@ -87,7 +91,7 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
# Store for reuse by callers (e.g., viz_routes PCA visualization) # Store for reuse by callers (e.g., viz_routes PCA visualization)
self.query_embedding = query_embedding self.query_embedding = query_embedding
logger.debug( logger.debug(
f"Generated embedding for query (dimension={len(query_embedding)})" "Generated embedding for query (dimension=%s)", len(query_embedding)
) )
# Build Qdrant filter # Build Qdrant filter
@@ -127,14 +131,14 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
raise raise
logger.info( logger.info(
f"Qdrant returned {len(search_response.points)} results " "Qdrant returned %s results (before deduplication)",
f"(before deduplication)" len(search_response.points),
) )
if search_response.points: if search_response.points:
# Log top 3 scores to help with threshold tuning # Log top 3 scores to help with threshold tuning
top_scores = [p.score for p in search_response.points[:3]] 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) # Deduplicate by (doc_id, doc_type, chunk_start, chunk_end)
# This allows multiple chunks from same doc, but removes duplicate chunks # This allows multiple chunks from same doc, but removes duplicate chunks
@@ -155,12 +159,12 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
if len(results) >= limit: if len(results) >= limit:
break break
logger.info(f"Returning {len(results)} unverified results after deduplication") logger.info("Returning %s unverified results after deduplication", len(results))
if results: if results:
result_details = [ result_details = [
f"{r.doc_type}_{r.id} (score={r.score:.3f}, title='{r.title}')" f"{r.doc_type}_{r.id} (score={r.score:.3f}, title='{r.title}')"
for r in results[:5] # Show top 5 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 return results
+4 -4
View File
@@ -116,7 +116,7 @@ def register_auth_tools(mcp: FastMCP) -> None:
) )
init_response = await flow_client.initiate() init_response = await flow_client.initiate()
except Exception as e: 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( return ProvisionAccessResponse(
status="error", status="error",
message=f"Failed to start login flow: {e}", message=f"Failed to start login flow: {e}",
@@ -227,7 +227,7 @@ def register_auth_tools(mcp: FastMCP) -> None:
try: try:
session = await storage.get_login_flow_session(user_id) session = await storage.get_login_flow_session(user_id)
except Exception as e: 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( return ProvisionStatusResponse(
status="error", status="error",
message=f"Failed to check login flow session: {e}", 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"], poll_token=session["poll_token"],
) )
except Exception as e: 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( return ProvisionStatusResponse(
status="error", status="error",
message=f"Failed to check login status: {e}", 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() init_response = await flow_client.initiate()
except Exception as e: 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( return UpdateScopesResponse(
status="error", status="error",
message=f"Failed to start re-provisioning flow: {e}", message=f"Failed to start re-provisioning flow: {e}",
+7 -7
View File
@@ -215,7 +215,7 @@ def configure_calendar_tools(mcp: FastMCP):
try: try:
start_datetime = dt.datetime.fromisoformat(start_date) start_datetime = dt.datetime.fromisoformat(start_date)
except ValueError: except ValueError:
logger.warning(f"Invalid start_date format: {start_date}") logger.warning("Invalid start_date format: %s", start_date)
if end_date: if end_date:
try: try:
@@ -228,7 +228,7 @@ def configure_calendar_tools(mcp: FastMCP):
try: try:
end_datetime = dt.datetime.fromisoformat(end_date) end_datetime = dt.datetime.fromisoformat(end_date)
except ValueError: except ValueError:
logger.warning(f"Invalid end_date format: {end_date}") logger.warning("Invalid end_date format: %s", end_date)
# Build filters dictionary # Build filters dictionary
filters = {} filters = {}
@@ -519,7 +519,7 @@ def configure_calendar_tools(mcp: FastMCP):
all_events.extend(cal_events) all_events.extend(cal_events)
except Exception as e: except Exception as e:
logger.warning( logger.warning(
f"Error getting events from calendar {calendar['name']}: {e}" "Error getting events from calendar %s: %s", calendar["name"], e
) )
continue continue
@@ -593,7 +593,7 @@ def configure_calendar_tools(mcp: FastMCP):
try: try:
start_datetime = dt.datetime.strptime(date_range_start, "%Y-%m-%d") start_datetime = dt.datetime.strptime(date_range_start, "%Y-%m-%d")
except ValueError: 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: if date_range_end:
try: try:
@@ -601,7 +601,7 @@ def configure_calendar_tools(mcp: FastMCP):
hour=23, minute=59, second=59 hour=23, minute=59, second=59
) )
except ValueError: 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 # Build constraints
constraints = { constraints = {
@@ -686,7 +686,7 @@ def configure_calendar_tools(mcp: FastMCP):
try: try:
start_datetime = dt.datetime.strptime(start_date, "%Y-%m-%d") start_datetime = dt.datetime.strptime(start_date, "%Y-%m-%d")
except ValueError: except ValueError:
logger.warning(f"Invalid start_date format: {start_date}") logger.warning("Invalid start_date format: %s", start_date)
if end_date: if end_date:
try: try:
@@ -694,7 +694,7 @@ def configure_calendar_tools(mcp: FastMCP):
hour=23, minute=59, second=59 hour=23, minute=59, second=59
) )
except ValueError: except ValueError:
logger.warning(f"Invalid end_date format: {end_date}") logger.warning("Invalid end_date format: %s", end_date)
# Build filter criteria # Build filter criteria
filter_criteria = {} filter_criteria = {}
+3 -3
View File
@@ -266,7 +266,7 @@ async def _provision_nextcloud_access(ctx: Context, user_id: str) -> Provisionin
) )
except Exception as e: except Exception as e:
logger.error(f"Failed to initiate provisioning: {e}") logger.error("Failed to initiate provisioning: %s", e)
return ProvisioningResult( return ProvisioningResult(
success=False, success=False,
message=f"Failed to initiate provisioning: {str(e)}", 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: except Exception as e:
logger.error(f"Failed to revoke access: {e}") logger.error("Failed to revoke access: %s", e)
return RevocationResult( return RevocationResult(
success=False, success=False,
message=f"Failed to revoke access: {str(e)}", 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." return "Login cancelled by user."
except Exception as e: 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)}" return f"Error checking login status: {str(e)}"
@@ -72,7 +72,7 @@ async def parse_document(
registry = get_registry() registry = get_registry()
logger.debug(f"Parsing document of type '{content_type}'") logger.debug("Parsing document of type '%s'", content_type)
try: try:
# Process using registry (auto-selects processor based on MIME type) # Process using registry (auto-selects processor based on MIME type)
@@ -83,12 +83,14 @@ async def parse_document(
progress_callback=progress_callback, 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 return result.text, result.metadata
except ProcessorError as e: except ProcessorError as e:
logger.error(f"Document processing failed: {e}") logger.error("Document processing failed: %s", e)
# Fallback to base64 with error metadata # Fallback to base64 with error metadata
parsed_text = f"Document could not be parsed. Base64 content: {base64.b64encode(content).decode('ascii')[:200]}..." parsed_text = f"Document could not be parsed. Base64 content: {base64.b64encode(content).decode('ascii')[:200]}..."
metadata = { metadata = {
@@ -91,7 +91,9 @@ class DocumentChunker:
] ]
logger.debug( logger.debug(
f"Chunked document into {len(chunks)} chunks " "Chunked document into %s chunks (chunk_size=%s, overlap=%s)",
f"(chunk_size={self.chunk_size}, overlap={self.overlap})" len(chunks),
self.chunk_size,
self.overlap,
) )
return chunks return chunks
@@ -42,7 +42,7 @@ def html_to_markdown(html_content: str | None) -> str:
) )
return markdown.strip() return markdown.strip()
except Exception as e: 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 # Fallback: strip all HTML tags as a last resort
text = re.sub(r"<[^>]+>", " ", html_content) text = re.sub(r"<[^>]+>", " ", html_content)
+55 -36
View File
@@ -90,7 +90,7 @@ async def get_user_client_basic_auth(
f"User must configure background sync in Astrolabe personal settings." 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( return NextcloudClient(
base_url=nextcloud_host, base_url=nextcloud_host,
username=user_id, username=user_id,
@@ -120,7 +120,7 @@ async def user_scanner_task(
nextcloud_host: Nextcloud base URL nextcloud_host: Nextcloud base URL
task_status: Status object for signaling task readiness task_status: Status object for signaling task readiness
""" """
logger.info(f"[BasicAuth] Scanner started for user: {user_id}") logger.info("[BasicAuth] Scanner started for user: %s", user_id)
settings = get_settings() settings = get_settings()
max_consecutive_errors = 5 max_consecutive_errors = 5
@@ -131,12 +131,13 @@ async def user_scanner_task(
nc_client = await get_user_client_basic_auth(user_id, nextcloud_host) nc_client = await get_user_client_basic_auth(user_id, nextcloud_host)
try: try:
await nc_client.capabilities() # Lightweight OCS call to validate creds await nc_client.capabilities() # Lightweight OCS call to validate creds
logger.info(f"[BasicAuth] Credentials validated for {user_id}") logger.info("[BasicAuth] Credentials validated for %s", user_id)
except HTTPStatusError as e: except HTTPStatusError as e:
if e.response.status_code in (401, 403): if e.response.status_code in (401, 403):
logger.warning( logger.warning(
f"[BasicAuth] Credential validation failed for {user_id} " "[BasicAuth] Credential validation failed for %s (HTTP %s), not starting scan loop",
f"(HTTP {e.response.status_code}), not starting scan loop" user_id,
e.response.status_code,
) )
return return
raise raise
@@ -144,13 +145,14 @@ async def user_scanner_task(
await nc_client.close() await nc_client.close()
except NotProvisionedError: except NotProvisionedError:
logger.warning( logger.warning(
f"[BasicAuth] User {user_id} not provisioned, not starting scan loop" "[BasicAuth] User %s not provisioned, not starting scan loop", user_id
) )
return return
except Exception as e: except Exception as e:
logger.warning( logger.warning(
f"[BasicAuth] Pre-validation failed for {user_id}: {e}. " "[BasicAuth] Pre-validation failed for %s: %s. Proceeding to scan loop (has its own error handling).",
f"Proceeding to scan loop (has its own error handling)." user_id,
e,
) )
consecutive_errors = 0 consecutive_errors = 0
@@ -172,7 +174,7 @@ async def user_scanner_task(
except NotProvisionedError: except NotProvisionedError:
logger.warning( logger.warning(
f"[BasicAuth] User {user_id} no longer provisioned, stopping scanner" "[BasicAuth] User %s no longer provisioned, stopping scanner", user_id
) )
break break
@@ -180,16 +182,17 @@ async def user_scanner_task(
status_code = e.response.status_code status_code = e.response.status_code
if status_code in (401, 403): if status_code in (401, 403):
logger.warning( logger.warning(
f"[BasicAuth] Scanner auth failed for {user_id} " "[BasicAuth] Scanner auth failed for %s (HTTP %s), stopping scanner. User may need to re-provision credentials.",
f"(HTTP {status_code}), stopping scanner. " user_id,
f"User may need to re-provision credentials." status_code,
) )
break break
elif status_code == 429: elif status_code == 429:
retry_after = min(int(e.response.headers.get("Retry-After", "60")), 300) retry_after = min(int(e.response.headers.get("Retry-After", "60")), 300)
logger.warning( logger.warning(
f"[BasicAuth] Scanner rate-limited for {user_id}, " "[BasicAuth] Scanner rate-limited for %s, backing off %ss",
f"backing off {retry_after}s" user_id,
retry_after,
) )
try: try:
with anyio.move_on_after(retry_after): with anyio.move_on_after(retry_after):
@@ -202,16 +205,22 @@ async def user_scanner_task(
else: else:
consecutive_errors += 1 consecutive_errors += 1
logger.error( logger.error(
f"[BasicAuth] Scanner HTTP error for {user_id}: {e} " "[BasicAuth] Scanner HTTP error for %s: %s (%s/%s)",
f"({consecutive_errors}/{max_consecutive_errors})", user_id,
e,
consecutive_errors,
max_consecutive_errors,
exc_info=True, exc_info=True,
) )
except Exception as e: except Exception as e:
consecutive_errors += 1 consecutive_errors += 1
logger.error( logger.error(
f"[BasicAuth] Scanner error for {user_id}: {e} " "[BasicAuth] Scanner error for %s: %s (%s/%s)",
f"({consecutive_errors}/{max_consecutive_errors})", user_id,
e,
consecutive_errors,
max_consecutive_errors,
exc_info=True, exc_info=True,
) )
@@ -221,8 +230,9 @@ async def user_scanner_task(
if consecutive_errors >= max_consecutive_errors: if consecutive_errors >= max_consecutive_errors:
logger.error( logger.error(
f"[BasicAuth] Scanner for {user_id} hit {max_consecutive_errors} " "[BasicAuth] Scanner for %s hit %s consecutive errors, stopping scanner",
f"consecutive errors, stopping scanner" user_id,
max_consecutive_errors,
) )
break break
@@ -233,7 +243,7 @@ async def user_scanner_task(
except anyio.get_cancelled_exc_class(): except anyio.get_cancelled_exc_class():
break break
logger.info(f"[BasicAuth] Scanner stopped for user: {user_id}") logger.info("[BasicAuth] Scanner stopped for user: %s", user_id)
async def multi_user_processor_task( async def multi_user_processor_task(
@@ -255,7 +265,7 @@ async def multi_user_processor_task(
nextcloud_host: Nextcloud base URL nextcloud_host: Nextcloud base URL
task_status: Status object for signaling task readiness task_status: Status object for signaling task readiness
""" """
logger.info(f"[BasicAuth] Processor {worker_id} started") logger.info("[BasicAuth] Processor %s started", worker_id)
task_status.started() task_status.started()
while not shutdown_event.is_set(): while not shutdown_event.is_set():
@@ -278,34 +288,39 @@ async def multi_user_processor_task(
continue continue
except anyio.EndOfStream: except anyio.EndOfStream:
logger.info(f"[BasicAuth] Processor {worker_id}: Stream closed, exiting") logger.info("[BasicAuth] Processor %s: Stream closed, exiting", worker_id)
break break
except NotProvisionedError: except NotProvisionedError:
if doc_task: if doc_task:
logger.warning( logger.warning(
f"[BasicAuth] User {doc_task.user_id} not provisioned, " "[BasicAuth] User %s not provisioned, skipping %s_%s",
f"skipping {doc_task.doc_type}_{doc_task.doc_id}" doc_task.user_id,
doc_task.doc_type,
doc_task.doc_id,
) )
continue continue
except Exception as e: except Exception as e:
if doc_task: if doc_task:
logger.error( logger.error(
f"[BasicAuth] Processor {worker_id} error processing " "[BasicAuth] Processor %s error processing %s_%s: %s",
f"{doc_task.doc_type}_{doc_task.doc_id}: {e}", worker_id,
doc_task.doc_type,
doc_task.doc_id,
e,
exc_info=True, exc_info=True,
) )
else: else:
logger.error( logger.error(
f"[BasicAuth] Processor {worker_id} error: {e}", exc_info=True "[BasicAuth] Processor %s error: %s", worker_id, e, exc_info=True
) )
finally: finally:
if nc_client: if nc_client:
await nc_client.close() await nc_client.close()
logger.info(f"[BasicAuth] Processor {worker_id} stopped") logger.info("[BasicAuth] Processor %s stopped", worker_id)
# Backward compatibility alias # Backward compatibility alias
@@ -372,7 +387,7 @@ async def user_manager_task(
settings = get_settings() settings = get_settings()
poll_interval = settings.vector_sync_user_poll_interval poll_interval = settings.vector_sync_user_poll_interval
logger.info(f"[BasicAuth] User manager started (poll interval: {poll_interval}s)") logger.info("[BasicAuth] User manager started (poll interval: %ss)", poll_interval)
task_status.started() task_status.started()
while not shutdown_event.is_set(): while not shutdown_event.is_set():
@@ -389,7 +404,8 @@ async def user_manager_task(
new_users = provisioned_users - active_users new_users = provisioned_users - active_users
for user_id in new_users: for user_id in new_users:
logger.info( logger.info(
f"[BasicAuth] Starting scanner for newly provisioned user: {user_id}" "[BasicAuth] Starting scanner for newly provisioned user: %s",
user_id,
) )
cancel_scope = anyio.CancelScope() cancel_scope = anyio.CancelScope()
user_states[user_id] = UserSyncState( user_states[user_id] = UserSyncState(
@@ -412,19 +428,21 @@ async def user_manager_task(
# Cancel scanners for revoked users # Cancel scanners for revoked users
revoked_users = active_users - provisioned_users revoked_users = active_users - provisioned_users
for user_id in revoked_users: for user_id in revoked_users:
logger.info(f"[BasicAuth] Stopping scanner for revoked user: {user_id}") logger.info(
"[BasicAuth] Stopping scanner for revoked user: %s", user_id
)
state = user_states.get(user_id) state = user_states.get(user_id)
if state: if state:
state.cancel_scope.cancel() state.cancel_scope.cancel()
# Note: state will be removed by _run_user_scanner_with_scope on exit # Note: state will be removed by _run_user_scanner_with_scope on exit
if new_users: if new_users:
logger.info(f"[BasicAuth] Started {len(new_users)} new scanner(s)") logger.info("[BasicAuth] Started %s new scanner(s)", len(new_users))
if revoked_users: if revoked_users:
logger.info(f"[BasicAuth] Stopped {len(revoked_users)} scanner(s)") logger.info("[BasicAuth] Stopped %s scanner(s)", len(revoked_users))
except Exception as e: except Exception as e:
logger.error(f"[BasicAuth] User manager error: {e}", exc_info=True) logger.error("[BasicAuth] User manager error: %s", e, exc_info=True)
# Sleep until next poll # Sleep until next poll
try: try:
@@ -435,7 +453,8 @@ async def user_manager_task(
# Cancel all remaining scanners on shutdown # Cancel all remaining scanners on shutdown
logger.info( logger.info(
f"[BasicAuth] User manager shutting down, cancelling {len(user_states)} scanner(s)" "[BasicAuth] User manager shutting down, cancelling %s scanner(s)",
len(user_states),
) )
for state in list(user_states.values()): for state in list(user_states.values()):
state.cancel_scope.cancel() state.cancel_scope.cancel()
+5 -3
View File
@@ -92,9 +92,11 @@ class PCA:
self.explained_variance_ratio_ = np.zeros(self.n_components) self.explained_variance_ratio_ = np.zeros(self.n_components)
logger.debug( logger.debug(
f"PCA fit: {n_samples} samples, {n_features} features → " "PCA fit: %s samples, %s features → %s components, explained variance: %s",
f"{self.n_components} components, " n_samples,
f"explained variance: {self.explained_variance_ratio_}" n_features,
self.n_components,
self.explained_variance_ratio_,
) )
return self return self
+25 -9
View File
@@ -122,13 +122,19 @@ async def write_placeholder_point(
) )
logger.debug( logger.debug(
f"Wrote placeholder for {doc_type}_{doc_id} (user={user_id}, " "Wrote placeholder for %s_%s (user=%s, modified_at=%s)",
f"modified_at={modified_at})" doc_type,
doc_id,
user_id,
modified_at,
) )
except Exception as e: except Exception as e:
logger.error( 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, exc_info=True,
) )
raise raise
@@ -180,7 +186,9 @@ async def query_document_metadata(
return None return None
except Exception as e: 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 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: except Exception as e:
logger.error( 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, exc_info=True,
) )
raise raise
@@ -271,13 +284,16 @@ async def update_placeholder_status(
) )
logger.debug( logger.debug(
f"Updated placeholder status for {doc_type}_{doc_id} to '{status}' " "Updated placeholder status for %s_%s to '%s' (user=%s)",
f"(user={user_id})" doc_type,
doc_id,
status,
user_id,
) )
except Exception as e: except Exception as e:
logger.warning( 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 # Don't raise - status updates are non-critical
+78 -37
View File
@@ -100,7 +100,7 @@ async def processor_task(
user_id: User being processed user_id: User being processed
task_status: Status object for signaling task readiness 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 # Signal that the task has started and is ready
task_status.started() task_status.started()
@@ -130,18 +130,21 @@ async def processor_task(
except anyio.EndOfStream: except anyio.EndOfStream:
# Scanner finished and closed stream, exit gracefully # 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 break
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Processor {worker_id} error processing " "Processor %s error processing %s_%s: %s",
f"{doc_task.doc_type}_{doc_task.doc_id}: {e}", worker_id,
doc_task.doc_type,
doc_task.doc_id,
e,
exc_info=True, exc_info=True,
) )
# Continue to next document (no task_done() needed with streams) # 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): 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() start_time = time.time()
logger.debug( logger.debug(
f"Processing {doc_task.doc_type}_{doc_task.doc_id} " "Processing %s_%s for %s (%s)",
f"for {doc_task.user_id} ({doc_task.operation})" doc_task.doc_type,
doc_task.doc_id,
doc_task.user_id,
doc_task.operation,
) )
with trace_operation( with trace_operation(
@@ -197,7 +203,10 @@ async def process_document(doc_task: DocumentTask, nc_client: NextcloudClient):
), ),
) )
logger.info( 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 # Record successful deletion metrics
@@ -223,15 +232,22 @@ async def process_document(doc_task: DocumentTask, nc_client: NextcloudClient):
except (HTTPStatusError, Exception) as e: except (HTTPStatusError, Exception) as e:
if attempt < max_retries - 1: if attempt < max_retries - 1:
logger.warning( logger.warning(
f"Retry {attempt + 1}/{max_retries} for " "Retry %s/%s for %s_%s: %s",
f"{doc_task.doc_type}_{doc_task.doc_id}: {e}" attempt + 1,
max_retries,
doc_task.doc_type,
doc_task.doc_id,
e,
) )
await anyio.sleep(retry_delay) await anyio.sleep(retry_delay)
retry_delay *= 2 # Exponential backoff retry_delay *= 2 # Exponential backoff
else: else:
logger.error( logger.error(
f"Failed to index {doc_task.doc_type}_{doc_task.doc_id} " "Failed to index %s_%s after %s retries: %s",
f"after {max_retries} retries: {e}" doc_task.doc_type,
doc_task.doc_id,
max_retries,
e,
) )
# Record failed processing metrics # Record failed processing metrics
duration = time.time() - start_time duration = time.time() - start_time
@@ -348,7 +364,11 @@ async def _index_document(
break break
except Exception as e: except Exception as e:
logger.warning( 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) # 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: if "page_boundaries" in file_metadata:
page_boundaries = file_metadata["page_boundaries"] page_boundaries = file_metadata["page_boundaries"]
logger.info( logger.info(
f"Page boundaries for {file_path}: " "Page boundaries for %s: %s pages, text length: %s",
f"{len(page_boundaries)} pages, text length: {len(content)}" file_path,
len(page_boundaries),
len(content),
) )
# Log first 3 page boundaries for debugging # Log first 3 page boundaries for debugging
for boundary in page_boundaries[:3]: for boundary in page_boundaries[:3]:
logger.debug( logger.debug(
f" Page {boundary['page']}: " " Page %s: offsets [%s:%s]",
f"offsets [{boundary['start_offset']}:{boundary['end_offset']}]" boundary["page"],
boundary["start_offset"],
boundary["end_offset"],
) )
# Verify last boundary matches text length # Verify last boundary matches text length
if page_boundaries: if page_boundaries:
last_boundary = page_boundaries[-1] last_boundary = page_boundaries[-1]
if last_boundary["end_offset"] != len(content): if last_boundary["end_offset"] != len(content):
logger.warning( logger.warning(
f"Text length mismatch: content={len(content)}, " "Text length mismatch: content=%s, last_boundary_end=%s",
f"last_boundary_end={last_boundary['end_offset']}" len(content),
last_boundary["end_offset"],
) )
else: 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: 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 raise
# Tokenize and chunk (using configured chunk size and overlap) # Tokenize and chunk (using configured chunk size and overlap)
@@ -509,26 +534,32 @@ async def _index_document(
# Diagnostic: Verify page number assignment # Diagnostic: Verify page number assignment
assigned_count = sum(1 for c in chunks if c.page_number is not None) assigned_count = sum(1 for c in chunks if c.page_number is not None)
logger.info( logger.info(
f"Assigned page numbers to {assigned_count}/{len(chunks)} chunks " "Assigned page numbers to %s/%s chunks for %s",
f"for {file_path}" assigned_count,
len(chunks),
file_path,
) )
# Log first 3 chunks to see their page assignments # Log first 3 chunks to see their page assignments
for i, chunk in enumerate(chunks[:3]): for i, chunk in enumerate(chunks[:3]):
logger.debug( logger.debug(
f" Chunk {i}: page={chunk.page_number}, " " Chunk %s: page=%s, offsets=[%s:%s]",
f"offsets=[{chunk.start_offset}:{chunk.end_offset}]" i,
chunk.page_number,
chunk.start_offset,
chunk.end_offset,
) )
# Warning if NO page numbers were assigned # Warning if NO page numbers were assigned
if assigned_count == 0: if assigned_count == 0:
logger.warning( logger.warning(
f"NO page numbers assigned! " "NO page numbers assigned! Text length: %s, Chunks: %s, Chunk offset range: [%s:%s], Page boundaries: %s pages, First boundary: %s",
f"Text length: {len(content)}, " len(content),
f"Chunks: {len(chunks)}, " len(chunks),
f"Chunk offset range: [{chunks[0].start_offset}:{chunks[-1].end_offset}], " chunks[0].start_offset,
f"Page boundaries: {len(page_boundaries_list)} pages, " chunks[-1].end_offset,
f"First boundary: {page_boundaries_list[0] if page_boundaries_list else 'None'}" len(page_boundaries_list),
page_boundaries_list[0] if page_boundaries_list else "None",
) )
# Extract chunk texts for embedding # Extract chunk texts for embedding
@@ -603,7 +634,7 @@ async def _index_document(
page_boundaries_list = cast(list[dict[str, Any]], page_boundaries) 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] batch_results = await anyio.to_thread.run_sync( # type: ignore[attr-defined]
lambda: PDFHighlighter.compute_chunk_bboxes_batch( lambda: PDFHighlighter.compute_chunk_bboxes_batch(
@@ -617,7 +648,9 @@ async def _index_document(
for chunk_index, (bboxes, _) in batch_results.items(): for chunk_index, (bboxes, _) in batch_results.items():
chunk_bboxes[chunk_index] = bboxes 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 # Run all embedding/highlighting operations in parallel
# - Dense embeddings: I/O bound (API call) # - Dense embeddings: I/O bound (API call)
@@ -753,7 +786,10 @@ async def _index_document(
except Exception as e: except Exception as e:
# Log but don't fail indexing if placeholder deletion fails # Log but don't fail indexing if placeholder deletion fails
logger.warning( 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, # 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): if batch_end < len(points):
logger.debug( 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( logger.info(
f"Indexed {doc_task.doc_type}_{doc_task.doc_id} for {doc_task.user_id} " "Indexed %s_%s for %s (%s chunks)",
f"({len(chunks)} chunks)" doc_task.doc_type,
doc_task.doc_id,
doc_task.user_id,
len(chunks),
) )
+18 -16
View File
@@ -534,7 +534,7 @@ async def get_qdrant_client() -> AsyncQdrantClient:
# Detect mode and initialize client accordingly # Detect mode and initialize client accordingly
if settings.qdrant_url: if settings.qdrant_url:
# Network mode # Network mode
logger.info(f"Using Qdrant network mode: {settings.qdrant_url}") logger.info("Using Qdrant network mode: %s", settings.qdrant_url)
provisional = AsyncQdrantClient( provisional = AsyncQdrantClient(
url=settings.qdrant_url, url=settings.qdrant_url,
api_key=settings.qdrant_api_key, api_key=settings.qdrant_api_key,
@@ -548,7 +548,7 @@ async def get_qdrant_client() -> AsyncQdrantClient:
else: else:
# Persistent local mode - use path parameter # Persistent local mode - use path parameter
logger.info( logger.info(
f"Using Qdrant persistent mode: {settings.qdrant_location}" "Using Qdrant persistent mode: %s", settings.qdrant_location
) )
provisional = AsyncQdrantClient(path=settings.qdrant_location) provisional = AsyncQdrantClient(path=settings.qdrant_location)
else: else:
@@ -580,14 +580,14 @@ async def get_qdrant_client() -> AsyncQdrantClient:
# `get_collection()`) is the only existence-probe permitted on a # `get_collection()`) is the only existence-probe permitted on a
# collection-scoped JWT — it returns 200 with the collection # collection-scoped JWT — it returns 200 with the collection
# detail on hit and 404 on miss. # 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 collection_info = None
try: try:
collection_info = await provisional.get_collection(collection_name) collection_info = await provisional.get_collection(collection_name)
except UnexpectedResponse as exc: except UnexpectedResponse as exc:
if exc.status_code != 404: if exc.status_code != 404:
raise raise
logger.debug(f"Collection '{collection_name}' not found (404).") logger.debug("Collection '%s' not found (404).", collection_name)
except ValueError as exc: except ValueError as exc:
# Local/in-memory qdrant_client raises ValueError(f"Collection # Local/in-memory qdrant_client raises ValueError(f"Collection
# {name} not found") instead of UnexpectedResponse — see # {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. # multi-user-basic CI jobs all exercise this path.
if "not found" not in str(exc): if "not found" not in str(exc):
raise 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: if collection_info is not None:
# Collection exists - validate dimensions # Collection exists - validate dimensions
logger.debug( 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 # Handle both named vectors (dict) and legacy single vector
vectors = collection_info.config.params.vectors vectors = collection_info.config.params.vectors
@@ -633,8 +633,10 @@ async def get_qdrant_client() -> AsyncQdrantClient:
) )
logger.info( logger.info(
f"Using existing Qdrant collection: {collection_name} " "Using existing Qdrant collection: %s (dimension=%s, model=%s)",
f"(dimension={actual_dimension}, model={settings.get_embedding_model_name()})" collection_name,
actual_dimension,
settings.get_embedding_model_name(),
) )
# Existing collections may pre-date the doc_id normalization / # 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 # Collection doesn't exist - create it
embedding_model = settings.get_embedding_model_name() embedding_model = settings.get_embedding_model_name()
logger.info( logger.info(
f"Collection '{collection_name}' not found, creating with " "Collection '%s' not found, creating with dimension=%s, model=%s...",
f"dimension={expected_dimension}, model={embedding_model}..." collection_name,
expected_dimension,
embedding_model,
) )
await provisional.create_collection( await provisional.create_collection(
collection_name=collection_name, collection_name=collection_name,
@@ -678,12 +682,10 @@ async def get_qdrant_client() -> AsyncQdrantClient:
}, },
) )
logger.info( logger.info(
f"Created Qdrant collection: {collection_name}\n" "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.",
f" Dense vector dimension: {expected_dimension}\n" collection_name,
f" Dense embedding model: {embedding_model}\n" expected_dimension,
f" Sparse vectors: BM25 (for hybrid search)\n" embedding_model,
f" Distance: COSINE\n"
f"Background sync will index all documents with dense + sparse vectors."
) )
# Freshly created collection has no payload schema yet; pass # Freshly created collection has no payload schema yet; pass
# {} explicitly to skip the otherwise-redundant # {} explicitly to skip the otherwise-redundant
+102 -55
View File
@@ -155,7 +155,7 @@ async def get_last_indexed_timestamp(user_id: str) -> int | None:
) )
num_points = len(points) 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: if points:
timestamps = [ timestamps = [
@@ -165,14 +165,16 @@ async def get_last_indexed_timestamp(user_id: str) -> int | None:
] ]
max_timestamp = max(timestamps) if timestamps else 0 max_timestamp = max(timestamps) if timestamps else 0
logger.info( 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 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 return None
except Exception as e: 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 return None
@@ -198,7 +200,7 @@ async def scanner_task(
user_id: User to scan user_id: User to scan
task_status: Status object for signaling task readiness 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() settings = get_settings()
# Signal that the task has started and is ready # Signal that the task has started and is ready
@@ -215,7 +217,7 @@ async def scanner_task(
) )
except Exception as e: 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 # Sleep until next interval or wake event
try: try:
@@ -247,7 +249,10 @@ async def scan_user_documents(
scan_id = random.randint(1000, 9999) scan_id = random.randint(1000, 9999)
logger.info( 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( with trace_operation(
@@ -266,7 +271,9 @@ async def scan_user_documents(
) )
if prune_before: if prune_before:
logger.info( 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) # 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 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 # Stream notes from Nextcloud and process immediately
note_count = 0 note_count = 0
@@ -341,7 +348,8 @@ async def scan_user_documents(
doc_key = (user_id, doc_id) doc_key = (user_id, doc_id)
if doc_key in _potentially_deleted: if doc_key in _potentially_deleted:
logger.debug( 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] del _potentially_deleted[doc_key]
@@ -368,14 +376,17 @@ async def scan_user_documents(
stale_threshold = get_settings().vector_sync_scan_interval * 5 stale_threshold = get_settings().vector_sync_scan_interval * 5
if placeholder_age > stale_threshold: if placeholder_age > stale_threshold:
logger.debug( logger.debug(
f"Found stale placeholder for note {doc_id} " "Found stale placeholder for note %s (age=%ss), requeuing",
f"(age={placeholder_age:.1f}s), requeuing" doc_id,
format(placeholder_age, ".1f"),
) )
needs_indexing = True needs_indexing = True
else: else:
logger.debug( logger.debug(
f"Skipping note {doc_id} with recent placeholder " "Skipping note %s with recent placeholder (age=%ss < %ss)",
f"(age={placeholder_age:.1f}s < {stale_threshold:.1f}s)" doc_id,
format(placeholder_age, ".1f"),
format(stale_threshold, ".1f"),
) )
if needs_indexing: if needs_indexing:
@@ -399,11 +410,11 @@ async def scan_user_documents(
queued += 1 queued += 1
# Log and record metrics after streaming # 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) record_vector_sync_scan(note_count)
if initial_sync: 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 return
# Check for deleted documents (in Qdrant but not in Nextcloud) # Check for deleted documents (in Qdrant but not in Nextcloud)
@@ -426,8 +437,10 @@ async def scan_user_documents(
if time_missing >= grace_period: if time_missing >= grace_period:
# Grace period elapsed, send for deletion # Grace period elapsed, send for deletion
logger.info( logger.info(
f"Document {doc_id} missing for {time_missing:.1f}s " "Document %s missing for %ss (>%ss grace period), sending deletion",
f"(>{grace_period:.1f}s grace period), sending deletion" doc_id,
format(time_missing, ".1f"),
format(grace_period, ".1f"),
) )
await send_stream.send( await send_stream.send(
DocumentTask( DocumentTask(
@@ -443,13 +456,16 @@ async def scan_user_documents(
del _potentially_deleted[doc_key] del _potentially_deleted[doc_key]
else: else:
logger.debug( logger.debug(
f"Document {doc_id} still missing " "Document %s still missing (%ss/%ss grace period)",
f"({time_missing:.1f}s/{grace_period:.1f}s grace period)" doc_id,
format(time_missing, ".1f"),
format(grace_period, ".1f"),
) )
else: else:
# First time missing, add to grace period tracking # First time missing, add to grace period tracking
logger.debug( 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 _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 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 # Scan for tagged PDF files
file_count = 0 file_count = 0
@@ -568,7 +584,9 @@ async def scan_user_documents(
file_key = (user_id, file_id) file_key = (user_id, file_id)
if file_key in _potentially_deleted: if file_key in _potentially_deleted:
logger.debug( 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] del _potentially_deleted[file_key]
@@ -595,14 +613,19 @@ async def scan_user_documents(
stale_threshold = get_settings().vector_sync_scan_interval * 5 stale_threshold = get_settings().vector_sync_scan_interval * 5
if placeholder_age > stale_threshold: if placeholder_age > stale_threshold:
logger.debug( logger.debug(
f"Found stale placeholder for file {file_path} (ID: {file_id}) " "Found stale placeholder for file %s (ID: %s) (age=%ss), requeuing",
f"(age={placeholder_age:.1f}s), requeuing" file_path,
file_id,
format(placeholder_age, ".1f"),
) )
needs_indexing = True needs_indexing = True
else: else:
logger.debug( logger.debug(
f"Skipping file {file_path} (ID: {file_id}) with recent placeholder " "Skipping file %s (ID: %s) with recent placeholder (age=%ss < %ss)",
f"(age={placeholder_age:.1f}s < {stale_threshold:.1f}s)" file_path,
file_id,
format(placeholder_age, ".1f"),
format(stale_threshold, ".1f"),
) )
if needs_indexing: if needs_indexing:
@@ -627,7 +650,7 @@ async def scan_user_documents(
file_queued += 1 file_queued += 1
logger.info( 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) record_vector_sync_scan(file_count)
@@ -645,8 +668,10 @@ async def scan_user_documents(
if time_missing >= grace_period: if time_missing >= grace_period:
# Grace period elapsed, send for deletion # Grace period elapsed, send for deletion
logger.info( logger.info(
f"File ID {file_id} missing for {time_missing:.1f}s " "File ID %s missing for %ss (>%ss grace period), sending deletion",
f"(>{grace_period:.1f}s grace period), sending deletion" file_id,
format(time_missing, ".1f"),
format(grace_period, ".1f"),
) )
await send_stream.send( await send_stream.send(
DocumentTask( DocumentTask(
@@ -662,12 +687,13 @@ async def scan_user_documents(
else: else:
# First time missing, add to grace period tracking # First time missing, add to grace period tracking
logger.debug( 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 _potentially_deleted[file_key] = current_time
except Exception as e: 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 queued += file_queued
@@ -683,7 +709,7 @@ async def scan_user_documents(
) )
queued += news_queued queued += news_queued
except Exception as e: 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 # Scan Deck cards
deck_queued = 0 deck_queued = 0
@@ -697,14 +723,19 @@ async def scan_user_documents(
) )
queued += deck_queued queued += deck_queued
except Exception as e: 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: if queued > 0:
logger.info( 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: else:
logger.debug(f"No changes detected for {user_id}") logger.debug("No changes detected for %s", user_id)
async def scan_news_items( async def scan_news_items(
@@ -754,7 +785,7 @@ async def scan_news_items(
for point in points for point in points
if point.payload is not None and "doc_id" in point.payload 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) # Fetch all items (News app caps at ~200 per feed via auto-purge)
all_items = await nc_client.news.get_items( all_items = await nc_client.news.get_items(
@@ -762,7 +793,7 @@ async def scan_news_items(
type_=NewsItemType.ALL, type_=NewsItemType.ALL,
get_read=True, 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) item_count = len(all_items)
nextcloud_item_ids: set[str] = set() nextcloud_item_ids: set[str] = set()
@@ -800,7 +831,8 @@ async def scan_news_items(
doc_key = (user_id, doc_id) doc_key = (user_id, doc_id)
if doc_key in _potentially_deleted: if doc_key in _potentially_deleted:
logger.debug( 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] del _potentially_deleted[doc_key]
@@ -820,8 +852,9 @@ async def scan_news_items(
stale_threshold = settings.vector_sync_scan_interval * 5 stale_threshold = settings.vector_sync_scan_interval * 5
if placeholder_age > stale_threshold: if placeholder_age > stale_threshold:
logger.debug( logger.debug(
f"Found stale placeholder for news item {doc_id} " "Found stale placeholder for news item %s (age=%ss), requeuing",
f"(age={placeholder_age:.1f}s), requeuing" doc_id,
format(placeholder_age, ".1f"),
) )
needs_indexing = True needs_indexing = True
@@ -844,7 +877,10 @@ async def scan_news_items(
queued += 1 queued += 1
logger.info( 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) record_vector_sync_scan(item_count)
@@ -864,8 +900,10 @@ async def scan_news_items(
if time_missing >= grace_period: if time_missing >= grace_period:
logger.info( logger.info(
f"News item {doc_id} missing for {time_missing:.1f}s " "News item %s missing for %ss (>%ss grace period), sending deletion",
f"(>{grace_period:.1f}s grace period), sending deletion" doc_id,
format(time_missing, ".1f"),
format(grace_period, ".1f"),
) )
await send_stream.send( await send_stream.send(
DocumentTask( DocumentTask(
@@ -880,7 +918,8 @@ async def scan_news_items(
del _potentially_deleted[doc_key] del _potentially_deleted[doc_key]
else: else:
logger.debug( 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 _potentially_deleted[doc_key] = current_time
@@ -932,11 +971,11 @@ async def scan_deck_cards(
for point in points for point in points
if point.payload is not None and "doc_id" in point.payload 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 # Fetch all boards
boards = await nc_client.deck.get_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 card_count = 0
nextcloud_card_ids: set[str] = set() nextcloud_card_ids: set[str] = set()
@@ -949,7 +988,7 @@ async def scan_deck_cards(
# Skip deleted boards (soft delete: deletedAt > 0) # Skip deleted boards (soft delete: deletedAt > 0)
if board.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 continue
# Get stacks for this board # Get stacks for this board
@@ -998,7 +1037,8 @@ async def scan_deck_cards(
doc_key = (user_id, doc_id) doc_key = (user_id, doc_id)
if doc_key in _potentially_deleted: if doc_key in _potentially_deleted:
logger.debug( 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] del _potentially_deleted[doc_key]
@@ -1018,8 +1058,9 @@ async def scan_deck_cards(
stale_threshold = settings.vector_sync_scan_interval * 5 stale_threshold = settings.vector_sync_scan_interval * 5
if placeholder_age > stale_threshold: if placeholder_age > stale_threshold:
logger.debug( logger.debug(
f"Found stale placeholder for deck card {doc_id} " "Found stale placeholder for deck card %s (age=%ss), requeuing",
f"(age={placeholder_age:.1f}s), requeuing" doc_id,
format(placeholder_age, ".1f"),
) )
needs_indexing = True needs_indexing = True
@@ -1043,7 +1084,10 @@ async def scan_deck_cards(
queued += 1 queued += 1
logger.info( 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) record_vector_sync_scan(card_count)
@@ -1062,8 +1106,10 @@ async def scan_deck_cards(
if time_missing >= grace_period: if time_missing >= grace_period:
logger.info( logger.info(
f"Deck card {doc_id} missing for {time_missing:.1f}s " "Deck card %s missing for %ss (>%ss grace period), sending deletion",
f"(>{grace_period:.1f}s grace period), sending deletion" doc_id,
format(time_missing, ".1f"),
format(grace_period, ".1f"),
) )
await send_stream.send( await send_stream.send(
DocumentTask( DocumentTask(
@@ -1078,7 +1124,8 @@ async def scan_deck_cards(
del _potentially_deleted[doc_key] del _potentially_deleted[doc_key]
else: else:
logger.debug( 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 _potentially_deleted[doc_key] = current_time
+16 -13
View File
@@ -92,7 +92,7 @@ async def compute_pca_coordinates(
if embedding_dim is None: if embedding_dim is None:
return {"coordinates_3d": [], "query_coords": []} 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) # Build chunk vectors array in search_results order (1:1 mapping)
chunk_vectors = [] chunk_vectors = []
@@ -103,7 +103,7 @@ async def compute_pca_coordinates(
else: else:
# Chunk not found in vectors (shouldn't happen) # Chunk not found in vectors (shouldn't happen)
logger.warning( 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)) chunk_vectors.append(np.zeros(embedding_dim))
@@ -129,17 +129,19 @@ async def compute_pca_coordinates(
if zero_norm_mask.any(): if zero_norm_mask.any():
zero_indices = np.where(zero_norm_mask)[0] zero_indices = np.where(zero_norm_mask)[0]
logger.warning( logger.warning(
f"Found {zero_norm_mask.sum()} zero-norm vectors at indices " "Found %s zero-norm vectors at indices %s. Replacing with small epsilon to avoid division by zero.",
f"{zero_indices.tolist()}. Replacing with small epsilon to avoid " zero_norm_mask.sum(),
"division by zero." zero_indices.tolist(),
) )
# Replace zero norms with small epsilon to avoid NaN # Replace zero norms with small epsilon to avoid NaN
norms[zero_norm_mask] = 1e-10 norms[zero_norm_mask] = 1e-10
all_vectors_normalized = all_vectors / norms all_vectors_normalized = all_vectors / norms
logger.info( logger.info(
f"Normalized vectors: query_norm={norms[-1][0]:.3f}, " "Normalized vectors: query_norm=%s, doc_norm_range=[%s, %s]",
f"doc_norm_range=[{norms[:-1].min():.3f}, {norms[:-1].max():.3f}]" format(norms[-1][0], ".3f"),
format(norms[:-1].min(), ".3f"),
format(norms[:-1].max(), ".3f"),
) )
# Apply PCA dimensionality reduction (768-dim → 3D) # Apply PCA dimensionality reduction (768-dim → 3D)
@@ -161,9 +163,9 @@ async def compute_pca_coordinates(
if nan_mask.any(): if nan_mask.any():
nan_rows = np.where(nan_mask.any(axis=1))[0] nan_rows = np.where(nan_mask.any(axis=1))[0]
logger.error( logger.error(
f"Found NaN values in PCA output at {len(nan_rows)} points: " "Found NaN values in PCA output at %s points: %s. Replacing NaN with 0.0 to prevent JSON serialization error.",
f"{nan_rows.tolist()[:10]}. Replacing NaN with 0.0 to prevent " len(nan_rows),
"JSON serialization error." nan_rows.tolist()[:10],
) )
# Replace NaN with 0 to allow JSON serialization # Replace NaN with 0 to allow JSON serialization
coords_3d = np.nan_to_num(coords_3d, nan=0.0) 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 chunk_coords_3d = coords_3d[:-1] # All but last are chunks
logger.info( logger.info(
f"PCA explained variance: PC1={pca.explained_variance_ratio_[0]:.3f}, " "PCA explained variance: PC1=%s, PC2=%s, PC3=%s",
f"PC2={pca.explained_variance_ratio_[1]:.3f}, " format(pca.explained_variance_ratio_[0], ".3f"),
f"PC3={pca.explained_variance_ratio_[2]:.3f}" format(pca.explained_variance_ratio_[1], ".3f"),
format(pca.explained_variance_ratio_[2], ".3f"),
) )
# Coordinates already match search_results order (1:1 mapping) # Coordinates already match search_results order (1:1 mapping)
+1 -1
View File
@@ -95,7 +95,7 @@ update_changelog_on_bump = true
major_version_zero = true major_version_zero = true
[tool.ruff.lint] [tool.ruff.lint]
extend-select = ["I", "PLC0415"] extend-select = ["I", "PLC0415", "G004"]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"tests/**" = ["PLC0415"] "tests/**" = ["PLC0415"]
@@ -44,29 +44,29 @@ async def temporary_event(nc_client: NextcloudClient, temporary_calendar: str):
} }
try: 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) result = await nc_client.calendar.create_event(calendar_name, event_data)
event_uid = result.get("uid") event_uid = result.get("uid")
if not event_uid: if not event_uid:
pytest.fail("Failed to create temporary event") 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} yield {"uid": event_uid, "calendar_name": calendar_name, "data": event_data}
finally: finally:
# Cleanup # Cleanup
if event_uid: if event_uid:
try: 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) 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: except HTTPStatusError as e:
if e.response.status_code != 404: 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: except Exception as e:
logger.error( 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: if not calendars:
pytest.skip("No calendars available - Calendar app may not be enabled") 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 # Check structure of calendars
for calendar in calendars: for calendar in calendars:
@@ -90,7 +90,7 @@ async def test_list_calendars(nc_client: NextcloudClient):
assert "description" in calendar assert "description" in calendar
assert "color" 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( 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] assert result["status_code"] in [200, 201, 204]
event_uid = result["uid"] 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 # Verify event was created by retrieving it
retrieved_event, etag = await nc_client.calendar.get_event( 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) delete_result = await nc_client.calendar.delete_event(calendar_name, event_uid)
assert delete_result["status_code"] in [200, 204, 404] 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: except Exception as e:
logger.error(f"Test failed: {e}") logger.error("Test failed: %s", e)
raise raise
@@ -157,7 +157,7 @@ async def test_create_all_day_event(
try: try:
result = await nc_client.calendar.create_event(calendar_name, event_data) result = await nc_client.calendar.create_event(calendar_name, event_data)
event_uid = result["uid"] 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 # Verify event
retrieved_event, _ = await nc_client.calendar.get_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) await nc_client.calendar.delete_event(calendar_name, event_uid)
except Exception as e: except Exception as e:
logger.error(f"All-day event test failed: {e}") logger.error("All-day event test failed: %s", e)
raise raise
@@ -194,7 +194,7 @@ async def test_create_recurring_event(
try: try:
result = await nc_client.calendar.create_event(calendar_name, event_data) result = await nc_client.calendar.create_event(calendar_name, event_data)
event_uid = result["uid"] 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 # Verify event
retrieved_event, _ = await nc_client.calendar.get_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) await nc_client.calendar.delete_event(calendar_name, event_uid)
except Exception as e: except Exception as e:
logger.error(f"Recurring event test failed: {e}") logger.error("Recurring event test failed: %s", e)
raise raise
@@ -227,7 +227,7 @@ async def test_list_events_in_range(nc_client: NextcloudClient, temporary_event:
) )
assert isinstance(events, list) 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 # Our temporary event should be in the list
event_uids = [event.get("uid") for event in events] 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["location"] == "Updated Location"
assert updated_event["priority"] == 1 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: except Exception as e:
logger.error(f"Event update test failed: {e}") logger.error("Event update test failed: %s", e)
raise raise
@@ -291,7 +291,7 @@ async def test_update_event_extended_fields(
try: try:
result = await nc_client.calendar.create_event(calendar_name, event_data) result = await nc_client.calendar.create_event(calendar_name, event_data)
event_uid = result["uid"] 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 --- # --- Phase 1: Set all four extended fields ---
updated_data = { updated_data = {
@@ -343,7 +343,7 @@ async def test_update_event_extended_fields(
logger.info("Phase 2 passed: all extended fields cleared correctly") logger.info("Phase 2 passed: all extended fields cleared correctly")
except Exception as e: except Exception as e:
logger.error(f"Extended fields update test failed: {e}") logger.error("Extended fields update test failed: %s", e)
raise raise
finally: finally:
if event_uid: if event_uid:
@@ -374,7 +374,7 @@ async def test_create_event_with_attendees(
try: try:
result = await nc_client.calendar.create_event(calendar_name, event_data) result = await nc_client.calendar.create_event(calendar_name, event_data)
event_uid = result["uid"] 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 # Verify event
retrieved_event, _ = await nc_client.calendar.get_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) await nc_client.calendar.delete_event(calendar_name, event_uid)
except Exception as e: except Exception as e:
logger.error(f"Event with attendees test failed: {e}") logger.error("Event with attendees test failed: %s", e)
raise raise
@@ -403,7 +403,7 @@ async def test_get_nonexistent_event(
with pytest.raises(Exception, match="not found"): with pytest.raises(Exception, match="not found"):
await nc_client.calendar.get_event(calendar_name, fake_uid) 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( 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) result = await nc_client.calendar.delete_event(calendar_name, fake_uid)
assert result["status_code"] == 404 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( async def test_event_with_url_and_categories(
@@ -439,7 +439,7 @@ async def test_event_with_url_and_categories(
try: try:
result = await nc_client.calendar.create_event(calendar_name, event_data) result = await nc_client.calendar.create_event(calendar_name, event_data)
event_uid = result["uid"] 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 # Verify event
retrieved_event, _ = await nc_client.calendar.get_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) await nc_client.calendar.delete_event(calendar_name, event_uid)
except Exception as e: except Exception as e:
logger.error(f"Event with metadata test failed: {e}") logger.error("Event with metadata test failed: %s", e)
raise raise
@@ -485,7 +485,7 @@ async def test_list_events_date_range_filtering(
calendar_name, past_event_data calendar_name, past_event_data
) )
past_uid = result_past["uid"] 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 # Create Event B: 1 day in the future
future_date = datetime.now() + timedelta(days=1) future_date = datetime.now() + timedelta(days=1)
@@ -499,7 +499,7 @@ async def test_list_events_date_range_filtering(
calendar_name, future_event_data calendar_name, future_event_data
) )
future_uid = result_future["uid"] 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 # Query with date range: today → 7 days ahead
now = datetime.now() now = datetime.now()
@@ -526,8 +526,8 @@ async def test_list_events_date_range_filtering(
) )
logger.info( logger.info(
f"Date range filtering works: {len(events)} events returned, " "Date range filtering works: %s events returned, past event correctly excluded",
f"past event correctly excluded" len(events),
) )
finally: finally:
@@ -537,7 +537,7 @@ async def test_list_events_date_range_filtering(
try: try:
await nc_client.calendar.delete_event(calendar_name, uid) await nc_client.calendar.delete_event(calendar_name, uid)
except Exception as e: 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( 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) result = await nc_client.calendar.create_event(calendar_name, event_data)
event_uid = result["uid"] 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 with date range: today → 3 days ahead
query_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) 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( logger.info(
f"Recurring event expansion works: {len(our_events)} occurrences " "Recurring event expansion works: %s occurrences returned with unique start dates",
f"returned with unique start dates" len(our_events),
) )
finally: finally:
@@ -628,7 +628,9 @@ async def test_recurring_event_date_range_expansion(
try: try:
await nc_client.calendar.delete_event(calendar_name, event_uid) await nc_client.calendar.delete_event(calendar_name, event_uid)
except Exception as e: 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( async def test_calendar_operations_error_handling(
@@ -68,7 +68,7 @@ END:VCALENDAR"""
event.data = custom_ical event.data = custom_ical
await event.save() 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 # Reload the event to confirm custom fields are present
await event.load() await event.load()
@@ -91,7 +91,7 @@ END:VCALENDAR"""
} }
await nc_client.calendar.update_event(calendar_name, event_uid, update_data) 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 # Reload the event to see if custom fields survived
await event.load() await event.load()
@@ -117,7 +117,7 @@ END:VCALENDAR"""
try: try:
await nc_client.calendar.delete_event(calendar_name, event_uid) await nc_client.calendar.delete_event(calendar_name, event_uid)
except Exception as cleanup_error: 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 @pytest.mark.integration
@@ -145,7 +145,7 @@ async def test_contact_extended_fields_preservation(nc_client):
contact_data=basic_contact_data, 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 # Now inject a rich vCard with extended fields directly via CardDAV
extended_vcard = f"""BEGIN:VCARD extended_vcard = f"""BEGIN:VCARD
@@ -182,7 +182,7 @@ END:VCARD"""
headers={"Content-Type": "text/vcard; charset=utf-8"}, 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 # Retrieve the contact to confirm extended fields are present in raw vCard
response = await nc_client.contacts._make_request("GET", contact_path) response = await nc_client.contacts._make_request("GET", contact_path)
@@ -232,7 +232,7 @@ END:VCARD"""
) )
logger.info("✓ Contact updated successfully") logger.info("✓ Contact updated successfully")
except Exception as e: except Exception as e:
logger.error(f"✗ Failed to update contact: {e}") logger.error("✗ Failed to update contact: %s", e)
raise raise
# Retrieve the contact again to see if extended fields survived # Retrieve the contact again to see if extended fields survived
@@ -268,9 +268,9 @@ END:VCARD"""
all_preserved = True all_preserved = True
for field_pattern, field_name in extended_field_checks: for field_pattern, field_name in extended_field_checks:
if field_pattern in updated_addressdata: if field_pattern in updated_addressdata:
logger.info(f"{field_name} preserved") logger.info("%s preserved", field_name)
else: else:
logger.error(f"{field_name} was lost during update") logger.error("%s was lost during update", field_name)
all_preserved = False all_preserved = False
# The test should PASS - field preservation should work # The test should PASS - field preservation should work
@@ -286,7 +286,7 @@ END:VCARD"""
await nc_client.contacts.delete_addressbook(name=addressbook_name) await nc_client.contacts.delete_addressbook(name=addressbook_name)
except Exception as cleanup_error: except Exception as cleanup_error:
logger.warning( 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: else:
lost.append(prop) lost.append(prop)
logger.info(f"Properties that SURVIVED: {survived}") logger.info("Properties that SURVIVED: %s", survived)
if lost: 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 that all extended properties were preserved
assert len(lost) == 0, ( assert len(lost) == 0, (
@@ -430,4 +430,4 @@ END:VCALENDAR"""
try: try:
await nc_client.calendar.delete_event(calendar_name, event_uid) await nc_client.calendar.delete_event(calendar_name, event_uid)
except Exception as cleanup_error: 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)
+22 -22
View File
@@ -33,29 +33,29 @@ async def temporary_todo(nc_client: NextcloudClient, temporary_calendar: str):
} }
try: 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) result = await nc_client.calendar.create_todo(calendar_name, todo_data)
todo_uid = result.get("uid") todo_uid = result.get("uid")
if not todo_uid: if not todo_uid:
pytest.fail("Failed to create temporary todo") 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} yield {"uid": todo_uid, "calendar_name": calendar_name, "data": todo_data}
finally: finally:
# Cleanup # Cleanup
if todo_uid: if todo_uid:
try: 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) 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: except HTTPStatusError as e:
if e.response.status_code != 404: 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: except Exception as e:
logger.error( 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] assert result["status_code"] in [200, 201, 204]
todo_uid = result["uid"] 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 # Verify todo was created by listing todos
todos = await nc_client.calendar.list_todos(calendar_name) 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) delete_result = await nc_client.calendar.delete_todo(calendar_name, todo_uid)
assert delete_result["status_code"] in [200, 204, 404] 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: except Exception as e:
logger.error(f"Test failed: {e}") logger.error("Test failed: %s", e)
raise raise
@@ -145,7 +145,7 @@ async def test_list_todos(nc_client: NextcloudClient, temporary_calendar: str):
for uid in todo_uids: for uid in todo_uids:
assert uid in listed_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: finally:
# Cleanup # Cleanup
@@ -187,10 +187,10 @@ async def test_update_todo(nc_client: NextcloudClient, temporary_todo: dict):
assert updated_todo["priority"] == 1 assert updated_todo["priority"] == 1
assert updated_todo["percent_complete"] == 50 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: except Exception as e:
logger.error(f"Todo update test failed: {e}") logger.error("Todo update test failed: %s", e)
raise raise
@@ -213,7 +213,7 @@ async def test_todo_with_dates(nc_client: NextcloudClient, temporary_calendar: s
try: try:
result = await nc_client.calendar.create_todo(calendar_name, todo_data) result = await nc_client.calendar.create_todo(calendar_name, todo_data)
todo_uid = result["uid"] 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 # Verify dates
todos = await nc_client.calendar.list_todos(calendar_name) 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) await nc_client.calendar.delete_todo(calendar_name, todo_uid)
except Exception as e: except Exception as e:
logger.error(f"Date handling test failed: {e}") logger.error("Date handling test failed: %s", e)
raise raise
@@ -281,7 +281,7 @@ async def test_todo_status_transitions(
assert todo["percent_complete"] == 100 assert todo["percent_complete"] == 100
assert "completed" in todo 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: finally:
await nc_client.calendar.delete_todo(calendar_name, todo_uid) 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 is not None
assert todo["priority"] == expected_priority assert todo["priority"] == expected_priority
logger.info(f"Successfully tested priority levels: {priorities}") logger.info("Successfully tested priority levels: %s", priorities)
finally: finally:
# Cleanup # Cleanup
@@ -342,7 +342,7 @@ async def test_todo_with_categories(
try: try:
result = await nc_client.calendar.create_todo(calendar_name, todo_data) result = await nc_client.calendar.create_todo(calendar_name, todo_data)
todo_uid = result["uid"] 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 # Verify categories
todos = await nc_client.calendar.list_todos(calendar_name) 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) await nc_client.calendar.delete_todo(calendar_name, todo_uid)
except Exception as e: except Exception as e:
logger.error(f"Categories test failed: {e}") logger.error("Categories test failed: %s", e)
raise raise
@@ -399,7 +399,7 @@ async def test_search_todos_across_calendars(
assert todo1["calendar_name"] == cal1_name assert todo1["calendar_name"] == cal1_name
assert todo2["calendar_name"] == cal2_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: finally:
# Cleanup: Delete only the todos we created (calendars are reused/built-in) # 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] matching_todos = [t for t in todos if t.get("uid") == fake_uid]
assert len(matching_todos) == 0 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( 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) result = await nc_client.calendar.delete_todo(calendar_name, fake_uid)
assert result["status_code"] == 404 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( 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] our_todo_uids = [t["uid"] for t in all_todos if t["uid"] in created_uids]
assert len(our_todo_uids) == 3 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: finally:
# Cleanup # Cleanup
@@ -22,7 +22,7 @@ async def test_list_addressbooks(nc_client: NextcloudClient):
if not addressbooks: if not addressbooks:
pytest.skip("No addressbooks available - Contacts app may not be enabled") 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 # Check structure of addressbooks
for addressbook in addressbooks: for addressbook in addressbooks:
@@ -31,7 +31,7 @@ async def test_list_addressbooks(nc_client: NextcloudClient):
assert "getctag" in addressbook assert "getctag" in addressbook
logger.info( logger.info(
f"Addressbook: {addressbook['name']} - {addressbook['display_name']}" "Addressbook: %s - %s", addressbook["name"], addressbook["display_name"]
) )
+61 -29
View File
@@ -27,14 +27,18 @@ async def test_attachments_add_and_get(
note_category = note_data.get("category") # Get category from fixture data note_category = note_data.get("category") # Get category from fixture data
logger.info( 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 # Pass category to get_note_attachment
retrieved_content, retrieved_mime = await nc_client.webdav.get_note_attachment( retrieved_content, retrieved_mime = await nc_client.webdav.get_note_attachment(
note_id=note_id, filename=attachment_filename, category=note_category note_id=note_id, filename=attachment_filename, category=note_category
) )
logger.info( 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 assert retrieved_content == attachment_content
@@ -55,7 +59,9 @@ async def test_attachments_add_to_note_with_category(
note_id = note_data["id"] note_id = note_data["id"]
note_category = note_data["category"] note_category = note_data["category"]
logger.info( 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 # Add attachment within the test
@@ -65,7 +71,7 @@ async def test_attachments_add_to_note_with_category(
attachment_mime = "text/plain" attachment_mime = "text/plain"
logger.info( 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 # Pass category to add_note_attachment
upload_response = await nc_client.webdav.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 and "status_code" in upload_response
assert upload_response["status_code"] in [201, 204] assert upload_response["status_code"] in [201, 204]
logger.info( 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) time.sleep(1)
# Get and Verify Attachment # Get and Verify Attachment
logger.info( 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 # Pass category to get_note_attachment
retrieved_content, retrieved_mime = await nc_client.webdav.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 category=note_category, # Pass the note's category
) )
logger.info( 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 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. # Instead, we will manually delete the note here and verify the attachment is gone.
logger.info( 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 # 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) 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) time.sleep(1)
# Verify Note Is Deleted # Verify Note Is Deleted
with pytest.raises(HTTPStatusError) as excinfo_note: with pytest.raises(HTTPStatusError) as excinfo_note:
await nc_client.notes.get_note(note_id=note_id) await nc_client.notes.get_note(note_id=note_id)
assert excinfo_note.value.response.status_code == 404 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) # Verify Attachment Is Deleted (via 404 on GET)
logger.info( 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: with pytest.raises(HTTPStatusError) as excinfo_attach:
# Pass category to get_note_attachment - although it should fail anyway # 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 # Expect 404 because the note itself is gone
assert excinfo_attach.value.response.status_code == 404 assert excinfo_attach.value.response.status_code == 404
logger.info( 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 # 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 status = propfind_resp.status_code
if status in [200, 207]: # Successful PROPFIND means directory exists if status in [200, 207]: # Successful PROPFIND means directory exists
logger.error( logger.error(
f"Attachment directory still exists! PROPFIND returned {status}" "Attachment directory still exists! PROPFIND returned %s", status
) )
assert False, ( assert False, (
f"Expected attachment directory to be gone, but PROPFIND returned {status}!" 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: try:
# 1. Create note with initial category # 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( created_note = await nc_client.notes.create_note(
title=note_title, content="Initial content", category=initial_category title=note_title, content="Initial content", category=initial_category
) )
note_id = created_note["id"] note_id = created_note["id"]
etag1 = created_note["etag"] 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) time.sleep(1)
# 2. Add attachment (passing initial category) # 2. Add attachment (passing initial category)
logger.info( 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( upload_response = await nc_client.webdav.add_note_attachment(
note_id=note_id, 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) # 3. Verify attachment retrieval from initial category (passing initial category)
logger.info( 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( retrieved_content1, _ = await nc_client.webdav.get_note_attachment(
note_id=note_id, filename=attachment_filename, category=initial_category 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) # 4. Update note category (with retry for ETag conflicts from background scanner)
logger.info( 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) # Retry logic for 412 Precondition Failed (ETag conflict)
# This can happen if the background vector scanner touches the note # 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_note_data = await nc_client.notes.get_note(note_id=note_id)
current_etag = current_note_data["etag"] current_etag = current_note_data["etag"]
logger.info( 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( 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"] etag3 = updated_note["etag"]
assert updated_note["category"] == new_category 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 break # Success, exit retry loop
except HTTPStatusError as e: except HTTPStatusError as e:
if e.response.status_code == 412 and attempt < max_update_attempts - 1: if e.response.status_code == 412 and attempt < max_update_attempts - 1:
# ETag conflict (likely from background scanner), retry # ETag conflict (likely from background scanner), retry
logger.warning( 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 time.sleep(1) # Brief delay before retry
continue 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) # 5. Verify attachment retrieval from *new* category (passing new category)
logger.info( 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( retrieved_content2, _ = await nc_client.webdav.get_note_attachment(
note_id=note_id, filename=attachment_filename, category=new_category 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 status = propfind_resp.status_code
if status in [200, 207]: # Successful PROPFIND means directory exists if status in [200, 207]: # Successful PROPFIND means directory exists
logger.error( logger.error(
f"Old attachment directory still exists! PROPFIND returned {status}" "Old attachment directory still exists! PROPFIND returned %s",
status,
) )
assert False, ( assert False, (
f"Expected old directory to be gone, but PROPFIND returned {status} - directory still exists!" 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, 200,
], f"Expected PROPFIND to return success (207/200), got {status}" ], f"Expected PROPFIND to return success (207/200), got {status}"
logger.info( 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: except HTTPStatusError as e:
logger.error( 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, ( assert False, (
f"Expected new attachment directory to exist, but PROPFIND failed with {e.response.status_code}" 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) # 6. Cleanup: Delete the note (client should use the *final* category for cleanup path)
if note_id: if note_id:
logger.info( 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: try:
await nc_client.notes.delete_note(note_id=note_id) 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) time.sleep(1)
# Verify note deletion # Verify note deletion
with pytest.raises(HTTPStatusError) as excinfo_note_del: 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." "Verified all attachment directories are properly cleaned up."
) )
except Exception as e: 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)
+16 -10
View File
@@ -37,7 +37,7 @@ def test_image_data() -> tuple[bytes, str]:
img.save(img_byte_arr, format="PNG") img.save(img_byte_arr, format="PNG")
image_bytes = img_byte_arr.getvalue() image_bytes = img_byte_arr.getvalue()
suggested_filename = "test_image.png" 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 return image_bytes, suggested_filename
@@ -61,7 +61,10 @@ async def test_note_with_embedded_image(
# 1. Upload the image as an attachment # 1. Upload the image as an attachment
note_category = note_data.get("category") # Get category from fixture data note_category = note_data.get("category") # Get category from fixture data
logger.info( 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( upload_response = await nc_client.webdav.add_note_attachment(
note_id=note_id, 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] assert upload_response and upload_response.get("status_code") in [201, 204]
logger.info( 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 time.sleep(1) # Allow potential processing time
@@ -94,11 +97,12 @@ async def test_note_with_embedded_image(
200, 200,
], f"Expected PROPFIND to return success (207/200), got {status}" ], f"Expected PROPFIND to return success (207/200), got {status}"
logger.info( logger.info(
f"Verified attachment directory exists via PROPFIND ({status} received)" "Verified attachment directory exists via PROPFIND (%s received)", status
) )
except HTTPStatusError as e: except HTTPStatusError as e:
logger.error( 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, ( assert False, (
f"Expected attachment directory to exist, but PROPFIND failed with {e.response.status_code}" 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 # 4. Verify the image attachment can be retrieved
logger.info( 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 # Pass category to get_note_attachment
retrieved_img_content, mime_type = await nc_client.webdav.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) # 5. Manually trigger deletion to verify cleanup (instead of waiting for fixture teardown)
logger.info( 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) 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) time.sleep(1)
# 6. Verify note is deleted # 6. Verify note is deleted
with pytest.raises(HTTPStatusError) as excinfo_note: with pytest.raises(HTTPStatusError) as excinfo_note:
await nc_client.notes.get_note(note_id=note_id) await nc_client.notes.get_note(note_id=note_id)
assert excinfo_note.value.response.status_code == 404 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 # 7. Verify attachment directory is deleted via WebDAV PROPFIND
logger.info("Directly verifying attachment directory doesn't exist via 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 status = propfind_resp.status_code
if status in [200, 207]: # Successful PROPFIND means directory exists if status in [200, 207]: # Successful PROPFIND means directory exists
logger.error( logger.error(
f"Attachment directory still exists! PROPFIND returned {status}" "Attachment directory still exists! PROPFIND returned %s", status
) )
assert False, ( assert False, (
f"Expected attachment directory to be gone, but PROPFIND returned {status}!" f"Expected attachment directory to be gone, but PROPFIND returned {status}!"
+2 -2
View File
@@ -39,7 +39,7 @@ async def test_create_and_delete_share(nc_client):
assert share_data is not None assert share_data is not None
assert "id" in share_data assert "id" in share_data
share_id = share_data["id"] share_id = share_data["id"]
logger.info(f"Created share: {share_id}") logger.info("Created share: %s", share_id)
# Get share info # Get share info
share_info = await nc_client.sharing.get_share(share_id) share_info = await nc_client.sharing.get_share(share_id)
@@ -56,7 +56,7 @@ async def test_create_and_delete_share(nc_client):
# Cleanup # Cleanup
if share_id: if share_id:
await nc_client.sharing.delete_share(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) await nc_client.webdav.delete_resource(file_path)
+34 -19
View File
@@ -29,18 +29,21 @@ async def test_category_change_cleans_up_old_attachments_directory(
try: try:
# 1. Create note with initial category # 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( created_note = await nc_client.notes.create_note(
title=note_title, content="Initial content", category=initial_category title=note_title, content="Initial content", category=initial_category
) )
note_id = created_note["id"] note_id = created_note["id"]
etag1 = created_note["etag"] 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) time.sleep(1)
# 2. Add attachment (passing initial category) # 2. Add attachment (passing initial category)
logger.info( 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( upload_response = await nc_client.webdav.add_note_attachment(
note_id=note_id, 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 # 3. Verify attachment retrieval from initial category
logger.info( 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( retrieved_content1, _ = await nc_client.webdav.get_note_attachment(
note_id=note_id, filename=attachment_filename, category=initial_category 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 # 4. Construct and check the WebDAV path for the initial category's attachment directory
initial_webdav_path = f"Notes/{initial_category}/.attachments.{note_id}" 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 # 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 # expose directory listing functionality, so we'll infer from attachment retrieval success
# 5. Update note category # 5. Update note category
logger.info( 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_note_data = await nc_client.notes.get_note(note_id=note_id)
current_etag = current_note_data["etag"] current_etag = current_note_data["etag"]
@@ -84,12 +91,12 @@ async def test_category_change_cleans_up_old_attachments_directory(
) )
etag3 = updated_note["etag"] etag3 = updated_note["etag"]
assert updated_note["category"] == new_category 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) time.sleep(1)
# 6. Verify attachment retrieval from new category # 6. Verify attachment retrieval from new category
logger.info( 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( retrieved_content2, _ = await nc_client.webdav.get_note_attachment(
note_id=note_id, filename=attachment_filename, category=new_category 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 # 7. Try to retrieve from old category - this should fail
logger.info( 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: try:
await nc_client.webdav.get_note_attachment( 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: except HTTPStatusError as e:
# This is the expected outcome - old directory should be gone # This is the expected outcome - old directory should be gone
logger.info( 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, ( assert e.response.status_code == 404, (
f"Expected 404, got {e.response.status_code}" f"Expected 404, got {e.response.status_code}"
@@ -143,14 +152,16 @@ async def test_category_change_cleans_up_old_attachments_directory(
207, 207,
]: # Success codes indicate the directory exists (a problem) ]: # Success codes indicate the directory exists (a problem)
logger.error( logger.error(
f"Old attachment directory still exists! PROPFIND returned {status}" "Old attachment directory still exists! PROPFIND returned %s",
status,
) )
assert False, ( assert False, (
f"Expected old attachment directory to be gone, but it still exists (PROPFIND returned {status})!" 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 # If we got another status code (like 404), it's also good - the directory doesn't exist
logger.info( 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: except HTTPStatusError as e:
# 404 is expected - directory should not exist # 404 is expected - directory should not exist
@@ -164,10 +175,10 @@ async def test_category_change_cleans_up_old_attachments_directory(
finally: finally:
# 8. Cleanup: Delete the note # 8. Cleanup: Delete the note
if note_id: if note_id:
logger.info(f"Cleaning up note ID: {note_id}") logger.info("Cleaning up note ID: %s", note_id)
try: try:
await nc_client.notes.delete_note(note_id=note_id) 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) time.sleep(1)
# 9. Verify both old and new attachment paths are gone # 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, 207,
]: # Success codes indicate the directory exists (a problem) ]: # Success codes indicate the directory exists (a problem)
logger.error( logger.error(
f"New category attachment directory still exists! PROPFIND returned {status}" "New category attachment directory still exists! PROPFIND returned %s",
status,
) )
assert False, ( assert False, (
f"Expected new category attachment directory to be gone, but it still exists (PROPFIND returned {status})!" 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 # If we got another status code (like 404), it's also good - the directory doesn't exist
logger.info( 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: except HTTPStatusError as e:
assert e.response.status_code == 404, ( assert e.response.status_code == 404, (
@@ -240,14 +253,16 @@ async def test_category_change_cleans_up_old_attachments_directory(
207, 207,
]: # Success codes indicate the directory exists (a problem) ]: # Success codes indicate the directory exists (a problem)
logger.error( logger.error(
f"Old category attachment directory still exists! PROPFIND returned {status}" "Old category attachment directory still exists! PROPFIND returned %s",
status,
) )
assert False, ( assert False, (
f"Expected old category attachment directory to be gone, but it still exists (PROPFIND returned {status})!" 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 # If we got another status code (like 404), it's also good - the directory doesn't exist
logger.info( 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: except HTTPStatusError as e:
assert e.response.status_code == 404, ( 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." "Verified all attachment directories are properly cleaned up."
) )
except Exception as e: 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)
+12 -12
View File
@@ -33,7 +33,7 @@ async def test_create_and_delete_directory(
# Create directory # Create directory
result = await nc_client.webdav.create_directory(test_dir) result = await nc_client.webdav.create_directory(test_dir)
assert result["status_code"] == 201 # Created 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 # Verify directory exists by listing parent
parent_listing = await nc_client.webdav.list_directory(test_base_path) 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 directory
delete_result = await nc_client.webdav.delete_resource(test_dir) delete_result = await nc_client.webdav.delete_resource(test_dir)
assert delete_result["status_code"] in [204, 404] # No Content or Not Found 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: finally:
# Cleanup: ensure directory is deleted # 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" test_file, test_content.encode("utf-8"), content_type="text/plain"
) )
assert write_result["status_code"] in [200, 201, 204] # Success codes 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 # Read file back
content, content_type = await nc_client.webdav.read_file(test_file) content, content_type = await nc_client.webdav.read_file(test_file)
assert content.decode("utf-8") == test_content assert content.decode("utf-8") == test_content
assert "text/plain" in content_type 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 # Verify file appears in directory listing
listing = await nc_client.webdav.list_directory(test_base_path) 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 file
delete_result = await nc_client.webdav.delete_resource(test_file) delete_result = await nc_client.webdav.delete_resource(test_file)
assert delete_result["status_code"] in [204, 404] # No Content or Not Found 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: finally:
# Cleanup # Cleanup
@@ -106,7 +106,7 @@ async def test_list_directory_empty_and_populated(
empty_listing = await nc_client.webdav.list_directory(test_base_path) empty_listing = await nc_client.webdav.list_directory(test_base_path)
assert isinstance(empty_listing, list) assert isinstance(empty_listing, list)
assert len(empty_listing) == 0 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 # Add some files and directories
await nc_client.webdav.create_directory(f"{test_base_path}/subdir1") 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 "content_type" in item
assert "last_modified" 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: finally:
# Cleanup # Cleanup
@@ -162,7 +162,7 @@ async def test_read_nonexistent_file(nc_client: NextcloudClient):
await nc_client.webdav.read_file(nonexistent_file) await nc_client.webdav.read_file(nonexistent_file)
assert exc_info.value.response.status_code == 404 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): 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) result = await nc_client.webdav.delete_resource(nonexistent_resource)
assert result["status_code"] == 404 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( 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]["name"] == "level3"
assert level2_listing[0]["is_directory"] is True 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: finally:
# Cleanup - delete from deepest to shallowest # 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) content, _ = await nc_client.webdav.read_file(test_file)
assert content.decode("utf-8") == new_content assert content.decode("utf-8") == new_content
logger.info(f"Successfully overwrote file: {test_file}") logger.info("Successfully overwrote file: %s", test_file)
finally: finally:
# Cleanup # Cleanup
@@ -270,4 +270,4 @@ async def test_list_root_directory(nc_client: NextcloudClient):
assert "content_type" in item assert "content_type" in item
assert "last_modified" 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))
+14 -14
View File
@@ -47,16 +47,16 @@ async def test_search_setup(nc_client: NextcloudClient):
for file_path, content, content_type in test_files: for file_path, content, content_type in test_files:
await nc_client.webdav.write_file(file_path, content, content_type) 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 yield test_dir
# Cleanup # Cleanup
try: try:
await nc_client.webdav.delete_resource(test_dir) 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: 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): 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"] readme_files = [r for r in results if r.get("name") == "readme.md"]
assert len(readme_files) >= 1, "Should find 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( async def test_find_by_name_wildcard_extension(
@@ -86,7 +86,7 @@ async def test_find_by_name_wildcard_extension(
name = result.get("name", "") name = result.get("name", "")
assert name.endswith(".txt"), f"Expected .txt file, got {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( 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}" 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): 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}" 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( 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}" 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): 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) <= 2, f"Should return at most 2 results, got {len(results)}"
assert len(results) > 0, "Should return at least 1 result" 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( 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}" 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): 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 # Should find at least the one we created
assert len(results) >= 1, f"Should find at least 1 file named {unique_name}" 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): 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] nested_file = results[0]
assert "nested.txt" in nested_file.get("name", ""), "Should find nested.txt" 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): 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 properties that may be present
optional_props = ["size", "content_type", "last_modified", "etag"] 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 # At least some optional properties should be present
present_optional = [prop for prop in optional_props if prop in result] 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}" 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()))
+315 -230
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -79,7 +79,7 @@ async def create_generation_provider(provider_name: str) -> Provider:
embedding_model=None, # Generation only embedding_model=None, # Generation only
generation_model=generation_model, 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 return provider
elif provider_name == "ollama": elif provider_name == "ollama":
@@ -96,7 +96,7 @@ async def create_generation_provider(provider_name: str) -> Provider:
embedding_model=None, # Generation only embedding_model=None, # Generation only
generation_model=generation_model, 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 return provider
elif provider_name == "anthropic": elif provider_name == "anthropic":
@@ -114,7 +114,7 @@ async def create_generation_provider(provider_name: str) -> Provider:
api_key=api_key, api_key=api_key,
generation_model=generation_model, 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 return provider
elif provider_name == "bedrock": elif provider_name == "bedrock":
@@ -133,7 +133,7 @@ async def create_generation_provider(provider_name: str) -> Provider:
embedding_model=None, # Generation only embedding_model=None, # Generation only
generation_model=generation_model, 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 return provider
else: else:
@@ -178,7 +178,7 @@ async def create_embedding_provider(provider_name: str) -> Provider:
embedding_model=embedding_model, embedding_model=embedding_model,
generation_model=None, # Embeddings only 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 return provider
elif provider_name == "ollama": elif provider_name == "ollama":
@@ -195,7 +195,7 @@ async def create_embedding_provider(provider_name: str) -> Provider:
embedding_model=embedding_model, embedding_model=embedding_model,
generation_model=None, # Embeddings only 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 return provider
elif provider_name == "bedrock": elif provider_name == "bedrock":
@@ -214,7 +214,7 @@ async def create_embedding_provider(provider_name: str) -> Provider:
embedding_model=embedding_model, embedding_model=embedding_model,
generation_model=None, # Embeddings only 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 return provider
else: else:
+6 -4
View File
@@ -62,7 +62,7 @@ def create_sampling_callback(provider: Provider):
params: types.CreateMessageRequestParams, params: types.CreateMessageRequestParams,
) -> types.CreateMessageResult | types.ErrorData: ) -> types.CreateMessageResult | types.ErrorData:
"""Handle sampling requests using the configured provider.""" """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 # Extract messages and build prompt
messages_text = [] messages_text = []
@@ -77,7 +77,7 @@ def create_sampling_callback(provider: Provider):
if params.systemPrompt: if params.systemPrompt:
prompt = f"System: {params.systemPrompt}\n\n{prompt}" 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: try:
# Generate response using provider # Generate response using provider
@@ -87,7 +87,9 @@ def create_sampling_callback(provider: Provider):
max_tokens=params.maxTokens, 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( return types.CreateMessageResult(
role="assistant", role="assistant",
@@ -96,7 +98,7 @@ def create_sampling_callback(provider: Provider):
stopReason="endTurn", stopReason="endTurn",
) )
except Exception as e: 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( return types.ErrorData(
code=types.INTERNAL_ERROR, code=types.INTERNAL_ERROR,
message=f"Generation failed: {e!s}", message=f"Generation failed: {e!s}",
@@ -91,8 +91,10 @@ async def _poll_astrolabe_search_for_note(
) )
if note_result is not None: if note_result is not None:
logger.info( logger.info(
f"Note {note_id} surfaced in Astrolabe search after {attempts} " "Note %s surfaced in Astrolabe search after %s attempts (~%ss)",
f"attempts (~{attempts * 2}s)" note_id,
attempts,
attempts * 2,
) )
return note_result return note_result
await anyio.sleep(2) 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} "nc_notes_delete_note", {"note_id": note_id}
) )
except Exception as cleanup_err: 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() await context.close()
@@ -40,7 +40,7 @@ async def login_to_nextcloud(page: Page, username: str, password: str):
""" """
nextcloud_url = "http://localhost:8080" 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") await page.goto(f"{nextcloud_url}/login", wait_until="networkidle")
# Fill in login form # 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, ( assert "/login" not in current_url, (
f"Login failed for {username}, still on login page" 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): 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" nextcloud_url = "http://localhost:8080"
settings_url = f"{nextcloud_url}/settings/user/astrolabe" 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) await page.goto(settings_url, wait_until="networkidle", timeout=30000)
# Verify we're on the settings page # 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" 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 # Check if already on Astrolabe settings page, if not navigate there
if "/settings/user/astrolabe" not in page.url: 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) # Check for "Active" badge (fully configured state)
active_badge = page.get_by_text("Active", exact=True) active_badge = page.get_by_text("Active", exact=True)
if await active_badge.count() > 0 and await active_badge.is_visible(): 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 return True
except Exception: except Exception:
pass pass
@@ -136,7 +136,7 @@ async def authorize_search_access(page: Page, username: str) -> bool:
step1_parent = step1_section.locator("..") step1_parent = step1_section.locator("..")
complete_badge = step1_parent.get_by_text("Complete", exact=True) complete_badge = step1_parent.get_by_text("Complete", exact=True)
if await complete_badge.count() > 0 and await complete_badge.is_visible(): 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 return True
except Exception: except Exception:
pass pass
@@ -146,36 +146,38 @@ async def authorize_search_access(page: Page, username: str) -> bool:
try: try:
await authorize_button.wait_for(timeout=5000, state="visible") 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: except Exception:
# Take screenshot for debugging # Take screenshot for debugging
screenshot_path = f"/tmp/astrolabe_no_authorize_button_{username}.png" screenshot_path = f"/tmp/astrolabe_no_authorize_button_{username}.png"
await page.screenshot(path=screenshot_path) await page.screenshot(path=screenshot_path)
logger.error( 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}") raise ValueError(f"Authorize button not found for {username}")
# Click the Authorize button - this will redirect to OAuth provider # Click the Authorize button - this will redirect to OAuth provider
# Use force=True to bypass stability check which can timeout due to CSS transitions # Use force=True to bypass stability check which can timeout due to CSS transitions
await authorize_button.click(force=True) 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 # Wait for OAuth redirect to complete
await page.wait_for_load_state("networkidle", timeout=30000) 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 # Take screenshot to see current state
await page.screenshot(path=f"/tmp/astrolabe_after_authorize_{username}.png") 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 # Handle OIDC consent screen if present
consent_handled = await _handle_oauth_consent_screen(page, username) consent_handled = await _handle_oauth_consent_screen(page, username)
if consent_handled: if consent_handled:
logger.info(f"✓ OAuth consent granted for {username}") logger.info("✓ OAuth consent granted for %s", username)
else: else:
logger.info( 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 # 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( await page.wait_for_url(
f"**{nextcloud_url}/settings/user/astrolabe**", timeout=30000 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: except Exception:
# Check if we're already on settings page # Check if we're already on settings page
if "/settings/user/astrolabe" not in page.url: if "/settings/user/astrolabe" not in page.url:
logger.warning( logger.warning(
f"Not redirected to Astrolabe settings, current URL: {page.url}" "Not redirected to Astrolabe settings, current URL: %s", page.url
) )
# Navigate manually # Navigate manually
await page.goto( 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) # First check if "Active" badge is shown (fully configured state)
active_badge = page.get_by_text("Active", exact=True) active_badge = page.get_by_text("Active", exact=True)
if await active_badge.count() > 0 and await active_badge.is_visible(): 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 return True
except Exception: except Exception:
pass pass
@@ -217,7 +221,7 @@ async def authorize_search_access(page: Page, username: str) -> bool:
step1_parent = step1_section.locator("..") step1_parent = step1_section.locator("..")
complete_badge = step1_parent.get_by_text("Complete", exact=True) complete_badge = step1_parent.get_by_text("Complete", exact=True)
await complete_badge.wait_for(timeout=5000, state="visible") 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 return True
except Exception: except Exception:
pass 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" screenshot_path = f"/tmp/astrolabe_step1_not_complete_{username}.png"
await page.screenshot(path=screenshot_path) await page.screenshot(path=screenshot_path)
logger.error( 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}") 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 True if consent was handled, False if no consent screen was found
""" """
try: 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 # Check if consent screen is present - try multiple selectors
# The consent screen may be #oidc-consent or use a different format # The consent screen may be #oidc-consent or use a different format
consent_div = await page.query_selector("#oidc-consent") consent_div = await page.query_selector("#oidc-consent")
if consent_div: 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 # Get consent screen data attributes for logging
client_name = await consent_div.get_attribute("data-client-name") client_name = await consent_div.get_attribute("data-client-name")
scopes_attr = await consent_div.get_attribute("data-scopes") scopes_attr = await consent_div.get_attribute("data-scopes")
logger.info(f" Client: {client_name}") logger.info(" Client: %s", client_name)
logger.info(f" Requested scopes: {scopes_attr}") logger.info(" Requested scopes: %s", scopes_attr)
else: else:
# Check for Allow button directly (different consent screen format) # Check for Allow button directly (different consent screen format)
allow_button = page.locator('button:has-text("Allow")') allow_button = page.locator('button:has-text("Allow")')
if await allow_button.count() > 0: 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: 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") 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 return False
# Wait for Vue.js to render the Allow button # 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: except Exception as e:
screenshot_path = f"/tmp/consent_no_allow_button_{username}.png" screenshot_path = f"/tmp/consent_no_allow_button_{username}.png"
await page.screenshot(path=screenshot_path) 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 raise
# Check all scope checkboxes # Check all scope checkboxes
scope_checkboxes = await page.query_selector_all('input[type="checkbox"]') scope_checkboxes = await page.query_selector_all('input[type="checkbox"]')
if scope_checkboxes: 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): for i, checkbox in enumerate(scope_checkboxes):
is_checked = await checkbox.is_checked() is_checked = await checkbox.is_checked()
is_disabled = await checkbox.is_disabled() is_disabled = await checkbox.is_disabled()
if not is_checked and not is_disabled: if not is_checked and not is_disabled:
await checkbox.check() 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) # Click the Allow button using JavaScript (handles viewport issues)
allow_button_locator = page.locator('button:has-text("Allow")') 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 # Debug: take screenshot before clicking Allow
await page.screenshot(path=f"/tmp/consent_before_allow_{username}.png") await page.screenshot(path=f"/tmp/consent_before_allow_{username}.png")
logger.info( 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() 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: if button_count > 0:
current_url = page.url current_url = page.url
logger.info(f" Current URL: {current_url}") logger.info(" Current URL: %s", current_url)
logger.info(f" Clicking Allow button for {username}...") logger.info(" Clicking Allow button for %s...", username)
# Use JavaScript click to handle consent buttons (proven pattern from conftest.py) # 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 # 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, lambda url: url != current_url,
timeout=30000, timeout=30000,
) )
logger.info(f" URL changed to: {page.url}") logger.info(" URL changed to: %s", page.url)
except Exception as wait_error: except Exception as wait_error:
# If URL didn't change, check console for errors # 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") await page.screenshot(path=f"/tmp/consent_after_allow_{username}.png")
# Try alternative: manually POST consent and navigate # 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") await page.goto(redirect_url, wait_until="networkidle")
except Exception as manual_error: except Exception as manual_error:
logger.error(f" Manual consent also failed: {manual_error}") logger.error(" Manual consent also failed: %s", manual_error)
raise raise
await page.screenshot(path=f"/tmp/consent_after_allow_{username}.png") 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 return True
else: else:
logger.error(f" Allow button not found for {username}") logger.error(" Allow button not found for %s", username)
return False return False
except Exception as e: 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 raise
@@ -387,7 +393,7 @@ async def generate_app_password(
Returns: Returns:
The generated app password string 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" 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) # Fill the app password input field (selector confirmed via Playwright MCP)
app_password_input = page.locator('input[placeholder="App name"]') app_password_input = page.locator('input[placeholder="App name"]')
await app_password_input.fill(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) # Wait for Vue.js to react and enable the button (needs 1 second, not 0.5)
await anyio.sleep(1.0) await anyio.sleep(1.0)
@@ -427,7 +433,7 @@ async def generate_app_password(
# Debug screenshot after clicking create # Debug screenshot after clicking create
await page.screenshot(path=f"/tmp/app_password_after_create_{username}.png") await page.screenshot(path=f"/tmp/app_password_after_create_{username}.png")
logger.info( 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 # 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 # Get all visible input elements
all_inputs = await page.locator('input[type="text"]').all() 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 # Check each input to find the one with the app password
for idx, input_elem in enumerate(all_inputs): 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: if value and "-" in value and len(value) > 20:
app_password = value.strip() app_password = value.strip()
logger.info( 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 break
except Exception as e: 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 continue
except Exception as e: 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: if not app_password:
# Take screenshot for debugging # Take screenshot for debugging
@@ -474,9 +483,9 @@ async def generate_app_password(
app_password, app_password,
): ):
logger.error( 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" screenshot_path = f"/tmp/app_password_invalid_format_{username}.png"
await page.screenshot(path=screenshot_path) await page.screenshot(path=screenshot_path)
raise ValueError( raise ValueError(
@@ -484,7 +493,9 @@ async def generate_app_password(
) )
logger.info( 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) # 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: Returns:
True if background sync was enabled successfully 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" nextcloud_url = "http://localhost:8080"
@@ -524,7 +535,7 @@ async def enable_background_sync_via_app_password(
def log_response(resp): def log_response(resp):
response_info = f"{resp.status} {resp.url}" response_info = f"{resp.status} {resp.url}"
network_responses.append(response_info) network_responses.append(response_info)
logger.info(f"Response: {response_info}") logger.info("Response: %s", response_info)
def log_console(msg): def log_console(msg):
console_messages.append(f"[{msg.type}] {msg.text}") 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) # First check for overall "Active" badge (both steps complete)
active_text = page.get_by_text("Active", exact=True) active_text = page.get_by_text("Active", exact=True)
if await active_text.is_visible(timeout=2000): 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 return True
except Exception: except Exception:
pass pass
@@ -558,7 +569,7 @@ async def enable_background_sync_via_app_password(
step2_parent = step2_section.locator("..") step2_parent = step2_section.locator("..")
complete_badge = step2_parent.get_by_text("Complete", exact=True) complete_badge = step2_parent.get_by_text("Complete", exact=True)
if await complete_badge.count() > 0 and await complete_badge.is_visible(): 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 return True
except Exception: except Exception:
pass pass
@@ -580,7 +591,7 @@ async def enable_background_sync_via_app_password(
# Enter the app password # Enter the app password
await app_password_input.fill(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 # Wait a moment for any validation to complete
await anyio.sleep(0.5) 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 # Take screenshot before clicking Save to check for warnings
screenshot_path = f"/tmp/before_save_{username}.png" screenshot_path = f"/tmp/before_save_{username}.png"
await page.screenshot(path=screenshot_path) 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 # Find and click the Save button
save_button = page.get_by_role("button", name="Save") save_button = page.get_by_role("button", name="Save")
# Check if Save button is enabled # Check if Save button is enabled
is_disabled = await save_button.is_disabled() 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() await save_button.click()
logger.info("Clicked Save button") logger.info("Clicked Save button")
@@ -604,24 +615,24 @@ async def enable_background_sync_via_app_password(
await anyio.sleep(0.5) await anyio.sleep(0.5)
# Log network requests after clicking Save # 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 for req in network_requests[-10:]: # Last 10 requests
logger.info(f" {req}") logger.info(" %s", req)
# Log network responses after clicking Save # 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 for resp in network_responses[-10:]: # Last 10 responses
logger.info(f" {resp}") logger.info(" %s", resp)
# Check specifically for the credentials POST response # Check specifically for the credentials POST response
credentials_responses = [ credentials_responses = [
r for r in network_responses if "background-sync/credentials" in r r for r in network_responses if "background-sync/credentials" in r
] ]
if credentials_responses: 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]: if "200" not in credentials_responses[-1]:
logger.error( 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: else:
logger.warning("No response found for credentials endpoint!") logger.warning("No response found for credentials endpoint!")
@@ -633,16 +644,16 @@ async def enable_background_sync_via_app_password(
# Log any console messages # Log any console messages
if console_messages: if console_messages:
logger.info(f"Console messages for {username}:") logger.info("Console messages for %s:", username)
for msg in console_messages: for msg in console_messages:
logger.info(f" {msg}") logger.info(" %s", msg)
# Check for error notifications (toast messages) # Check for error notifications (toast messages)
try: try:
error_toast = page.locator(".toastify.toast-error, .toast-error") error_toast = page.locator(".toastify.toast-error, .toast-error")
if await error_toast.count() > 0: if await error_toast.count() > 0:
error_text = await error_toast.first.text_content() 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: except Exception:
pass pass
@@ -653,7 +664,7 @@ async def enable_background_sync_via_app_password(
if await active_text.count() > 0: if await active_text.count() > 0:
await active_text.wait_for(timeout=5000, state="visible") await active_text.wait_for(timeout=5000, state="visible")
logger.info( logger.info(
f"✓ Background sync enabled for {username} - Active badge visible" "✓ Background sync enabled for %s - Active badge visible", username
) )
return True return True
except Exception: except Exception:
@@ -667,7 +678,8 @@ async def enable_background_sync_via_app_password(
complete_badge = step2_parent.get_by_text("Complete", exact=True) complete_badge = step2_parent.get_by_text("Complete", exact=True)
await complete_badge.wait_for(timeout=5000, state="visible") await complete_badge.wait_for(timeout=5000, state="visible")
logger.info( 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 return True
except Exception: except Exception:
@@ -677,8 +689,9 @@ async def enable_background_sync_via_app_password(
screenshot_path = f"/tmp/astrolabe_after_password_{username}.png" screenshot_path = f"/tmp/astrolabe_after_password_{username}.png"
await page.screenshot(path=screenshot_path) await page.screenshot(path=screenshot_path)
logger.error( logger.error(
f"Neither Active nor Complete badge appeared for {username}. " "Neither Active nor Complete badge appeared for %s. Screenshot: %s",
f"Screenshot: {screenshot_path}" username,
screenshot_path,
) )
raise ValueError(f"Background sync setup did not complete for {username}") raise ValueError(f"Background sync setup did not complete for {username}")
@@ -702,7 +715,7 @@ async def complete_astrolabe_authorization(
Returns: Returns:
Dict with {"step1": bool, "step2": bool, "app_password": str | None} 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} result = {"step1": False, "step2": False, "app_password": None}
@@ -712,9 +725,9 @@ async def complete_astrolabe_authorization(
# Step 1: OAuth authorization # Step 1: OAuth authorization
try: try:
result["step1"] = await authorize_search_access(page, username) 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: except Exception as e:
logger.error(f"Step 1 failed for {username}: {e}") logger.error("Step 1 failed for %s: %s", username, e)
raise raise
# Navigate back to settings if needed (OAuth might have redirected elsewhere) # 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("..") step2_parent = step2_section.locator("..")
complete_badge = step2_parent.get_by_text("Complete", exact=True) complete_badge = step2_parent.get_by_text("Complete", exact=True)
if await complete_badge.count() > 0 and await complete_badge.is_visible(): 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 result["step2"] = True
return result return result
except Exception: except Exception:
@@ -738,7 +751,7 @@ async def complete_astrolabe_authorization(
try: try:
active_text = page.get_by_text("Active", exact=True) active_text = page.get_by_text("Active", exact=True)
if await active_text.count() > 0 and await active_text.is_visible(): 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 result["step2"] = True
return result return result
except Exception: except Exception:
@@ -752,12 +765,12 @@ async def complete_astrolabe_authorization(
result["step2"] = await enable_background_sync_via_app_password( result["step2"] = await enable_background_sync_via_app_password(
page, username, 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: except Exception as e:
logger.error(f"Step 2 failed for {username}: {e}") logger.error("Step 2 failed for %s: %s", username, e)
raise raise
logger.info(f"✓ Full Astrolabe authorization complete for {username}") logger.info("✓ Full Astrolabe authorization complete for %s", username)
return result return result
@@ -773,7 +786,7 @@ async def verify_app_password_created(username: str) -> bool:
Returns: Returns:
True if background sync app password exists 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 # Query the database to check for background sync credentials
# Astrolabe stores app passwords in oc_preferences, not oc_authtoken # 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 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 # Check if background sync credentials exist
# We should see 3 rows: background_sync_password, background_sync_type, background_sync_provisioned_at # 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) if len(lines) >= 3: # Header + at least 2 data rows (password + type)
# Verify background_sync_type is "app_password" # Verify background_sync_type is "app_password"
if "app_password" in output: 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 return True
else: else:
logger.warning( 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 return False
else: else:
logger.warning(f"No background sync credentials found for {username}") logger.warning("No background sync credentials found for %s", username)
return False return False
except Exception as e: 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 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) result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
if result.returncode != 0: if result.returncode != 0:
logger.warning( 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: else:
logger.debug(f"Cleared {label}") logger.debug("Cleared %s", label)
@pytest.mark.integration @pytest.mark.integration
@@ -946,7 +965,9 @@ async def test_multi_user_astrolabe_background_sync_enablement(
# Use nc_client to check if user exists # Use nc_client to check if user exists
user_details = await nc_client.users.get_user_details(username) user_details = await nc_client.users.get_user_details(username)
logger.info( 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: except Exception as e:
raise AssertionError( raise AssertionError(
@@ -957,9 +978,9 @@ async def test_multi_user_astrolabe_background_sync_enablement(
results = {} results = {}
for username in test_users: for username in test_users:
logger.info(f"\n{'=' * 60}") logger.info("\\n%s", "=" * 60)
logger.info(f"Testing background sync enablement for: {username}") logger.info("Testing background sync enablement for: %s", username)
logger.info(f"{'=' * 60}") logger.info("%s", "=" * 60)
user_config = test_users_setup[username] user_config = test_users_setup[username]
password = user_config["password"] 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, "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(" Settings accessed: ✓")
logger.info(f" App password generated: {'' if app_password else ''}") logger.info(" App password generated: %s", "" if app_password else "")
logger.info(f" Sync enabled: {'' if sync_enabled else ''}") logger.info(" Sync enabled: %s", "" if sync_enabled else "")
logger.info(f" App password stored: {'' if app_password_stored else ''}")
logger.info( 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: except Exception as e:
logger.error(f"Error during {username} test: {e}") logger.error("Error during %s test: %s", username, e)
results[username] = { results[username] = {
"settings_accessed": False, "settings_accessed": False,
"app_password_generated": False, "app_password_generated": False,
@@ -1018,18 +1042,18 @@ async def test_multi_user_astrolabe_background_sync_enablement(
await context.close() await context.close()
# Verify all users succeeded # Verify all users succeeded
logger.info(f"\n{'=' * 60}") logger.info("\\n%s", "=" * 60)
logger.info("Test Summary") logger.info("Test Summary")
logger.info(f"{'=' * 60}") logger.info("%s", "=" * 60)
for username, result in results.items(): for username, result in results.items():
logger.info(f"\n{username}:") logger.info("\\n%s:", username)
for key, value in result.items(): for key, value in result.items():
if key != "error": if key != "error":
status = "" if value else "" status = "" if value else ""
logger.info(f" {key}: {status}") logger.info(" %s: %s", key, status)
elif value: elif value:
logger.info(f" error: {value}") logger.info(" error: %s", value)
# Assert all users successfully enabled background sync # Assert all users successfully enabled background sync
for username in test_users: for username in test_users:
@@ -1051,7 +1075,8 @@ async def test_multi_user_astrolabe_background_sync_enablement(
) )
logger.info( 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: Returns:
True if revocation was successful 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" nextcloud_url = "http://localhost:8080"
@@ -1080,7 +1105,7 @@ async def revoke_background_sync_access(page: Page, username: str) -> bool:
def log_response(resp): def log_response(resp):
response_info = f"{resp.status} {resp.url}" response_info = f"{resp.status} {resp.url}"
network_responses.append(response_info) network_responses.append(response_info)
logger.info(f"Response: {response_info}") logger.info("Response: %s", response_info)
def log_console(msg): def log_console(msg):
console_messages.append(f"[{msg.type}] {msg.text}") 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) active_text = page.get_by_text("Active", exact=True)
if not await active_text.is_visible(timeout=2000): if not await active_text.is_visible(timeout=2000):
logger.warning( logger.warning(
f"Background sync not active for {username}, nothing to revoke" "Background sync not active for %s, nothing to revoke", username
) )
return False return False
except Exception: except Exception:
logger.warning(f"Could not find Active badge for {username}") logger.warning("Could not find Active badge for %s", username)
return False return False
# Find the "Revoke Access" button # Find the "Revoke Access" button
@@ -1134,21 +1159,21 @@ async def revoke_background_sync_access(page: Page, username: str) -> bool:
await anyio.sleep(2) await anyio.sleep(2)
# Log network requests after clicking # 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:]: for req in network_requests[-10:]:
logger.info(f" {req}") logger.info(" %s", req)
# Log network responses # 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:]: for resp in network_responses[-10:]:
logger.info(f" {resp}") logger.info(" %s", resp)
# Check specifically for the revoke POST response # Check specifically for the revoke POST response
revoke_responses = [r for r in network_responses if "credentials/revoke" in r] revoke_responses = [r for r in network_responses if "credentials/revoke" in r]
if revoke_responses: 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]: 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 return False
else: else:
logger.warning("No response found for credentials/revoke endpoint!") 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 # Log any console messages
if console_messages: if console_messages:
logger.info(f"Console messages for {username}:") logger.info("Console messages for %s:", username)
for msg in console_messages: for msg in console_messages:
logger.info(f" {msg}") logger.info(" %s", msg)
# Check for error notifications (toast messages) # Check for error notifications (toast messages)
try: try:
error_toast = page.locator(".toastify.toast-error, .toast-error") error_toast = page.locator(".toastify.toast-error, .toast-error")
if await error_toast.count() > 0: if await error_toast.count() > 0:
error_text = await error_toast.first.text_content() 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 return False
except Exception: except Exception:
pass pass
@@ -1177,14 +1202,14 @@ async def revoke_background_sync_access(page: Page, username: str) -> bool:
try: try:
active_text = page.get_by_text("Active", exact=True) active_text = page.get_by_text("Active", exact=True)
if await active_text.is_visible(timeout=2000): 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" screenshot_path = f"/tmp/astrolabe_revoke_still_active_{username}.png"
await page.screenshot(path=screenshot_path) await page.screenshot(path=screenshot_path)
return False return False
except Exception: except Exception:
pass pass
logger.info(f"✓ Background sync access revoked for {username}") logger.info("✓ Background sync access revoked for %s", username)
return True return True
@@ -1197,7 +1222,7 @@ async def verify_app_password_deleted(username: str) -> bool:
Returns: Returns:
True if background sync credentials no longer exist 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""" query = f"""
SELECT userid, configkey, configvalue SELECT userid, configkey, configvalue
@@ -1230,18 +1255,20 @@ async def verify_app_password_deleted(username: str) -> bool:
) )
output = result.stdout 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 # After deletion, we should NOT see background_sync_password
if "background_sync_password" not in output: 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 return True
else: else:
logger.warning(f"Background sync credentials still exist for {username}") logger.warning("Background sync credentials still exist for %s", username)
return False return False
except Exception as e: 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 return False
@@ -1316,7 +1343,9 @@ async def test_revoke_background_sync_access(
f"Background sync credentials not deleted for {username}" 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: finally:
await context.close() await context.close()
@@ -58,7 +58,7 @@ async def wait_for_vector_sync(
while waited < timeout_seconds: while waited < timeout_seconds:
sync_status = await mcp_client.call_tool("nc_get_vector_sync_status", {}) sync_status = await mcp_client.call_tool("nc_get_vector_sync_status", {})
if sync_status.isError: 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 return False, None
status_data = json.loads(sync_status.content[0].text) 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) pending_count = status_data.get("pending_count", 1)
logger.info( logger.info(
f"Sync status at {waited}s: indexed={indexed_count}, " "Sync status at %ss: indexed=%s, pending=%s, status=%s",
f"pending={pending_count}, status={status_data.get('status')}" waited,
indexed_count,
pending_count,
status_data.get("status"),
) )
if indexed_count > initial_indexed_count and pending_count == 0: if indexed_count > initial_indexed_count and pending_count == 0:
logger.info( logger.info(
f"✓ Sync complete: {indexed_count} documents indexed " "✓ Sync complete: %s documents indexed (was %s)",
f"(was {initial_indexed_count})" indexed_count,
initial_indexed_count,
) )
return True, status_data 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) # Phase 2: Complete full Astrolabe authorization (OAuth + app password)
await login_to_nextcloud(page, username, password) await login_to_nextcloud(page, username, password)
auth_result = await complete_astrolabe_authorization(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 # Create MCP client session as alice - all MCP operations inside this block
async with create_mcp_client_session( 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_data = json.loads(initial_sync.content[0].text)
initial_count = initial_data.get("indexed_count", 0) 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 # Create note with unique searchable term
unique_term = f"plotly_viz_test_{uuid.uuid4().hex[:8]}" 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_data = json.loads(note_response.content[0].text)
note_id = note_data.get("id") 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 # Phase 4: Wait for vector indexing
sync_complete, status = await wait_for_vector_sync( 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") search_input = page.locator(".mcp-search-input input")
await search_input.wait_for(timeout=10000, state="visible") await search_input.wait_for(timeout=10000, state="visible")
await search_input.fill(unique_term) 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 # Trigger search by pressing Enter on the input field
# This is wired to performSearch via @keyup.enter in the Vue component # 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 for attempt in range(60): # 60 attempts, 500ms each = 30s total
if await error_note.count() > 0: if await error_note.count() > 0:
error_text = await error_note.text_content() 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}") pytest.fail(f"Search failed with error: {error_text}")
if await no_results_text.count() > 0: 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: if await results_text_pattern.count() > 0:
results_text = await results_text_pattern.first.text_content() 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 found_state = True
break break
if attempt % 10 == 0: if attempt % 10 == 0:
logger.info( logger.info(
f"Waiting for results... (attempt {attempt + 1}/60)" "Waiting for results... (attempt %s/60)", attempt + 1
) )
await anyio.sleep(0.5) 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: if not found_state:
await page.screenshot(path="/tmp/astrolabe_search_timeout.png") await page.screenshot(path="/tmp/astrolabe_search_timeout.png")
page_content = await page.content() page_content = await page.content()
logger.error(f"Search state not resolved. Page URL: {page.url}") logger.error("Search state not resolved. Page URL: %s", page.url)
logger.error(f"Page content snippet: {page_content[:2000]}") logger.error("Page content snippet: %s", page_content[:2000])
raise AssertionError("Search did not complete within timeout") raise AssertionError("Search did not complete within timeout")
except AssertionError: 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 # Take another screenshot and get page content for debugging
await page.screenshot(path="/tmp/astrolabe_search_timeout.png") await page.screenshot(path="/tmp/astrolabe_search_timeout.png")
page_content = await page.content() page_content = await page.content()
logger.error(f"Search state not resolved. Page URL: {page.url}") logger.error("Search state not resolved. Page URL: %s", page.url)
logger.error(f"Page content snippet: {page_content[:2000]}") logger.error("Page content snippet: %s", page_content[:2000])
raise AssertionError(f"Search did not complete: {e}") raise AssertionError(f"Search did not complete: {e}")
logger.info("Results loaded") 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_items = page.locator(".mcp-result-item")
result_count = await result_items.count() result_count = await result_items.count()
assert result_count > 0, "No search results displayed" 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 # Verify our note appears in results
found_note = False 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() title_text = await title_elem.text_content()
if title_text and unique_term in title_text: if title_text and unique_term in title_text:
found_note = True found_note = True
logger.info(f"✓ Found test note in results: {title_text}") logger.info("✓ Found test note in results: %s", title_text)
break break
assert found_note, f"Created note with '{unique_term}' not found in results" 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} "nc_notes_delete_note", {"note_id": note_id}
) )
if not delete_response.isError: 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 note_id = None # Mark as cleaned
else: else:
logger.warning( 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: 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: finally:
# Cleanup note if not already cleaned (create new client for cleanup) # 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} "nc_notes_delete_note", {"note_id": note_id}
) )
if not delete_response.isError: 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: else:
logger.warning( 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: 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 # Close browser context
await context.close() await context.close()
@@ -45,7 +45,7 @@ async def login_to_nextcloud(page: Page, username: str, password: str):
""" """
nextcloud_url = "http://localhost:8080" 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") await page.goto(f"{nextcloud_url}/login", wait_until="networkidle")
# Fill in login form # 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, ( assert "/login" not in current_url, (
f"Login failed for {username}, still on login page" 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( async def generate_app_password(
@@ -78,7 +78,7 @@ async def generate_app_password(
Returns: Returns:
The generated app password string 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" nextcloud_url = "http://localhost:8080"
@@ -89,7 +89,7 @@ async def generate_app_password(
# Fill the app password input field # Fill the app password input field
app_password_input = page.locator('input[placeholder="App name"]') app_password_input = page.locator('input[placeholder="App name"]')
await app_password_input.fill(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 # Wait for Vue.js to react and enable the button
await anyio.sleep(1.0) await anyio.sleep(1.0)
@@ -116,12 +116,12 @@ async def generate_app_password(
value = await input_elem.input_value() value = await input_elem.input_value()
if value and "-" in value and len(value) > 20: if value and "-" in value and len(value) > 20:
app_password = value.strip() app_password = value.strip()
logger.info(f"Found app password in input {idx}") logger.info("Found app password in input %s", idx)
break break
except Exception: except Exception:
continue continue
except Exception as e: 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: if not app_password:
screenshot_path = f"/tmp/app_password_generation_{username}.png" 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}") 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 the dialog
close_button = page.get_by_role("button", name="Close") close_button = page.get_by_role("button", name="Close")
@@ -163,7 +163,7 @@ async def save_app_password_in_astrolabe(
Returns: Returns:
True if the password was saved successfully (based on network response) 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" nextcloud_url = "http://localhost:8080"
@@ -174,7 +174,7 @@ async def save_app_password_in_astrolabe(
nonlocal credentials_response_status nonlocal credentials_response_status
if "background-sync/credentials" in resp.url or "storeAppPassword" in resp.url: if "background-sync/credentials" in resp.url or "storeAppPassword" in resp.url:
credentials_response_status = resp.status 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) page.on("response", capture_response)
@@ -188,7 +188,7 @@ async def save_app_password_in_astrolabe(
try: try:
complete_badge = page.locator('text="Complete"').first complete_badge = page.locator('text="Complete"').first
if await complete_badge.is_visible(timeout=2000): 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 return True
except Exception: except Exception:
pass pass
@@ -208,7 +208,7 @@ async def save_app_password_in_astrolabe(
# Enter the app password # Enter the app password
await app_password_input.fill(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) 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 # Verify the save was successful by checking network response
if credentials_response_status == 200: 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 return True
else: else:
logger.error( 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" screenshot_path = f"/tmp/astrolabe_save_failed_{username}.png"
await page.screenshot(path=screenshot_path) await page.screenshot(path=screenshot_path)
@@ -284,7 +286,7 @@ def get_background_sync_credentials(username: str) -> dict | None:
return None return None
except Exception as e: 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 return None
@@ -325,11 +327,11 @@ def delete_user_credentials(username: str) -> bool:
timeout=10, timeout=10,
) )
logger.info(f"Deleted credentials for {username}") logger.info("Deleted credentials for %s", username)
return result.returncode == 0 return result.returncode == 0
except Exception as e: 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 return False
@@ -489,7 +491,7 @@ async def test_credential_isolation_between_users(
# Verify stored # Verify stored
creds = get_background_sync_credentials(username) creds = get_background_sync_credentials(username)
assert creds is not None, f"Credentials not stored for {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: finally:
await context.close() await context.close()
+16 -14
View File
@@ -26,7 +26,7 @@ async def board_with_two_stacks(nc_client: NextcloudClient):
board_title = f"Reorder Test Board {unique_suffix}" board_title = f"Reorder Test Board {unique_suffix}"
board = None board = None
logger.info(f"Creating board with two stacks: {board_title}") logger.info("Creating board with two stacks: %s", board_title)
try: try:
board = await nc_client.deck.create_board(board_title, "0000FF") board = await nc_client.deck.create_board(board_title, "0000FF")
board_id = board.id board_id = board.id
@@ -40,7 +40,7 @@ async def board_with_two_stacks(nc_client: NextcloudClient):
"title": source_stack.title, "title": source_stack.title,
"order": source_stack.order, "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) # Create target stack (stack 2)
target_stack = await nc_client.deck.create_stack( 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, "title": target_stack.title,
"order": target_stack.order, "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 = { board_data = {
"id": board_id, "id": board_id,
@@ -63,11 +63,11 @@ async def board_with_two_stacks(nc_client: NextcloudClient):
finally: finally:
if board: if board:
logger.info(f"Cleaning up board ID: {board.id}") logger.info("Cleaning up board ID: %s", board.id)
try: try:
await nc_client.deck.delete_board(board.id) await nc_client.deck.delete_board(board.id)
except Exception as e: 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( 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" board_id, source_stack_id, card_title, description="Card to be moved"
) )
card_id = card.id 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: try:
# Verify card is in source stack # 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"Card should start in source stack {source_stack_id}, "
f"but is in {card_before.stackId}" 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 # Move card to target stack
logger.info( logger.info(
f"Moving card {card_id} from stack {source_stack_id} " "Moving card %s from stack %s to stack %s",
f"to stack {target_stack_id}" card_id,
source_stack_id,
target_stack_id,
) )
await nc_client.deck.reorder_card( await nc_client.deck.reorder_card(
board_id=board_id, 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"Card should have moved to target stack {target_stack_id}, "
f"but is in {card_after.stackId}" 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: finally:
# Clean up - try to delete from target stack first, then source # 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: try:
await nc_client.deck.delete_card(board_id, source_stack_id, card_id) await nc_client.deck.delete_card(board_id, source_stack_id, card_id)
except Exception as e: 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( 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( card2 = await nc_client.deck.create_card(
board_id, source_stack_id, f"Card 2 {unique_suffix}", order=1 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: try:
# Reorder card1 to position after card2 # Reorder card1 to position after card2
@@ -162,7 +164,7 @@ async def test_reorder_card_within_same_stack(
order=2, # Move to position 2 order=2, # Move to position 2
target_stack_id=source_stack_id, # Same stack 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 # Verify card is still in the same stack
card_after = await nc_client.deck.get_card(board_id, source_stack_id, card1.id) 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, card1.id)
await nc_client.deck.delete_card(board_id, source_stack_id, card2.id) await nc_client.deck.delete_card(board_id, source_stack_id, card2.id)
except Exception as e: except Exception as e:
logger.warning(f"Error cleaning up cards: {e}") logger.warning("Error cleaning up cards: %s", e)
+12 -9
View File
@@ -129,7 +129,7 @@ async def indexed_manual_pdf(nc_client, nc_mcp_client):
manual_path = os.getenv("RAG_MANUAL_PATH", DEFAULT_MANUAL_PATH) 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 # Get file info to verify file exists and get file ID. After the
# round-7 contract widening, get_file_info raises HTTPStatusError on # 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)") pytest.skip(f"Manual PDF unreadable at '{manual_path}' (malformed PROPFIND)")
file_id = file_info["id"] 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 # Create or get the vector-index tag
tag = await nc_client.webdav.get_or_create_tag("vector-index") tag = await nc_client.webdav.get_or_create_tag("vector-index")
tag_id = tag["id"] 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 # Assign tag to file
await nc_client.webdav.assign_tag_to_file(file_id, tag_id) 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 # Wait for vector sync to complete indexing
max_attempts = 60 max_attempts = 60
@@ -176,23 +176,26 @@ async def indexed_manual_pdf(nc_client, nc_mcp_client):
pending = content.get("pending_count", 1) pending = content.get("pending_count", 1)
logger.info( logger.info(
f"Attempt {attempt}/{max_attempts}: " "Attempt %s/%s: indexed=%s, pending=%s",
f"indexed={indexed}, pending={pending}" attempt,
max_attempts,
indexed,
pending,
) )
if indexed > 0 and pending == 0: if indexed > 0 and pending == 0:
logger.info( logger.info(
f"Vector indexing complete: {indexed} documents indexed" "Vector indexing complete: %s documents indexed", indexed
) )
break break
except Exception as e: 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: if attempt < max_attempts:
await anyio.sleep(poll_interval) await anyio.sleep(poll_interval)
else: else:
logger.warning( 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 { yield {
+2 -2
View File
@@ -57,7 +57,7 @@ async def test_unstructured_api_enabled_parsing(
await nc_client.webdav.write_file( await nc_client.webdav.write_file(
test_file, pdf_content, content_type="application/pdf" 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) # Read the PDF using MCP tool (should parse via Unstructured API)
mcp_result = await nc_mcp_client.call_tool( mcp_result = await nc_mcp_client.call_tool(
@@ -123,7 +123,7 @@ async def test_unstructured_api_with_docx(
docx_content, docx_content,
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", 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 # Read the file using MCP tool
mcp_result = await nc_mcp_client.call_tool( mcp_result = await nc_mcp_client.call_tool(
+9 -9
View File
@@ -215,7 +215,7 @@ class BenchmarkMetrics:
@asynccontextmanager @asynccontextmanager
async def create_mcp_session(url: str): async def create_mcp_session(url: str):
"""Create an MCP client session with proper cleanup.""" """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) streamable_context = streamablehttp_client(url)
session_context = None session_context = None
@@ -231,17 +231,17 @@ async def create_mcp_session(url: str):
try: try:
await session_context.__aexit__(None, None, None) await session_context.__aexit__(None, None, None)
except Exception as e: except Exception as e:
logger.debug(f"Error closing session: {e}") logger.debug("Error closing session: %s", e)
try: try:
await streamable_context.__aexit__(None, None, None) await streamable_context.__aexit__(None, None, None)
except Exception as e: 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: async def wait_for_mcp_server(url: str, max_attempts: int = 10) -> bool:
"""Wait for MCP server to be ready.""" """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): for attempt in range(1, max_attempts + 1):
try: try:
@@ -252,10 +252,10 @@ async def wait_for_mcp_server(url: str, max_attempts: int = 10) -> bool:
return True return True
except Exception as e: except Exception as e:
if attempt < max_attempts: 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) await anyio.sleep(2)
else: 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
return False return False
@@ -269,7 +269,7 @@ async def benchmark_worker(
stop_event: anyio.Event, stop_event: anyio.Event,
): ):
"""Single worker that runs operations for the specified duration.""" """Single worker that runs operations for the specified duration."""
logger.info(f"Worker {worker_id} starting...") logger.info("Worker %s starting...", worker_id)
try: try:
async with create_mcp_session(url) as session: async with create_mcp_session(url) as session:
@@ -297,10 +297,10 @@ async def benchmark_worker(
# Cleanup # Cleanup
await ops.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: 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( async def run_benchmark(
+28 -21
View File
@@ -74,7 +74,7 @@ class OAuthCallbackServer:
if code and state: if code and state:
self.auth_states[state] = code 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_response(200)
self.send_header("Content-type", "text/html") self.send_header("Content-type", "text/html")
@@ -94,7 +94,9 @@ class OAuthCallbackServer:
self.server = HTTPServer((self.host, self.port), CallbackHandler) self.server = HTTPServer((self.host, self.port), CallbackHandler)
def run(): 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.server.serve_forever()
self.thread = threading.Thread(target=run, daemon=True) 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"], "token_endpoint": config["token_endpoint"],
"registration_endpoint": config["registration_endpoint"], "registration_endpoint": config["registration_endpoint"],
} }
logger.info(f"Discovered endpoints: {endpoints}") logger.info("Discovered endpoints: %s", endpoints)
return endpoints return endpoints
@@ -168,7 +170,7 @@ async def setup_oauth_client(
redirect_uris=[callback_url], 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 { return {
"client_id": client_info.client_id, "client_id": client_info.client_id,
"client_secret": client_info.client_secret, "client_secret": client_info.client_secret,
@@ -197,7 +199,7 @@ async def create_and_authenticate_user(
Returns: Returns:
OAuth access token for the user 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 # Create Nextcloud user
await user_pool.create_nextcloud_user( await user_pool.create_nextcloud_user(
@@ -218,7 +220,7 @@ async def create_and_authenticate_user(
auth_states=auth_states, auth_states=auth_states,
) )
logger.info(f"Successfully authenticated user: {username}") logger.info("Successfully authenticated user: %s", username)
return token return token
@@ -239,7 +241,7 @@ async def oauth_benchmark_worker(
metrics: Metrics collector metrics: Metrics collector
stop_event: Event to signal stop 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() start_time = time.time()
operation_count = 0 operation_count = 0
@@ -265,18 +267,21 @@ async def oauth_benchmark_worker(
await anyio.sleep(0.05) await anyio.sleep(0.05)
logger.info( 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(): except anyio.get_cancelled_exc_class():
# Handle task cancellation gracefully (e.g., during benchmark shutdown) # Handle task cancellation gracefully (e.g., during benchmark shutdown)
logger.info( logger.info(
f"Worker for {user_wrapper.username} was cancelled " "Worker for %s was cancelled (completed %s operations)",
f"(completed {operation_count} operations)" user_wrapper.username,
operation_count,
) )
raise # Re-raise to allow proper cleanup raise # Re-raise to allow proper cleanup
except Exception as e: 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( async def show_progress(
@@ -432,7 +437,9 @@ async def run_oauth_benchmark(
return (username, password, token) return (username, password, token)
except Exception as e: 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 return None
async with async_playwright() as p: async with async_playwright() as p:
@@ -452,7 +459,7 @@ async def run_oauth_benchmark(
) )
results.append(result) results.append(result)
except Exception as e: except Exception as e:
logger.error(f"User creation task failed: {e}") logger.error("User creation task failed: %s", e)
results.append(e) results.append(e)
async with anyio.create_task_group() as tg: async with anyio.create_task_group() as tg:
@@ -462,7 +469,7 @@ async def run_oauth_benchmark(
# Process results # Process results
for result in results: for result in results:
if isinstance(result, Exception): if isinstance(result, Exception):
logger.error(f"User creation task failed: {result}") logger.error("User creation task failed: %s", result)
continue continue
if result is None: if result is None:
continue continue
@@ -496,7 +503,7 @@ async def run_oauth_benchmark(
print(f" ✓ Session created for '{username}'") print(f" ✓ Session created for '{username}'")
return wrapper return wrapper
except Exception as e: 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 return None
# Create all sessions concurrently using anyio task groups # Create all sessions concurrently using anyio task groups
@@ -508,7 +515,7 @@ async def run_oauth_benchmark(
result = await create_session_task(username) result = await create_session_task(username)
session_results.append(result) session_results.append(result)
except Exception as e: except Exception as e:
logger.error(f"Session creation task failed: {e}") logger.error("Session creation task failed: %s", e)
session_results.append(e) session_results.append(e)
async with anyio.create_task_group() as tg: async with anyio.create_task_group() as tg:
@@ -518,7 +525,7 @@ async def run_oauth_benchmark(
# Process results # Process results
for result in session_results: for result in session_results:
if isinstance(result, Exception): if isinstance(result, Exception):
logger.error(f"Session creation task failed: {result}") logger.error("Session creation task failed: %s", result)
continue continue
if result is not None: if result is not None:
user_wrappers.append(result) user_wrappers.append(result)
@@ -573,7 +580,7 @@ async def run_oauth_benchmark(
print("✓ All sessions closed\n") print("✓ All sessions closed\n")
except Exception as e: 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 # Don't re-raise here - we want cleanup to run
finally: finally:
@@ -583,7 +590,7 @@ async def run_oauth_benchmark(
callback_server.stop() callback_server.stop()
logger.info("OAuth callback server stopped") logger.info("OAuth callback server stopped")
except Exception as e: except Exception as e:
logger.warning(f"Error stopping callback server: {e}") logger.warning("Error stopping callback server: %s", e)
# Cleanup test users # Cleanup test users
if cleanup and created_users: if cleanup and created_users:
@@ -596,10 +603,10 @@ async def run_oauth_benchmark(
await cleanup_client.users.delete_user(userid=username) await cleanup_client.users.delete_user(userid=username)
print(f" ✓ Deleted user '{username}'") print(f" ✓ Deleted user '{username}'")
except Exception as e: 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") print("✓ Cleanup complete\n")
except Exception as e: except Exception as e:
logger.error(f"Error during user cleanup: {e}") logger.error("Error during user cleanup: %s", e)
print( print(
"⚠️ Failed to cleanup users. Please run cleanup script manually.\n" "⚠️ Failed to cleanup users. Please run cleanup script manually.\n"
) )
+21 -21
View File
@@ -92,7 +92,7 @@ class OAuthUserPool:
Returns: Returns:
OAuth access token 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: if not self._http_client:
raise RuntimeError( raise RuntimeError(
@@ -118,7 +118,7 @@ class OAuthUserPool:
if not access_token: if not access_token:
raise ValueError(f"No access token in response for {username}") 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 return access_token
async def add_user(self, username: str, password: str, token: str) -> UserProfile: async def add_user(self, username: str, password: str, token: str) -> UserProfile:
@@ -134,11 +134,11 @@ class OAuthUserPool:
UserProfile for the added user UserProfile for the added user
""" """
if username in self.users: 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) profile = UserProfile(username=username, password=password, token=token)
self.users[username] = profile 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 return profile
async def create_user_session( async def create_user_session(
@@ -177,7 +177,7 @@ class OAuthUserPool:
# Store both session and context for proper cleanup # Store both session and context for proper cleanup
profile.session = session profile.session = session
profile.streamable_context = streamable_context profile.streamable_context = streamable_context
logger.info(f"Created MCP session for {username}") logger.info("Created MCP session for %s", username)
return session return session
except Exception as e: except Exception as e:
@@ -185,7 +185,7 @@ class OAuthUserPool:
try: try:
await streamable_context.__aexit__(None, None, None) await streamable_context.__aexit__(None, None, None)
except Exception as cleanup_error: except Exception as cleanup_error:
logger.debug(f"Error during cleanup: {cleanup_error}") logger.debug("Error during cleanup: %s", cleanup_error)
raise e raise e
async def close_user_session(self, username: str): async def close_user_session(self, username: str):
@@ -200,7 +200,7 @@ class OAuthUserPool:
try: try:
await profile.session.__aexit__(None, None, None) await profile.session.__aexit__(None, None, None)
except Exception as e: 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 profile.session = None
# Close streamable context # Close streamable context
@@ -208,7 +208,7 @@ class OAuthUserPool:
try: try:
await profile.streamable_context.__aexit__(None, None, None) await profile.streamable_context.__aexit__(None, None, None)
except Exception as e: 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 profile.streamable_context = None
async def close_all_sessions(self): async def close_all_sessions(self):
@@ -270,7 +270,7 @@ class OAuthUserPool:
Raises: Raises:
HTTPStatusError: If user creation fails 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( await self.admin_client.users.create_user(
userid=username, userid=username,
@@ -279,7 +279,7 @@ class OAuthUserPool:
email=email or f"{username}@benchmark.local", 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( return UserConfig(
username=username, username=username,
@@ -296,13 +296,13 @@ class OAuthUserPool:
Args: Args:
username: Username to delete username: Username to delete
""" """
logger.info(f"Deleting Nextcloud user: {username}") logger.info("Deleting Nextcloud user: %s", username)
try: try:
await self.admin_client.users.delete_user(userid=username) 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: 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( async def acquire_token_playwright(
self, self,
@@ -338,8 +338,8 @@ class OAuthUserPool:
ValueError: If token exchange fails ValueError: If token exchange fails
""" """
logger.info(f"Starting Playwright OAuth flow for {username}...") logger.info("Starting Playwright OAuth flow for %s...", username)
logger.debug(f"Using state: {state[:16]}...") logger.debug("Using state: %s...", state[:16])
# Construct authorization URL # Construct authorization URL
auth_url = ( auth_url = (
@@ -363,7 +363,7 @@ class OAuthUserPool:
# Login if needed # Login if needed
if "/login" in current_url or "/index.php/login" in current_url: 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.wait_for_selector('input[name="user"]', timeout=10000)
await page.fill('input[name="user"]', username) await page.fill('input[name="user"]', username)
await page.fill('input[name="password"]', password) await page.fill('input[name="password"]', password)
@@ -382,7 +382,7 @@ class OAuthUserPool:
await authorize_button.click() await authorize_button.click()
await page.wait_for_load_state("networkidle", timeout=10000) await page.wait_for_load_state("networkidle", timeout=10000)
except Exception as e: 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 # Wait for callback server to receive auth code
logger.info("Waiting for OAuth callback...") logger.info("Waiting for OAuth callback...")
@@ -392,20 +392,20 @@ class OAuthUserPool:
if time.time() - start_time > timeout_seconds: if time.time() - start_time > timeout_seconds:
screenshot_path = f"/tmp/oauth_timeout_{username}.png" screenshot_path = f"/tmp/oauth_timeout_{username}.png"
await page.screenshot(path=screenshot_path) await page.screenshot(path=screenshot_path)
logger.error(f"Screenshot saved to {screenshot_path}") logger.error("Screenshot saved to %s", screenshot_path)
raise TimeoutError( raise TimeoutError(
f"Timeout waiting for OAuth callback for {username}" f"Timeout waiting for OAuth callback for {username}"
) )
await anyio.sleep(0.5) await anyio.sleep(0.5)
auth_code = auth_states[state] auth_code = auth_states[state]
logger.info(f"Received auth code for {username}") logger.info("Received auth code for %s", username)
finally: finally:
await context.close() await context.close()
# Exchange code for token # 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( token_response = await self._http_client.post(
self.token_endpoint, self.token_endpoint,
data={ data={
@@ -424,7 +424,7 @@ class OAuthUserPool:
if not access_token: if not access_token:
raise ValueError(f"No access token for {username}: {token_data}") 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 return access_token
+4 -4
View File
@@ -115,7 +115,7 @@ class Workflow(ABC):
return step_result return step_result
except Exception as e: except Exception as e:
duration = time.time() - start 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_result = WorkflowStepResult(
step_name=step_name, step_name=step_name,
user=user.username, user=user.username,
@@ -236,7 +236,7 @@ class NoteShareWorkflow(Workflow):
return self._finish(success=True) return self._finish(success=True)
except Exception as e: 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)) return self._finish(False, error=str(e))
@@ -338,7 +338,7 @@ class CollaborativeEditWorkflow(Workflow):
return self._finish(success=True) return self._finish(success=True)
except Exception as e: 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)) return self._finish(False, error=str(e))
@@ -424,7 +424,7 @@ class FileShareAndDownloadWorkflow(Workflow):
return self._finish(success=True) return self._finish(success=True)
except Exception as e: 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)) return self._finish(False, error=str(e))
+4 -4
View File
@@ -182,12 +182,12 @@ class WorkloadOperations:
async def cleanup(self): async def cleanup(self):
"""Clean up any resources created during testing.""" """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[:]: for note_id in self._created_notes[:]:
try: try:
await self.delete_note(note_id) await self.delete_note(note_id)
except Exception as e: 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: class MixedWorkload:
@@ -210,7 +210,7 @@ class MixedWorkload:
async def warmup(self, count: int = 10): async def warmup(self, count: int = 10):
"""Create initial notes for read/update operations.""" """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): for _ in range(count):
result = await self.ops.create_note() result = await self.ops.create_note()
if result.success and self.ops._created_notes: if result.success and self.ops._created_notes:
@@ -225,7 +225,7 @@ class MixedWorkload:
etag = note_data.get("etag", "") etag = note_data.get("etag", "")
self._warmup_note_ids.append((note_id, etag)) self._warmup_note_ids.append((note_id, etag))
except Exception as e: 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: async def run_operation(self) -> OperationResult:
"""Execute one random operation based on the workload distribution.""" """Execute one random operation based on the workload distribution."""
+42 -36
View File
@@ -81,7 +81,7 @@ async def login_flow_oauth_client_credentials(anyio_backend, oauth_callback_serv
token_type="Bearer", 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 ( yield (
client_info.client_id, 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, registration_client_uri=client_info.registration_client_uri,
) )
logger.info( 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: 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") @pytest.fixture(scope="session")
@@ -137,7 +137,7 @@ async def login_flow_oauth_token(
) )
resource_id = resource_metadata.get("resource") resource_id = resource_metadata.get("resource")
except Exception as e: 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 resource_id = None
state = secrets.token_urlsafe(32) 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() page = await context.new_page()
try: 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) 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" # Step 1: "Connect to your account" page - click "Log in"
login_btn = page.get_by_role("button", name="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: except Exception:
logger.info("No 'Log in' button - may already be on login/grant page") 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) # Step 2: Login form (only if not already logged in)
# If the user has an active session, they skip straight to the grant page. # 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.locator('input[name="password"]').fill(password)
await page.get_by_role("button", name="Log in", exact=True).click() await page.get_by_role("button", name="Log in", exact=True).click()
await page.wait_for_load_state("networkidle", timeout=60000) await page.wait_for_load_state("networkidle", timeout=60000)
logger.info(f"After login: {page.url}") logger.info("After login: %s", page.url)
else: else:
logger.info("No login form - already logged in via session") 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() await grant_btn.click()
logger.info("Clicked 'Grant access'") logger.info("Clicked 'Grant access'")
except Exception as e: 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") await page.screenshot(path="/tmp/login_flow_no_grant.png")
# Step 4: Password confirmation dialog # Step 4: Password confirmation dialog
@@ -310,7 +310,7 @@ async def _complete_login_flow_v2(browser, login_url: str) -> None:
except Exception: except Exception:
# The grant may have completed without the success page being visible # The grant may have completed without the success page being visible
await page.wait_for_load_state("networkidle", timeout=10000) 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: finally:
await context.close() await context.close()
@@ -347,14 +347,14 @@ async def nc_mcp_login_flow_client(
completes the Login Flow v2 browser login. completes the Login Flow v2 browser login.
""" """
message = params.message message = params.message
logger.info(f"Elicitation received: {message[:100]}...") logger.info("Elicitation received: %s...", message[:100])
# Extract login URL from elicitation message # Extract login URL from elicitation message
for line in message.split("\n"): for line in message.split("\n"):
stripped = line.strip() stripped = line.strip()
if stripped.startswith("http") and "/login/v2/" in stripped: if stripped.startswith("http") and "/login/v2/" in stripped:
login_url_holder["url"] = stripped login_url_holder["url"] = stripped
logger.info(f"Extracted login URL: {stripped[:80]}...") logger.info("Extracted login URL: %s...", stripped[:80])
break break
if "url" in login_url_holder: 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) 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), # If elicitation didn't fire (client doesn't support it),
# extract URL from the response and complete flow manually # 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_result = await session.call_tool("nc_auth_check_status", {})
status_data = json.loads(status_result.content[0].text) status_data = json.loads(status_result.content[0].text)
status = status_data.get("status") 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": if status == "provisioned":
logger.info( logger.info(
f"Login Flow v2 provisioned! Username: {status_data.get('username')}" "Login Flow v2 provisioned! Username: %s",
status_data.get("username"),
) )
break break
@@ -698,8 +699,10 @@ async def all_login_flow_user_tokens(
elapsed = time.time() - start_time elapsed = time.time() - start_time
logger.info( logger.info(
f"Fetched {len(results)} login-flow tokens in {elapsed:.1f}s " "Fetched %s login-flow tokens in %ss (~%ss per user)",
f"(~{elapsed / len(results):.1f}s per user)" len(results),
format(elapsed, ".1f"),
format(elapsed / len(results), ".1f"),
) )
return results # type: ignore[return-value] 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) status_data = json.loads(status_result.content[0].text)
if status_data.get("status") == "provisioned": if status_data.get("status") == "provisioned":
logger.info( logger.info(
f"Login Flow v2 provisioned for {username}: " "Login Flow v2 provisioned for %s: %s",
f"{status_data.get('username')}" username,
status_data.get("username"),
) )
break break
if status_data.get("status") in ("not_initiated", "error"): 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() page = await context.new_page()
try: 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) 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" # Step 1: "Connect to your account" page - click "Log in"
login_btn = page.get_by_role("button", name="Log in") login_btn = page.get_by_role("button", name="Log in")
try: try:
await login_btn.wait_for(timeout=10000) await login_btn.wait_for(timeout=10000)
await login_btn.click() 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) await page.wait_for_load_state("networkidle", timeout=30000)
except Exception: except Exception:
logger.info( 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) # Step 2: Login form (only if not already logged in)
user_field = page.locator('input[name="user"]') user_field = page.locator('input[name="user"]')
if await user_field.count() > 0: 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 user_field.fill(username)
await page.locator('input[name="password"]').fill(password) await page.locator('input[name="password"]').fill(password)
await page.get_by_role("button", name="Log in", exact=True).click() await page.get_by_role("button", name="Log in", exact=True).click()
await page.wait_for_load_state("networkidle", timeout=60000) 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: 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" # Step 3: "Account access" grant page - click "Grant access"
grant_btn = page.get_by_role("button", name="Grant access") grant_btn = page.get_by_role("button", name="Grant access")
try: try:
await grant_btn.wait_for(timeout=15000) await grant_btn.wait_for(timeout=15000)
await grant_btn.click() await grant_btn.click()
logger.info(f"[{username}] Clicked 'Grant access'") logger.info("[%s] Clicked 'Grant access'", username)
except Exception as e: 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") await page.screenshot(path=f"/tmp/login_flow_no_grant_{username}.png")
# Step 4: Password confirmation dialog # Step 4: Password confirmation dialog
@@ -841,27 +845,27 @@ async def _complete_login_flow_v2_as_user(
) )
try: try:
await confirm_password.wait_for(timeout=10000) 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) await confirm_password.fill(password)
confirm_btn = page.get_by_role("dialog").get_by_role( confirm_btn = page.get_by_role("dialog").get_by_role(
"button", name="Confirm" "button", name="Confirm"
) )
await confirm_btn.wait_for(timeout=5000) await confirm_btn.wait_for(timeout=5000)
await confirm_btn.click() await confirm_btn.click()
logger.info(f"[{username}] Clicked 'Confirm' in password dialog") logger.info("[%s] Clicked 'Confirm' in password dialog", username)
except Exception: except Exception:
logger.info( logger.info(
f"[{username}] No password confirmation dialog " "[%s] No password confirmation dialog (may have been auto-confirmed)",
"(may have been auto-confirmed)" username,
) )
# Step 5: Wait for "Account connected" success page # Step 5: Wait for "Account connected" success page
try: try:
await page.get_by_text("Account connected").wait_for(timeout=15000) 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: except Exception:
await page.wait_for_load_state("networkidle", timeout=10000) 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: finally:
await context.close() await context.close()
@@ -986,7 +990,9 @@ async def login_flow_static_client_credentials(anyio_backend, oauth_callback_ser
capture_output=True, 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( result = subprocess.run(
[ [
"docker", "docker",
@@ -61,9 +61,9 @@ async def test_dcr_deletion_authentication_methods(
) )
deletion_endpoint = f"{nextcloud_host}/apps/oidc/register/{client_info.client_id}" deletion_endpoint = f"{nextcloud_host}/apps/oidc/register/{client_info.client_id}"
logger.info(f"\nTesting deletion endpoint: {deletion_endpoint}") logger.info("\\nTesting deletion endpoint: %s", deletion_endpoint)
logger.info(f"Client ID: {client_info.client_id}") logger.info("Client ID: %s", client_info.client_id)
logger.info(f"Client Secret (first 16 chars): {client_info.client_secret[:16]}...") logger.info("Client Secret (first 16 chars): %s...", client_info.client_secret[:16])
results = {} results = {}
@@ -79,11 +79,11 @@ async def test_dcr_deletion_authentication_methods(
"status": response.status_code, "status": response.status_code,
"body": response.text[:200], "body": response.text[:200],
} }
logger.info(f"Status: {response.status_code}") logger.info("Status: %s", response.status_code)
logger.info(f"Body: {response.text[:200]}") logger.info("Body: %s", response.text[:200])
except Exception as e: except Exception as e:
results["basic_auth"] = {"status": "error", "error": str(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 # Method 2: Credentials in JSON body
logger.info("\n=== 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, "status": response.status_code,
"body": response.text[:200], "body": response.text[:200],
} }
logger.info(f"Status: {response.status_code}") logger.info("Status: %s", response.status_code)
logger.info(f"Body: {response.text[:200]}") logger.info("Body: %s", response.text[:200])
except Exception as e: except Exception as e:
results["json_body"] = {"status": "error", "error": str(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 # Method 3: Credentials in query parameters
logger.info("\n=== 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, "status": response.status_code,
"body": response.text[:200], "body": response.text[:200],
} }
logger.info(f"Status: {response.status_code}") logger.info("Status: %s", response.status_code)
logger.info(f"Body: {response.text[:200]}") logger.info("Body: %s", response.text[:200])
except Exception as e: except Exception as e:
results["query_params"] = {"status": "error", "error": str(e)} results["query_params"] = {"status": "error", "error": str(e)}
logger.error(f"Error: {e}") logger.error("Error: %s", e)
# Method 4: No authentication (baseline) # Method 4: No authentication (baseline)
logger.info("\n=== 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, "status": response.status_code,
"body": response.text[:200], "body": response.text[:200],
} }
logger.info(f"Status: {response.status_code}") logger.info("Status: %s", response.status_code)
logger.info(f"Body: {response.text[:200]}") logger.info("Body: %s", response.text[:200])
except Exception as e: except Exception as e:
results["no_auth"] = {"status": "error", "error": str(e)} results["no_auth"] = {"status": "error", "error": str(e)}
logger.error(f"Error: {e}") logger.error("Error: %s", e)
# Print summary # Print summary
logger.info("\n" + "=" * 70) logger.info("\n" + "=" * 70)
@@ -146,7 +146,7 @@ async def test_dcr_deletion_authentication_methods(
for method, result in results.items(): for method, result in results.items():
status = result.get("status", "unknown") status = result.get("status", "unknown")
logger.info(f"{method:20s} → Status: {status}") logger.info("%s → Status: %s", format(method, "20s"), status)
# Analysis # Analysis
logger.info("\n" + "=" * 70) 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)") logger.info("✓ At least one authentication method succeeded (204 No Content)")
for method, result in results.items(): for method, result in results.items():
if result.get("status") == 204: if result.get("status") == 204:
logger.info(f" Working method: {method}") logger.info(" Working method: %s", method)
else: else:
logger.info("? Mixed results - further investigation needed") logger.info("? Mixed results - further investigation needed")
for method, result in results.items(): for method, result in results.items():
logger.info(f" {method}: {result.get('status')}") logger.info(" %s: %s", method, result.get("status"))
# Document the finding # Document the finding
assert all_401 or any_204, ( assert all_401 or any_204, (
+18 -16
View File
@@ -101,7 +101,7 @@ async def get_oauth_token_with_client(
try: try:
await _handle_oauth_consent_screen(page, username) await _handle_oauth_consent_screen(page, username)
except Exception as e: 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 # Wait for callback
logger.info("Waiting for OAuth callback...") logger.info("Waiting for OAuth callback...")
@@ -115,7 +115,7 @@ async def get_oauth_token_with_client(
await anyio.sleep(0.5) await anyio.sleep(0.5)
auth_code = auth_states[state] auth_code = auth_states[state]
logger.info(f"Got auth code: {auth_code[:20]}...") logger.info("Got auth code: %s...", auth_code[:20])
finally: finally:
await context.close() await context.close()
@@ -200,8 +200,8 @@ async def test_dcr_register_and_delete_lifecycle(
reg_response.raise_for_status() reg_response.raise_for_status()
full_client_info = reg_response.json() full_client_info = reg_response.json()
logger.info(f"Full registration response keys: {list(full_client_info.keys())}") logger.info("Full registration response keys: %s", list(full_client_info.keys()))
logger.info(f"Registration response: {full_client_info}") logger.info("Registration response: %s", full_client_info)
# Use the register_client function for the ClientInfo object # Use the register_client function for the ClientInfo object
client_info = await register_client( 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_access_token = full_client_info.get("registration_access_token")
registration_client_uri = full_client_info.get("registration_client_uri") registration_client_uri = full_client_info.get("registration_client_uri")
logger.info( 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( 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 # Step 2: Obtain token and verify client works
logger.info("Step 2: Obtaining OAuth token with registered client...") 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" 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 # Step 3: Delete the client using RFC 7592
logger.info("Step 3: Deleting OAuth client...") logger.info("Step 3: Deleting OAuth client...")
logger.info(f"Client ID: {client_info.client_id}") logger.info("Client ID: %s", client_info.client_id)
logger.info(f"Client secret (first 16 chars): {client_info.client_secret[:16]}...") logger.info("Client secret (first 16 chars): %s...", client_info.client_secret[:16])
logger.info( 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 # 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, ( assert success, (
"Client deletion should succeed with RFC 7592 Bearer token or Basic Auth" "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 # Step 4: Verify deleted client cannot obtain new tokens
logger.info("Step 4: Verifying 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 # Accept either 400 (Bad Request) or 401 (Unauthorized) as valid rejection
if token_response.status_code in [400, 401]: if token_response.status_code in [400, 401]:
logger.info( logger.info(
f"✅ Deleted client correctly rejected ({token_response.status_code})" "✅ Deleted client correctly rejected (%s)",
token_response.status_code,
) )
else: else:
# Unexpected success - client should be deleted # Unexpected success - client should be deleted
@@ -343,7 +345,7 @@ async def test_dcr_delete_with_wrong_credentials(
token_type="Bearer", 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) # Try to delete with wrong registration_access_token (RFC 7592 Bearer token)
logger.info("Attempting deletion with wrong registration_access_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_id = "nonexistent_" + secrets.token_urlsafe(16)
fake_client_secret = secrets.token_urlsafe(32) 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( success = await delete_client(
nextcloud_url=nextcloud_host, nextcloud_url=nextcloud_host,
@@ -442,7 +444,7 @@ async def test_dcr_deletion_is_idempotent(
token_type="Bearer", 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 # First deletion with RFC 7592 Bearer token
logger.info("First deletion attempt...") logger.info("First deletion attempt...")
@@ -63,28 +63,28 @@ async def test_new_dcr_registration_includes_access_token(
registration_data = response.json() registration_data = response.json()
# Log the full response # Log the full response
logger.info(f"\n{'=' * 70}") logger.info("\\n%s", "=" * 70)
logger.info("REGISTRATION RESPONSE") logger.info("REGISTRATION RESPONSE")
logger.info(f"{'=' * 70}") logger.info("%s", "=" * 70)
logger.info(f"Response keys: {sorted(registration_data.keys())}") logger.info("Response keys: %s", sorted(registration_data.keys()))
logger.info("\nFull response:") logger.info("\nFull response:")
for key, value in sorted(registration_data.items()): for key, value in sorted(registration_data.items()):
if key in ["client_secret", "registration_access_token"]: if key in ["client_secret", "registration_access_token"]:
# Truncate secrets for security # Truncate secrets for security
logger.info(f" {key}: {value[:20]}... (truncated)") logger.info(" %s: %s... (truncated)", key, value[:20])
else: else:
logger.info(f" {key}: {value}") logger.info(" %s: %s", key, value)
# Check for RFC 7592 required fields # Check for RFC 7592 required fields
logger.info(f"\n{'=' * 70}") logger.info("\\n%s", "=" * 70)
logger.info("RFC 7592 COMPLIANCE CHECK") logger.info("RFC 7592 COMPLIANCE CHECK")
logger.info(f"{'=' * 70}") logger.info("%s", "=" * 70)
has_token = "registration_access_token" in registration_data has_token = "registration_access_token" in registration_data
has_uri = "registration_client_uri" in registration_data has_uri = "registration_client_uri" in registration_data
logger.info(f"registration_access_token present: {has_token}") logger.info("registration_access_token present: %s", has_token)
logger.info(f"registration_client_uri present: {has_uri}") logger.info("registration_client_uri present: %s", has_uri)
if has_token and has_uri: if has_token and has_uri:
logger.info( logger.info(
@@ -100,15 +100,15 @@ async def test_new_dcr_registration_includes_access_token(
registration_client_uri = registration_data.get("registration_client_uri") registration_client_uri = registration_data.get("registration_client_uri")
# Now test deletion with the registration_access_token # 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("TESTING DCR DELETION WITH REGISTRATION_ACCESS_TOKEN")
logger.info(f"{'=' * 70}") logger.info("%s", "=" * 70)
deletion_endpoint = ( deletion_endpoint = (
registration_client_uri registration_client_uri
or f"{nextcloud_host}/apps/oidc/register/{client_id}" 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: async with httpx.AsyncClient(timeout=30.0) as client:
# Try deletion with Bearer token (RFC 7592 standard) # 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}"}, headers={"Authorization": f"Bearer {registration_access_token}"},
) )
logger.info(f"Response status: {delete_response.status_code}") logger.info("Response status: %s", delete_response.status_code)
logger.info(f"Response body: {delete_response.text[:200]}") logger.info("Response body: %s", delete_response.text[:200])
if delete_response.status_code == 204: if delete_response.status_code == 204:
logger.info( logger.info(
@@ -139,7 +139,7 @@ async def test_new_dcr_registration_includes_access_token(
) )
else: else:
logger.warning( logger.warning(
f"\n? UNEXPECTED: Got status {delete_response.status_code}" "\\n? UNEXPECTED: Got status %s", delete_response.status_code
) )
pytest.fail( pytest.fail(
f"Unexpected status code: {delete_response.status_code}, body: {delete_response.text[:500]}" 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"] client_secret = reg_data["client_secret"]
deletion_endpoint = f"{nextcloud_host}/apps/oidc/register/{client_id}" 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("TESTING DCR DELETION WITH HTTP BASIC AUTH")
logger.info(f"{'=' * 70}") logger.info("%s", "=" * 70)
logger.info(f"Endpoint: {deletion_endpoint}") logger.info("Endpoint: %s", deletion_endpoint)
async with httpx.AsyncClient(timeout=30.0) as client: async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.delete( response = await client.delete(
@@ -215,8 +215,8 @@ async def test_dcr_deletion_with_basic_auth_new_impl(
auth=(client_id, client_secret), auth=(client_id, client_secret),
) )
logger.info(f"Status: {response.status_code}") logger.info("Status: %s", response.status_code)
logger.info(f"Body: {response.text[:200]}") logger.info("Body: %s", response.text[:200])
if response.status_code == 204: if response.status_code == 204:
logger.info("\n✓ SUCCESS: HTTP Basic Auth works for deletion!") 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" "\n✗ HTTP Basic Auth not supported - use registration_access_token instead"
) )
else: 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 # This test is informational - we don't fail if Basic Auth doesn't work
# as long as Bearer token works # as long as Bearer token works
+10 -10
View File
@@ -157,7 +157,7 @@ async def get_oauth_token_with_client(
try: try:
await _handle_oauth_consent_screen(page, username) await _handle_oauth_consent_screen(page, username)
except Exception as e: 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 # Wait for callback
logger.info("Waiting for OAuth callback...") logger.info("Waiting for OAuth callback...")
@@ -171,7 +171,7 @@ async def get_oauth_token_with_client(
await anyio.sleep(0.5) await anyio.sleep(0.5)
auth_code = auth_states[state] auth_code = auth_states[state]
logger.info(f"Got auth code: {auth_code[:20]}...") logger.info("Got auth code: %s...", auth_code[:20])
finally: finally:
await context.close() await context.close()
@@ -245,7 +245,7 @@ async def test_dcr_respects_jwt_token_type(
token_type="jwt", 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 # Obtain token via OAuth flow
access_token = await get_oauth_token_with_client( 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" assert "notes.write" in scopes, "JWT scope claim missing notes.write"
logger.info( logger.info(
f"✅ DCR with token_type=jwt works correctly! " "✅ DCR with token_type=jwt works correctly! Token is JWT format with scope claim: %s",
f"Token is JWT format with scope claim: {payload['scope']}" payload["scope"],
) )
@@ -329,7 +329,7 @@ async def test_dcr_respects_bearer_token_type(
token_type="opaque", 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 # Obtain token via OAuth flow
access_token = await get_oauth_token_with_client( access_token = await get_oauth_token_with_client(
@@ -357,8 +357,8 @@ async def test_dcr_respects_bearer_token_type(
pass pass
logger.info( logger.info(
f"✅ DCR with token_type=opaque works correctly! " "✅ DCR with token_type=opaque works correctly! Token is opaque (not JWT format): %s...",
f"Token is opaque (not JWT format): {access_token[:30]}..." 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 # but we document the behavior explicitly here for reference
logger.info( logger.info(
"✅ JWT token scope embedding verified. " "✅ JWT token scope embedding verified. Expected scopes in JWT payload: %s",
f"Expected scopes in JWT payload: {DEFAULT_FULL_SCOPES}" DEFAULT_FULL_SCOPES,
) )
# This test primarily serves as documentation # This test primarily serves as documentation
@@ -82,7 +82,7 @@ async def test_oauth_clients(
token_type="Bearer", # Use opaque tokens for this test token_type="Bearer", # Use opaque tokens for this test
) )
clients["clientA"] = (client_a.client_id, client_a.client_secret) 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) # Create client B (will attempt to introspect client A's tokens)
logger.info("Creating OAuth client B for introspection testing") logger.info("Creating OAuth client B for introspection testing")
@@ -95,7 +95,7 @@ async def test_oauth_clients(
token_type="Bearer", token_type="Bearer",
) )
clients["clientB"] = (client_b.client_id, client_b.client_secret) 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) # Create client C (third party, should not be able to introspect)
logger.info("Creating OAuth client C for introspection testing") logger.info("Creating OAuth client C for introspection testing")
@@ -108,7 +108,7 @@ async def test_oauth_clients(
token_type="Bearer", token_type="Bearer",
) )
clients["clientC"] = (client_c.client_id, client_c.client_secret) 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 yield clients
@@ -146,7 +146,7 @@ async def test_introspection_requires_client_authentication(
) )
assert response.status_code == 401, "Should return 401 with invalid credentials" assert response.status_code == 401, "Should return 401 with invalid credentials"
data = response.json() 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": "..."} # Response may be either {"error": "invalid_client"} or {"message": "..."}
# Both are acceptable as long as we get 401 # Both are acceptable as long as we get 401
assert "error" in data or "message" in data, "Should return error information" 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) 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: if resource:
logger.info(f" Resource parameter: {resource[:16]}...") logger.info(" Resource parameter: %s...", resource[:16])
# Browser automation (same pattern as conftest.py) # Browser automation (same pattern as conftest.py)
context = await browser.new_context(ignore_https_errors=True) context = await browser.new_context(ignore_https_errors=True)
page = await context.new_page() page = await context.new_page()
try: 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) await page.goto(auth_url, wait_until="networkidle", timeout=60000)
current_url = page.url 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 # Handle login if needed
if "/login" in current_url or "/index.php/login" in current_url: 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.click('button[type="submit"]')
await page.wait_for_load_state("networkidle", timeout=60000) await page.wait_for_load_state("networkidle", timeout=60000)
current_url = page.url 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 # Wait a bit for page to fully render after login
await anyio.sleep(2) await anyio.sleep(2)
current_url = page.url 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 # Check page content for debugging
page_content = await page.content() page_content = await page.content()
has_consent_div = "#oidc-consent" in 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 # Handle consent screen using the helper from conftest
try: try:
consent_handled = await _handle_oauth_consent_screen(page, username) 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: except Exception as e:
logger.warning(f"Error handling consent screen: {e}") logger.warning("Error handling consent screen: %s", e)
# Take screenshot for debugging # Take screenshot for debugging
await page.screenshot(path=f"/tmp/consent_error_{state[:8]}.png") await page.screenshot(path=f"/tmp/consent_error_{state[:8]}.png")
logger.error("Consent error screenshot saved") 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" f"/tmp/oauth_introspection_test_timeout_{state[:8]}.png"
) )
await page.screenshot(path=screenshot_path) await page.screenshot(path=screenshot_path)
logger.error(f"Timeout! Screenshot saved to {screenshot_path}") logger.error("Timeout! Screenshot saved to %s", screenshot_path)
logger.error(f"Current URL: {page.url}") logger.error("Current URL: %s", page.url)
raise TimeoutError( raise TimeoutError(
f"Timeout waiting for OAuth callback (state={state[:16]}...)" f"Timeout waiting for OAuth callback (state={state[:16]}...)"
) )
await anyio.sleep(0.5) await anyio.sleep(0.5)
auth_code = auth_states[state] 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: finally:
await context.close() 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"] different_client_id, different_client_secret = test_oauth_clients["clientB"]
logger.info( 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("Shared client ID: %s...", shared_client_id[:16])
logger.info(f"Different client ID: {different_client_id[:16]}...") logger.info("Different client ID: %s...", different_client_id[:16])
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
# Test 1: The owning client (shared client) can introspect its own token # 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 assert response.status_code == 200
data = response.json() 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, ( assert data.get("active") is True, (
"Owner client should be able to introspect its own token" "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 assert response.status_code == 200
data = response.json() 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, ( assert data.get("active") is False, (
"Different client should NOT be able to introspect another client's token" "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 resource=client_b_id, # Set client B as the resource server
) )
except Exception as e: 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}") pytest.skip(f"Cannot obtain test token with resource parameter: {e}")
logger.info( 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 # Test introspection
@@ -404,7 +408,7 @@ async def test_introspection_with_resource_parameter(
) )
assert response.status_code == 200 assert response.status_code == 200
data = response.json() 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, ( assert data.get("active") is True, (
"Client A (owner) should be able to introspect its own token" "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 assert response.status_code == 200
data = response.json() 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, ( assert data.get("active") is True, (
"Client B (resource server) should be able to introspect token intended for it" "Client B (resource server) should be able to introspect token intended for it"
) )
# Verify the resource field in the response matches client B # 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) # Test 3: Client C CANNOT introspect the token (not owner, not resource server)
response = await client.post( response = await client.post(
@@ -433,7 +437,7 @@ async def test_introspection_with_resource_parameter(
) )
assert response.status_code == 200 assert response.status_code == 200
data = response.json() 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, ( assert data.get("active") is False, (
"Client C should NOT be able to introspect token (not owner or resource server)" "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 assert response.status_code == 200
data = response.json() 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, ( assert data.get("active") is False, (
"Should return active=false for invalid token" "Should return active=false for invalid token"
) )
@@ -50,7 +50,7 @@ class TestLoginFlowAuthTools:
# Search" flow) stores. So accept either a non-empty list or None; # 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. # the field's *presence* in the payload is what we care about here.
assert data["scopes"] is None or len(data["scopes"]) > 0 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( async def test_provision_access_already_provisioned(
self, nc_mcp_login_flow_client: ClientSession self, nc_mcp_login_flow_client: ClientSession
@@ -100,7 +100,7 @@ class TestLoginFlowNotes:
note = json.loads(create_result.content[0].text) note = json.loads(create_result.content[0].text)
note_id = note["id"] note_id = note["id"]
etag = note["etag"] etag = note["etag"]
logger.info(f"Created note {note_id}") logger.info("Created note %s", note_id)
try: try:
# Read # Read
@@ -152,7 +152,7 @@ class TestLoginFlowNotes:
await nc_mcp_login_flow_client.call_tool( await nc_mcp_login_flow_client.call_tool(
"nc_notes_delete_note", {"note_id": note_id} "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", []) calendars = cal_data.get("calendars", [])
assert len(calendars) > 0 assert len(calendars) > 0
calendar_name = calendars[0].get("name", "personal") 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] suffix = uuid.uuid4().hex[:8]
event_title = f"LoginFlow Event {suffix}" event_title = f"LoginFlow Event {suffix}"
@@ -197,7 +197,7 @@ class TestLoginFlowCalendarEvents:
) )
event_data = json.loads(create_result.content[0].text) event_data = json.loads(create_result.content[0].text)
event_uid = event_data.get("uid") or event_data.get("event_uid") 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: try:
# Get event # Get event
@@ -213,7 +213,7 @@ class TestLoginFlowCalendarEvents:
"nc_calendar_delete_event", "nc_calendar_delete_event",
{"calendar_name": calendar_name, "event_uid": event_uid}, {"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}") raise AssertionError(f"Create todo failed: {error_text}")
todo_data = json.loads(create_result.content[0].text) todo_data = json.loads(create_result.content[0].text)
todo_uid = todo_data.get("uid") or todo_data.get("todo_uid") 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: try:
# List todos # List todos
@@ -280,7 +280,7 @@ class TestLoginFlowCalendarTodos:
"nc_calendar_delete_todo", "nc_calendar_delete_todo",
{"calendar_name": calendar_name, "todo_uid": todo_uid}, {"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, ( assert create_ab_result.isError is False, (
f"Create addressbook failed: {create_ab_result.content[0].text}" 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: try:
# Create contact (requires addressbook, uid, contact_data dict) # Create contact (requires addressbook, uid, contact_data dict)
@@ -330,7 +330,7 @@ class TestLoginFlowContacts:
assert create_result.isError is False, ( assert create_result.isError is False, (
f"Create contact failed: {create_result.content[0].text}" 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 # List contacts in our clean addressbook
# Note: may fail due to server-side Pydantic bug where ContactField.value # 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 error_text = list_result.content[0].text
if "ContactField" in error_text: if "ContactField" in error_text:
logger.warning( logger.warning(
f"Known server bug: ContactField validation: {error_text}" "Known server bug: ContactField validation: %s", error_text
) )
else: else:
raise AssertionError(f"List contacts failed: {error_text}") raise AssertionError(f"List contacts failed: {error_text}")
@@ -360,7 +360,7 @@ class TestLoginFlowContacts:
"nc_contacts_delete_contact", "nc_contacts_delete_contact",
{"addressbook": ab_name, "uid": contact_uid}, {"addressbook": ab_name, "uid": contact_uid},
) )
logger.info(f"Deleted contact {contact_uid}") logger.info("Deleted contact %s", contact_uid)
finally: finally:
# Always clean up the temporary address book # Always clean up the temporary address book
@@ -368,7 +368,7 @@ class TestLoginFlowContacts:
"nc_contacts_delete_addressbook", "nc_contacts_delete_addressbook",
{"name": ab_name}, {"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, ( assert mkdir_result.isError is False, (
f"Create dir failed: {mkdir_result.content[0].text}" f"Create dir failed: {mkdir_result.content[0].text}"
) )
logger.info(f"Created directory: {dir_path}") logger.info("Created directory: %s", dir_path)
try: try:
# Write file # Write file
@@ -436,7 +436,7 @@ class TestLoginFlowFiles:
await nc_mcp_login_flow_client.call_tool( await nc_mcp_login_flow_client.call_tool(
"nc_webdav_delete_resource", {"path": dir_path} "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_data = json.loads(create_result.content[0].text)
board_id = board_data.get("id") or board_data.get("board_id") 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 boards (tool name is deck_get_boards)
list_result = await nc_mcp_login_flow_client.call_tool( list_result = await nc_mcp_login_flow_client.call_tool(
@@ -499,9 +499,11 @@ class TestLoginFlowDeck:
resp = await client.delete( resp = await client.delete(
f"/apps/deck/api/v1.0/boards/{board_id}" 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: 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", {}) 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}" assert result.isError is False, f"List tables failed: {result.content[0].text}"
data = json.loads(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_data = json.loads(create_result.content[0].text)
recipe_id = recipe_data.get("id") or recipe_data.get("recipe_id") 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: try:
# Get recipe (may fail due to server-side Pydantic bug with recipeYield=None) # 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 error_text = get_result.content[0].text
if "recipeYield" in error_text: if "recipeYield" in error_text:
logger.warning( logger.warning(
f"Known server bug: Recipe.recipeYield validation: {error_text}" "Known server bug: Recipe.recipeYield validation: %s",
error_text,
) )
else: else:
raise AssertionError(f"Get recipe failed: {error_text}") raise AssertionError(f"Get recipe failed: {error_text}")
@@ -590,7 +593,7 @@ class TestLoginFlowCookbook:
await nc_mcp_login_flow_client.call_tool( await nc_mcp_login_flow_client.call_tool(
"nc_cookbook_delete_recipe", {"recipe_id": recipe_id} "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): async def test_list_resources(self, nc_mcp_login_flow_client: ClientSession):
"""Verify resource templates are available.""" """Verify resource templates are available."""
templates = await nc_mcp_login_flow_client.list_resource_templates() 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))
@@ -537,4 +537,4 @@ class TestMultiUserSmoke:
]: ]:
tools = await client.list_tools() tools = await client.list_tools()
assert len(tools.tools) > 0, f"{name} MCP client has no 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))
@@ -73,7 +73,7 @@ async def test_read_only_token_filters_write_tools(nc_mcp_login_flow_client_read
assert len(result.tools) > 0 assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools] 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) # Verify read tools are present (only for apps with :read scopes)
# Read-only token has: notes.read, calendar.read, contacts.read, # 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( logger.info(
f"✅ Read-only token properly filters tools: {len(tool_names)} read tools visible, " "✅ Read-only token properly filters tools: %s read tools visible, write tools hidden",
f"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 assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools] 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 # Verify write tools are present
# Write-only token has: notes.write, calendar.write, contacts.write, # 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( logger.info(
f"✅ Write-only token properly filters tools: {len(tool_names)} write tools visible, " "✅ Write-only token properly filters tools: %s write tools visible, read tools hidden",
f"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 assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools] tool_names = [tool.name for tool in result.tools]
logger.info(f"Full access token sees {len(tool_names)} tools") logger.info("Full access token sees %s tools", len(tool_names))
logger.info(f"Tools: {sorted(tool_names)}") logger.info("Tools: %s", sorted(tool_names))
# Verify both read and write tools are present # Verify both read and write tools are present
# Full access has all *read and *write scopes # 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 assert len(tool_names) >= 90
logger.info( 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] tool_names = [tool.name for tool in result.tools]
logger.info( 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) # 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( logger.info(
f"✅ JWT token with only openid scope correctly shows {len(tool_names)} auth tools, " "✅ JWT token with only openid scope correctly shows %s auth tools, resource tools filtered out",
"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 assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools] 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 # Verify read tools are present
read_tools = ["nc_notes_get_note", "nc_notes_search_notes", "nc_webdav_read_file"] 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" assert tool not in tool_names, f"Write tool {tool} should be filtered out"
logger.info( 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 assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools] 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 # Verify write tools are present
write_tools = [ 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" assert tool not in tool_names, f"Read-only tool {tool} should be filtered out"
logger.info( 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 assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools] 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 # Verify both read and write tools are present
read_tools = ["nc_notes_get_note", "nc_webdav_read_file"] 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)}" assert len(tool_names) >= 90, f"Expected 90+ tools but got {len(tool_names)}"
logger.info( 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)
) )

Some files were not shown because too many files have changed in this diff Show More