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:
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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 = {}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}!"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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, (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user