refactor: convert f-string logging to lazy %-style format (G004)
Sweep all 1676 G004 violations across 112 files, converting
`logger.<level>(f"…{x}…")` to `logger.<level>("…%s…", x)`.
Why: ruff rule G004 was added to pyproject.toml to enforce lazy
%-style logging — defers formatting until the log level is enabled
and lets structured log tooling match the unformatted template.
Conversion preserves rendered output byte-for-byte:
- `{x}` → `%s` + `x`
- `{x!r}` / `{x!s}` / `{x!a}` → `%r` / `%s` / `%a`
- Format specs (`{x:.2f}`, `{x:>10}`) → `%s` + `format(x, 'spec')`
(printf-style specs aren't 1:1 with Python format specs, so we
delegate to `format()` to keep identical output)
- Literal `%` → `%%`
- Concatenated f-strings (`f"a {x} " "b"`) flattened
- Trailing kwargs (`exc_info=True`) preserved
Verified:
- `uv run ruff check --select G004` → 0 violations
- `uv run ty check -- nextcloud_mcp_server` → passes
- `uv run pytest tests/unit/` → 1010 passed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
a4e6125d28
commit
665cb9b1eb
@@ -79,7 +79,7 @@ async def create_generation_provider(provider_name: str) -> Provider:
|
||||
embedding_model=None, # Generation only
|
||||
generation_model=generation_model,
|
||||
)
|
||||
logger.info(f"Created OpenAI generation provider: model={generation_model}")
|
||||
logger.info("Created OpenAI generation provider: model=%s", generation_model)
|
||||
return provider
|
||||
|
||||
elif provider_name == "ollama":
|
||||
@@ -96,7 +96,7 @@ async def create_generation_provider(provider_name: str) -> Provider:
|
||||
embedding_model=None, # Generation only
|
||||
generation_model=generation_model,
|
||||
)
|
||||
logger.info(f"Created Ollama generation provider: model={generation_model}")
|
||||
logger.info("Created Ollama generation provider: model=%s", generation_model)
|
||||
return provider
|
||||
|
||||
elif provider_name == "anthropic":
|
||||
@@ -114,7 +114,7 @@ async def create_generation_provider(provider_name: str) -> Provider:
|
||||
api_key=api_key,
|
||||
generation_model=generation_model,
|
||||
)
|
||||
logger.info(f"Created Anthropic generation provider: model={generation_model}")
|
||||
logger.info("Created Anthropic generation provider: model=%s", generation_model)
|
||||
return provider
|
||||
|
||||
elif provider_name == "bedrock":
|
||||
@@ -133,7 +133,7 @@ async def create_generation_provider(provider_name: str) -> Provider:
|
||||
embedding_model=None, # Generation only
|
||||
generation_model=generation_model,
|
||||
)
|
||||
logger.info(f"Created Bedrock generation provider: model={generation_model}")
|
||||
logger.info("Created Bedrock generation provider: model=%s", generation_model)
|
||||
return provider
|
||||
|
||||
else:
|
||||
@@ -178,7 +178,7 @@ async def create_embedding_provider(provider_name: str) -> Provider:
|
||||
embedding_model=embedding_model,
|
||||
generation_model=None, # Embeddings only
|
||||
)
|
||||
logger.info(f"Created OpenAI embedding provider: model={embedding_model}")
|
||||
logger.info("Created OpenAI embedding provider: model=%s", embedding_model)
|
||||
return provider
|
||||
|
||||
elif provider_name == "ollama":
|
||||
@@ -195,7 +195,7 @@ async def create_embedding_provider(provider_name: str) -> Provider:
|
||||
embedding_model=embedding_model,
|
||||
generation_model=None, # Embeddings only
|
||||
)
|
||||
logger.info(f"Created Ollama embedding provider: model={embedding_model}")
|
||||
logger.info("Created Ollama embedding provider: model=%s", embedding_model)
|
||||
return provider
|
||||
|
||||
elif provider_name == "bedrock":
|
||||
@@ -214,7 +214,7 @@ async def create_embedding_provider(provider_name: str) -> Provider:
|
||||
embedding_model=embedding_model,
|
||||
generation_model=None, # Embeddings only
|
||||
)
|
||||
logger.info(f"Created Bedrock embedding provider: model={embedding_model}")
|
||||
logger.info("Created Bedrock embedding provider: model=%s", embedding_model)
|
||||
return provider
|
||||
|
||||
else:
|
||||
|
||||
@@ -62,7 +62,7 @@ def create_sampling_callback(provider: Provider):
|
||||
params: types.CreateMessageRequestParams,
|
||||
) -> types.CreateMessageResult | types.ErrorData:
|
||||
"""Handle sampling requests using the configured provider."""
|
||||
logger.debug(f"Sampling callback invoked with {len(params.messages)} messages")
|
||||
logger.debug("Sampling callback invoked with %s messages", len(params.messages))
|
||||
|
||||
# Extract messages and build prompt
|
||||
messages_text = []
|
||||
@@ -77,7 +77,7 @@ def create_sampling_callback(provider: Provider):
|
||||
if params.systemPrompt:
|
||||
prompt = f"System: {params.systemPrompt}\n\n{prompt}"
|
||||
|
||||
logger.debug(f"Generating response for prompt ({len(prompt)} chars)")
|
||||
logger.debug("Generating response for prompt (%s chars)", len(prompt))
|
||||
|
||||
try:
|
||||
# Generate response using provider
|
||||
@@ -87,7 +87,9 @@ def create_sampling_callback(provider: Provider):
|
||||
max_tokens=params.maxTokens,
|
||||
)
|
||||
|
||||
logger.info(f"Sampling completed: {len(response)} chars from {model_name}")
|
||||
logger.info(
|
||||
"Sampling completed: %s chars from %s", len(response), model_name
|
||||
)
|
||||
|
||||
return types.CreateMessageResult(
|
||||
role="assistant",
|
||||
@@ -96,7 +98,7 @@ def create_sampling_callback(provider: Provider):
|
||||
stopReason="endTurn",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Generation failed ({provider.__class__.__name__}): {e}")
|
||||
logger.error("Generation failed (%s): %s", provider.__class__.__name__, e)
|
||||
return types.ErrorData(
|
||||
code=types.INTERNAL_ERROR,
|
||||
message=f"Generation failed: {e!s}",
|
||||
|
||||
@@ -91,8 +91,10 @@ async def _poll_astrolabe_search_for_note(
|
||||
)
|
||||
if note_result is not None:
|
||||
logger.info(
|
||||
f"Note {note_id} surfaced in Astrolabe search after {attempts} "
|
||||
f"attempts (~{attempts * 2}s)"
|
||||
"Note %s surfaced in Astrolabe search after %s attempts (~%ss)",
|
||||
note_id,
|
||||
attempts,
|
||||
attempts * 2,
|
||||
)
|
||||
return note_result
|
||||
await anyio.sleep(2)
|
||||
@@ -245,7 +247,7 @@ async def test_chunk_context_endpoint_uses_app_password(
|
||||
"nc_notes_delete_note", {"note_id": note_id}
|
||||
)
|
||||
except Exception as cleanup_err:
|
||||
logger.warning(f"Cleanup failed for note {note_id}: {cleanup_err}")
|
||||
logger.warning("Cleanup failed for note %s: %s", note_id, cleanup_err)
|
||||
await context.close()
|
||||
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ async def login_to_nextcloud(page: Page, username: str, password: str):
|
||||
"""
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
logger.info(f"Logging in to Nextcloud as {username}...")
|
||||
logger.info("Logging in to Nextcloud as %s...", username)
|
||||
await page.goto(f"{nextcloud_url}/login", wait_until="networkidle")
|
||||
|
||||
# Fill in login form
|
||||
@@ -68,7 +68,7 @@ async def login_to_nextcloud(page: Page, username: str, password: str):
|
||||
assert "/login" not in current_url, (
|
||||
f"Login failed for {username}, still on login page"
|
||||
)
|
||||
logger.info(f"✓ Successfully logged in as {username}")
|
||||
logger.info("✓ Successfully logged in as %s", username)
|
||||
|
||||
|
||||
async def navigate_to_astrolabe_settings(page: Page):
|
||||
@@ -80,7 +80,7 @@ async def navigate_to_astrolabe_settings(page: Page):
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
settings_url = f"{nextcloud_url}/settings/user/astrolabe"
|
||||
|
||||
logger.info(f"Navigating to Astrolabe settings: {settings_url}")
|
||||
logger.info("Navigating to Astrolabe settings: %s", settings_url)
|
||||
await page.goto(settings_url, wait_until="networkidle", timeout=30000)
|
||||
|
||||
# Verify we're on the settings page
|
||||
@@ -110,7 +110,7 @@ async def authorize_search_access(page: Page, username: str) -> bool:
|
||||
"""
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
logger.info(f"Authorizing search access (Step 1) for {username}...")
|
||||
logger.info("Authorizing search access (Step 1) for %s...", username)
|
||||
|
||||
# Check if already on Astrolabe settings page, if not navigate there
|
||||
if "/settings/user/astrolabe" not in page.url:
|
||||
@@ -124,7 +124,7 @@ async def authorize_search_access(page: Page, username: str) -> bool:
|
||||
# Check for "Active" badge (fully configured state)
|
||||
active_badge = page.get_by_text("Active", exact=True)
|
||||
if await active_badge.count() > 0 and await active_badge.is_visible():
|
||||
logger.info(f"✓ Already fully authorized for {username} (Active badge)")
|
||||
logger.info("✓ Already fully authorized for %s (Active badge)", username)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
@@ -136,7 +136,7 @@ async def authorize_search_access(page: Page, username: str) -> bool:
|
||||
step1_parent = step1_section.locator("..")
|
||||
complete_badge = step1_parent.get_by_text("Complete", exact=True)
|
||||
if await complete_badge.count() > 0 and await complete_badge.is_visible():
|
||||
logger.info(f"✓ Step 1 already complete for {username}")
|
||||
logger.info("✓ Step 1 already complete for %s", username)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
@@ -146,36 +146,38 @@ async def authorize_search_access(page: Page, username: str) -> bool:
|
||||
|
||||
try:
|
||||
await authorize_button.wait_for(timeout=5000, state="visible")
|
||||
logger.info(f"Found Authorize button for {username}")
|
||||
logger.info("Found Authorize button for %s", username)
|
||||
except Exception:
|
||||
# Take screenshot for debugging
|
||||
screenshot_path = f"/tmp/astrolabe_no_authorize_button_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(
|
||||
f"Could not find Authorize button for {username}. Screenshot: {screenshot_path}"
|
||||
"Could not find Authorize button for %s. Screenshot: %s",
|
||||
username,
|
||||
screenshot_path,
|
||||
)
|
||||
raise ValueError(f"Authorize button not found for {username}")
|
||||
|
||||
# Click the Authorize button - this will redirect to OAuth provider
|
||||
# Use force=True to bypass stability check which can timeout due to CSS transitions
|
||||
await authorize_button.click(force=True)
|
||||
logger.info(f"Clicked Authorize button for {username}")
|
||||
logger.info("Clicked Authorize button for %s", username)
|
||||
|
||||
# Wait for OAuth redirect to complete
|
||||
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||
logger.info(f"After networkidle, current URL: {page.url}")
|
||||
logger.info("After networkidle, current URL: %s", page.url)
|
||||
|
||||
# Take screenshot to see current state
|
||||
await page.screenshot(path=f"/tmp/astrolabe_after_authorize_{username}.png")
|
||||
logger.info(f"Screenshot saved: /tmp/astrolabe_after_authorize_{username}.png")
|
||||
logger.info("Screenshot saved: /tmp/astrolabe_after_authorize_%s.png", username)
|
||||
|
||||
# Handle OIDC consent screen if present
|
||||
consent_handled = await _handle_oauth_consent_screen(page, username)
|
||||
if consent_handled:
|
||||
logger.info(f"✓ OAuth consent granted for {username}")
|
||||
logger.info("✓ OAuth consent granted for %s", username)
|
||||
else:
|
||||
logger.info(
|
||||
f"No consent screen required for {username} (may be previously authorized)"
|
||||
"No consent screen required for %s (may be previously authorized)", username
|
||||
)
|
||||
|
||||
# Wait for redirect back to Astrolabe settings
|
||||
@@ -184,12 +186,12 @@ async def authorize_search_access(page: Page, username: str) -> bool:
|
||||
await page.wait_for_url(
|
||||
f"**{nextcloud_url}/settings/user/astrolabe**", timeout=30000
|
||||
)
|
||||
logger.info(f"Redirected back to Astrolabe settings for {username}")
|
||||
logger.info("Redirected back to Astrolabe settings for %s", username)
|
||||
except Exception:
|
||||
# Check if we're already on settings page
|
||||
if "/settings/user/astrolabe" not in page.url:
|
||||
logger.warning(
|
||||
f"Not redirected to Astrolabe settings, current URL: {page.url}"
|
||||
"Not redirected to Astrolabe settings, current URL: %s", page.url
|
||||
)
|
||||
# Navigate manually
|
||||
await page.goto(
|
||||
@@ -205,7 +207,9 @@ async def authorize_search_access(page: Page, username: str) -> bool:
|
||||
# First check if "Active" badge is shown (fully configured state)
|
||||
active_badge = page.get_by_text("Active", exact=True)
|
||||
if await active_badge.count() > 0 and await active_badge.is_visible():
|
||||
logger.info(f"✓ OAuth authorization complete for {username} (Active badge)")
|
||||
logger.info(
|
||||
"✓ OAuth authorization complete for %s (Active badge)", username
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
@@ -217,7 +221,7 @@ async def authorize_search_access(page: Page, username: str) -> bool:
|
||||
step1_parent = step1_section.locator("..")
|
||||
complete_badge = step1_parent.get_by_text("Complete", exact=True)
|
||||
await complete_badge.wait_for(timeout=5000, state="visible")
|
||||
logger.info(f"✓ Step 1 OAuth authorization complete for {username}")
|
||||
logger.info("✓ Step 1 OAuth authorization complete for %s", username)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
@@ -226,7 +230,9 @@ async def authorize_search_access(page: Page, username: str) -> bool:
|
||||
screenshot_path = f"/tmp/astrolabe_step1_not_complete_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(
|
||||
f"Authorization badge not visible for {username}. Screenshot: {screenshot_path}"
|
||||
"Authorization badge not visible for %s. Screenshot: %s",
|
||||
username,
|
||||
screenshot_path,
|
||||
)
|
||||
raise ValueError(f"OAuth authorization did not complete for {username}")
|
||||
|
||||
@@ -244,28 +250,28 @@ async def _handle_oauth_consent_screen(page: Page, username: str) -> bool:
|
||||
True if consent was handled, False if no consent screen was found
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Checking for consent screen at URL: {page.url}")
|
||||
logger.info("Checking for consent screen at URL: %s", page.url)
|
||||
|
||||
# Check if consent screen is present - try multiple selectors
|
||||
# The consent screen may be #oidc-consent or use a different format
|
||||
consent_div = await page.query_selector("#oidc-consent")
|
||||
|
||||
if consent_div:
|
||||
logger.info(f"Consent screen detected via #oidc-consent for {username}")
|
||||
logger.info("Consent screen detected via #oidc-consent for %s", username)
|
||||
# Get consent screen data attributes for logging
|
||||
client_name = await consent_div.get_attribute("data-client-name")
|
||||
scopes_attr = await consent_div.get_attribute("data-scopes")
|
||||
logger.info(f" Client: {client_name}")
|
||||
logger.info(f" Requested scopes: {scopes_attr}")
|
||||
logger.info(" Client: %s", client_name)
|
||||
logger.info(" Requested scopes: %s", scopes_attr)
|
||||
else:
|
||||
# Check for Allow button directly (different consent screen format)
|
||||
allow_button = page.locator('button:has-text("Allow")')
|
||||
if await allow_button.count() > 0:
|
||||
logger.info(f"Consent screen detected via Allow button for {username}")
|
||||
logger.info("Consent screen detected via Allow button for %s", username)
|
||||
else:
|
||||
logger.info(f"No consent screen found for {username} at {page.url}")
|
||||
logger.info("No consent screen found for %s at %s", username, page.url)
|
||||
await page.screenshot(path=f"/tmp/no_consent_screen_{username}.png")
|
||||
logger.info(f"Screenshot: /tmp/no_consent_screen_{username}.png")
|
||||
logger.info("Screenshot: /tmp/no_consent_screen_%s.png", username)
|
||||
return False
|
||||
|
||||
# Wait for Vue.js to render the Allow button
|
||||
@@ -275,19 +281,19 @@ async def _handle_oauth_consent_screen(page: Page, username: str) -> bool:
|
||||
except Exception as e:
|
||||
screenshot_path = f"/tmp/consent_no_allow_button_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(f" Timeout waiting for Allow button: {e}")
|
||||
logger.error(" Timeout waiting for Allow button: %s", e)
|
||||
raise
|
||||
|
||||
# Check all scope checkboxes
|
||||
scope_checkboxes = await page.query_selector_all('input[type="checkbox"]')
|
||||
if scope_checkboxes:
|
||||
logger.info(f" Found {len(scope_checkboxes)} scope checkboxes")
|
||||
logger.info(" Found %s scope checkboxes", len(scope_checkboxes))
|
||||
for i, checkbox in enumerate(scope_checkboxes):
|
||||
is_checked = await checkbox.is_checked()
|
||||
is_disabled = await checkbox.is_disabled()
|
||||
if not is_checked and not is_disabled:
|
||||
await checkbox.check()
|
||||
logger.info(f" ✓ Checked scope checkbox {i + 1}")
|
||||
logger.info(" ✓ Checked scope checkbox %s", i + 1)
|
||||
|
||||
# Click the Allow button using JavaScript (handles viewport issues)
|
||||
allow_button_locator = page.locator('button:has-text("Allow")')
|
||||
@@ -295,16 +301,16 @@ async def _handle_oauth_consent_screen(page: Page, username: str) -> bool:
|
||||
# Debug: take screenshot before clicking Allow
|
||||
await page.screenshot(path=f"/tmp/consent_before_allow_{username}.png")
|
||||
logger.info(
|
||||
f" Screenshot before Allow: /tmp/consent_before_allow_{username}.png"
|
||||
" Screenshot before Allow: /tmp/consent_before_allow_%s.png", username
|
||||
)
|
||||
|
||||
button_count = await allow_button_locator.count()
|
||||
logger.info(f" Found {button_count} Allow button(s)")
|
||||
logger.info(" Found %s Allow button(s)", button_count)
|
||||
|
||||
if button_count > 0:
|
||||
current_url = page.url
|
||||
logger.info(f" Current URL: {current_url}")
|
||||
logger.info(f" Clicking Allow button for {username}...")
|
||||
logger.info(" Current URL: %s", current_url)
|
||||
logger.info(" Clicking Allow button for %s...", username)
|
||||
|
||||
# Use JavaScript click to handle consent buttons (proven pattern from conftest.py)
|
||||
# This is more reliable than Playwright's click for Vue.js rendered buttons
|
||||
@@ -327,10 +333,10 @@ async def _handle_oauth_consent_screen(page: Page, username: str) -> bool:
|
||||
lambda url: url != current_url,
|
||||
timeout=30000,
|
||||
)
|
||||
logger.info(f" URL changed to: {page.url}")
|
||||
logger.info(" URL changed to: %s", page.url)
|
||||
except Exception as wait_error:
|
||||
# If URL didn't change, check console for errors
|
||||
logger.warning(f" URL didn't change after click: {wait_error}")
|
||||
logger.warning(" URL didn't change after click: %s", wait_error)
|
||||
await page.screenshot(path=f"/tmp/consent_after_allow_{username}.png")
|
||||
|
||||
# Try alternative: manually POST consent and navigate
|
||||
@@ -356,21 +362,21 @@ async def _handle_oauth_consent_screen(page: Page, username: str) -> bool:
|
||||
}
|
||||
"""
|
||||
)
|
||||
logger.info(f" Manual consent returned URL: {redirect_url}")
|
||||
logger.info(" Manual consent returned URL: %s", redirect_url)
|
||||
await page.goto(redirect_url, wait_until="networkidle")
|
||||
except Exception as manual_error:
|
||||
logger.error(f" Manual consent also failed: {manual_error}")
|
||||
logger.error(" Manual consent also failed: %s", manual_error)
|
||||
raise
|
||||
|
||||
await page.screenshot(path=f"/tmp/consent_after_allow_{username}.png")
|
||||
logger.info(f" Consent granted for {username}")
|
||||
logger.info(" Consent granted for %s", username)
|
||||
return True
|
||||
else:
|
||||
logger.error(f" Allow button not found for {username}")
|
||||
logger.error(" Allow button not found for %s", username)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling consent screen for {username}: {e}")
|
||||
logger.error("Error handling consent screen for %s: %s", username, e)
|
||||
raise
|
||||
|
||||
|
||||
@@ -387,7 +393,7 @@ async def generate_app_password(
|
||||
Returns:
|
||||
The generated app password string
|
||||
"""
|
||||
logger.info(f"Generating app password for {username}...")
|
||||
logger.info("Generating app password for %s...", username)
|
||||
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
@@ -398,7 +404,7 @@ async def generate_app_password(
|
||||
# Fill the app password input field (selector confirmed via Playwright MCP)
|
||||
app_password_input = page.locator('input[placeholder="App name"]')
|
||||
await app_password_input.fill(app_name)
|
||||
logger.info(f"Entered app name: {app_name}")
|
||||
logger.info("Entered app name: %s", app_name)
|
||||
|
||||
# Wait for Vue.js to react and enable the button (needs 1 second, not 0.5)
|
||||
await anyio.sleep(1.0)
|
||||
@@ -427,7 +433,7 @@ async def generate_app_password(
|
||||
# Debug screenshot after clicking create
|
||||
await page.screenshot(path=f"/tmp/app_password_after_create_{username}.png")
|
||||
logger.info(
|
||||
f"Screenshot after create: /tmp/app_password_after_create_{username}.png"
|
||||
"Screenshot after create: /tmp/app_password_after_create_%s.png", username
|
||||
)
|
||||
|
||||
# Find the Login input field which should have the username value
|
||||
@@ -440,7 +446,7 @@ async def generate_app_password(
|
||||
|
||||
# Get all visible input elements
|
||||
all_inputs = await page.locator('input[type="text"]').all()
|
||||
logger.info(f"Found {len(all_inputs)} text input elements")
|
||||
logger.info("Found %s text input elements", len(all_inputs))
|
||||
|
||||
# Check each input to find the one with the app password
|
||||
for idx, input_elem in enumerate(all_inputs):
|
||||
@@ -449,15 +455,18 @@ async def generate_app_password(
|
||||
if value and "-" in value and len(value) > 20:
|
||||
app_password = value.strip()
|
||||
logger.info(
|
||||
f"Found app password in input {idx}: '{app_password}' (length: {len(app_password)})"
|
||||
"Found app password in input %s: '%s' (length: %s)",
|
||||
idx,
|
||||
app_password,
|
||||
len(app_password),
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not get value from input {idx}: {e}")
|
||||
logger.debug("Could not get value from input %s: %s", idx, e)
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to find app password dialog or extract password: {e}")
|
||||
logger.error("Failed to find app password dialog or extract password: %s", e)
|
||||
|
||||
if not app_password:
|
||||
# Take screenshot for debugging
|
||||
@@ -474,9 +483,9 @@ async def generate_app_password(
|
||||
app_password,
|
||||
):
|
||||
logger.error(
|
||||
f"Extracted password does not match expected format: '{app_password}'"
|
||||
"Extracted password does not match expected format: '%s'", app_password
|
||||
)
|
||||
logger.error(f"Password repr: {repr(app_password)}")
|
||||
logger.error("Password repr: %s", repr(app_password))
|
||||
screenshot_path = f"/tmp/app_password_invalid_format_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
raise ValueError(
|
||||
@@ -484,7 +493,9 @@ async def generate_app_password(
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"✓ Generated app password for {username}: {app_password[:10]}... (validated)"
|
||||
"✓ Generated app password for %s: %s... (validated)",
|
||||
username,
|
||||
app_password[:10],
|
||||
)
|
||||
|
||||
# Close dialog with Escape key (bypasses CSS layout issues with h2 intercepting clicks)
|
||||
@@ -509,7 +520,7 @@ async def enable_background_sync_via_app_password(
|
||||
Returns:
|
||||
True if background sync was enabled successfully
|
||||
"""
|
||||
logger.info(f"Enabling background sync via app password for {username}...")
|
||||
logger.info("Enabling background sync via app password for %s...", username)
|
||||
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
@@ -524,7 +535,7 @@ async def enable_background_sync_via_app_password(
|
||||
def log_response(resp):
|
||||
response_info = f"{resp.status} {resp.url}"
|
||||
network_responses.append(response_info)
|
||||
logger.info(f"Response: {response_info}")
|
||||
logger.info("Response: %s", response_info)
|
||||
|
||||
def log_console(msg):
|
||||
console_messages.append(f"[{msg.type}] {msg.text}")
|
||||
@@ -546,7 +557,7 @@ async def enable_background_sync_via_app_password(
|
||||
# First check for overall "Active" badge (both steps complete)
|
||||
active_text = page.get_by_text("Active", exact=True)
|
||||
if await active_text.is_visible(timeout=2000):
|
||||
logger.info(f"✓ Background sync already active for {username}")
|
||||
logger.info("✓ Background sync already active for %s", username)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
@@ -558,7 +569,7 @@ async def enable_background_sync_via_app_password(
|
||||
step2_parent = step2_section.locator("..")
|
||||
complete_badge = step2_parent.get_by_text("Complete", exact=True)
|
||||
if await complete_badge.count() > 0 and await complete_badge.is_visible():
|
||||
logger.info(f"✓ Step 2 (app password) already complete for {username}")
|
||||
logger.info("✓ Step 2 (app password) already complete for %s", username)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
@@ -580,7 +591,7 @@ async def enable_background_sync_via_app_password(
|
||||
|
||||
# Enter the app password
|
||||
await app_password_input.fill(app_password)
|
||||
logger.info(f"Entered app password for {username}")
|
||||
logger.info("Entered app password for %s", username)
|
||||
|
||||
# Wait a moment for any validation to complete
|
||||
await anyio.sleep(0.5)
|
||||
@@ -588,14 +599,14 @@ async def enable_background_sync_via_app_password(
|
||||
# Take screenshot before clicking Save to check for warnings
|
||||
screenshot_path = f"/tmp/before_save_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.info(f"Screenshot taken before Save: {screenshot_path}")
|
||||
logger.info("Screenshot taken before Save: %s", screenshot_path)
|
||||
|
||||
# Find and click the Save button
|
||||
save_button = page.get_by_role("button", name="Save")
|
||||
|
||||
# Check if Save button is enabled
|
||||
is_disabled = await save_button.is_disabled()
|
||||
logger.info(f"Save button disabled state: {is_disabled}")
|
||||
logger.info("Save button disabled state: %s", is_disabled)
|
||||
|
||||
await save_button.click()
|
||||
logger.info("Clicked Save button")
|
||||
@@ -604,24 +615,24 @@ async def enable_background_sync_via_app_password(
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
# Log network requests after clicking Save
|
||||
logger.info(f"Network requests after Save for {username}:")
|
||||
logger.info("Network requests after Save for %s:", username)
|
||||
for req in network_requests[-10:]: # Last 10 requests
|
||||
logger.info(f" {req}")
|
||||
logger.info(" %s", req)
|
||||
|
||||
# Log network responses after clicking Save
|
||||
logger.info(f"Network responses after Save for {username}:")
|
||||
logger.info("Network responses after Save for %s:", username)
|
||||
for resp in network_responses[-10:]: # Last 10 responses
|
||||
logger.info(f" {resp}")
|
||||
logger.info(" %s", resp)
|
||||
|
||||
# Check specifically for the credentials POST response
|
||||
credentials_responses = [
|
||||
r for r in network_responses if "background-sync/credentials" in r
|
||||
]
|
||||
if credentials_responses:
|
||||
logger.info(f"Credentials endpoint response: {credentials_responses[-1]}")
|
||||
logger.info("Credentials endpoint response: %s", credentials_responses[-1])
|
||||
if "200" not in credentials_responses[-1]:
|
||||
logger.error(
|
||||
f"Credentials POST did not return 200 OK: {credentials_responses[-1]}"
|
||||
"Credentials POST did not return 200 OK: %s", credentials_responses[-1]
|
||||
)
|
||||
else:
|
||||
logger.warning("No response found for credentials endpoint!")
|
||||
@@ -633,16 +644,16 @@ async def enable_background_sync_via_app_password(
|
||||
|
||||
# Log any console messages
|
||||
if console_messages:
|
||||
logger.info(f"Console messages for {username}:")
|
||||
logger.info("Console messages for %s:", username)
|
||||
for msg in console_messages:
|
||||
logger.info(f" {msg}")
|
||||
logger.info(" %s", msg)
|
||||
|
||||
# Check for error notifications (toast messages)
|
||||
try:
|
||||
error_toast = page.locator(".toastify.toast-error, .toast-error")
|
||||
if await error_toast.count() > 0:
|
||||
error_text = await error_toast.first.text_content()
|
||||
logger.error(f"Error notification for {username}: {error_text}")
|
||||
logger.error("Error notification for %s: %s", username, error_text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -653,7 +664,7 @@ async def enable_background_sync_via_app_password(
|
||||
if await active_text.count() > 0:
|
||||
await active_text.wait_for(timeout=5000, state="visible")
|
||||
logger.info(
|
||||
f"✓ Background sync enabled for {username} - Active badge visible"
|
||||
"✓ Background sync enabled for %s - Active badge visible", username
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
@@ -667,7 +678,8 @@ async def enable_background_sync_via_app_password(
|
||||
complete_badge = step2_parent.get_by_text("Complete", exact=True)
|
||||
await complete_badge.wait_for(timeout=5000, state="visible")
|
||||
logger.info(
|
||||
f"✓ Step 2 (app password) enabled for {username} - Complete badge visible"
|
||||
"✓ Step 2 (app password) enabled for %s - Complete badge visible",
|
||||
username,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
@@ -677,8 +689,9 @@ async def enable_background_sync_via_app_password(
|
||||
screenshot_path = f"/tmp/astrolabe_after_password_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(
|
||||
f"Neither Active nor Complete badge appeared for {username}. "
|
||||
f"Screenshot: {screenshot_path}"
|
||||
"Neither Active nor Complete badge appeared for %s. Screenshot: %s",
|
||||
username,
|
||||
screenshot_path,
|
||||
)
|
||||
raise ValueError(f"Background sync setup did not complete for {username}")
|
||||
|
||||
@@ -702,7 +715,7 @@ async def complete_astrolabe_authorization(
|
||||
Returns:
|
||||
Dict with {"step1": bool, "step2": bool, "app_password": str | None}
|
||||
"""
|
||||
logger.info(f"Starting full Astrolabe authorization for {username}...")
|
||||
logger.info("Starting full Astrolabe authorization for %s...", username)
|
||||
|
||||
result = {"step1": False, "step2": False, "app_password": None}
|
||||
|
||||
@@ -712,9 +725,9 @@ async def complete_astrolabe_authorization(
|
||||
# Step 1: OAuth authorization
|
||||
try:
|
||||
result["step1"] = await authorize_search_access(page, username)
|
||||
logger.info(f"✓ Step 1 complete for {username}")
|
||||
logger.info("✓ Step 1 complete for %s", username)
|
||||
except Exception as e:
|
||||
logger.error(f"Step 1 failed for {username}: {e}")
|
||||
logger.error("Step 1 failed for %s: %s", username, e)
|
||||
raise
|
||||
|
||||
# Navigate back to settings if needed (OAuth might have redirected elsewhere)
|
||||
@@ -728,7 +741,7 @@ async def complete_astrolabe_authorization(
|
||||
step2_parent = step2_section.locator("..")
|
||||
complete_badge = step2_parent.get_by_text("Complete", exact=True)
|
||||
if await complete_badge.count() > 0 and await complete_badge.is_visible():
|
||||
logger.info(f"✓ Step 2 already complete for {username}")
|
||||
logger.info("✓ Step 2 already complete for %s", username)
|
||||
result["step2"] = True
|
||||
return result
|
||||
except Exception:
|
||||
@@ -738,7 +751,7 @@ async def complete_astrolabe_authorization(
|
||||
try:
|
||||
active_text = page.get_by_text("Active", exact=True)
|
||||
if await active_text.count() > 0 and await active_text.is_visible():
|
||||
logger.info(f"✓ Authorization already fully active for {username}")
|
||||
logger.info("✓ Authorization already fully active for %s", username)
|
||||
result["step2"] = True
|
||||
return result
|
||||
except Exception:
|
||||
@@ -752,12 +765,12 @@ async def complete_astrolabe_authorization(
|
||||
result["step2"] = await enable_background_sync_via_app_password(
|
||||
page, username, app_password
|
||||
)
|
||||
logger.info(f"✓ Step 2 complete for {username}")
|
||||
logger.info("✓ Step 2 complete for %s", username)
|
||||
except Exception as e:
|
||||
logger.error(f"Step 2 failed for {username}: {e}")
|
||||
logger.error("Step 2 failed for %s: %s", username, e)
|
||||
raise
|
||||
|
||||
logger.info(f"✓ Full Astrolabe authorization complete for {username}")
|
||||
logger.info("✓ Full Astrolabe authorization complete for %s", username)
|
||||
return result
|
||||
|
||||
|
||||
@@ -773,7 +786,7 @@ async def verify_app_password_created(username: str) -> bool:
|
||||
Returns:
|
||||
True if background sync app password exists
|
||||
"""
|
||||
logger.info(f"Verifying background sync app password for {username}...")
|
||||
logger.info("Verifying background sync app password for %s...", username)
|
||||
|
||||
# Query the database to check for background sync credentials
|
||||
# Astrolabe stores app passwords in oc_preferences, not oc_authtoken
|
||||
@@ -809,7 +822,7 @@ async def verify_app_password_created(username: str) -> bool:
|
||||
)
|
||||
|
||||
output = result.stdout
|
||||
logger.debug(f"Background sync credentials query result:\n{output}")
|
||||
logger.debug("Background sync credentials query result:\\n%s", output)
|
||||
|
||||
# Check if background sync credentials exist
|
||||
# We should see 3 rows: background_sync_password, background_sync_type, background_sync_provisioned_at
|
||||
@@ -818,19 +831,22 @@ async def verify_app_password_created(username: str) -> bool:
|
||||
if len(lines) >= 3: # Header + at least 2 data rows (password + type)
|
||||
# Verify background_sync_type is "app_password"
|
||||
if "app_password" in output:
|
||||
logger.info(f"✓ Background sync app password stored for {username}")
|
||||
logger.info("✓ Background sync app password stored for %s", username)
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
f"Background sync credentials found but type is not app_password for {username}"
|
||||
"Background sync credentials found but type is not app_password for %s",
|
||||
username,
|
||||
)
|
||||
return False
|
||||
else:
|
||||
logger.warning(f"No background sync credentials found for {username}")
|
||||
logger.warning("No background sync credentials found for %s", username)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking background sync credentials for {username}: {e}")
|
||||
logger.error(
|
||||
"Error checking background sync credentials for %s: %s", username, e
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
@@ -892,10 +908,13 @@ def clear_stale_test_state(clear_preferences: bool = False) -> None:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||
if result.returncode != 0:
|
||||
logger.warning(
|
||||
f"Failed to clear {label} (rc={result.returncode}): {result.stderr}"
|
||||
"Failed to clear %s (rc=%s): %s",
|
||||
label,
|
||||
result.returncode,
|
||||
result.stderr,
|
||||
)
|
||||
else:
|
||||
logger.debug(f"Cleared {label}")
|
||||
logger.debug("Cleared %s", label)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -946,7 +965,9 @@ async def test_multi_user_astrolabe_background_sync_enablement(
|
||||
# Use nc_client to check if user exists
|
||||
user_details = await nc_client.users.get_user_details(username)
|
||||
logger.info(
|
||||
f"✓ Confirmed {username} exists (display name: {user_details.displayname})"
|
||||
"✓ Confirmed %s exists (display name: %s)",
|
||||
username,
|
||||
user_details.displayname,
|
||||
)
|
||||
except Exception as e:
|
||||
raise AssertionError(
|
||||
@@ -957,9 +978,9 @@ async def test_multi_user_astrolabe_background_sync_enablement(
|
||||
results = {}
|
||||
|
||||
for username in test_users:
|
||||
logger.info(f"\n{'=' * 60}")
|
||||
logger.info(f"Testing background sync enablement for: {username}")
|
||||
logger.info(f"{'=' * 60}")
|
||||
logger.info("\\n%s", "=" * 60)
|
||||
logger.info("Testing background sync enablement for: %s", username)
|
||||
logger.info("%s", "=" * 60)
|
||||
|
||||
user_config = test_users_setup[username]
|
||||
password = user_config["password"]
|
||||
@@ -994,17 +1015,20 @@ async def test_multi_user_astrolabe_background_sync_enablement(
|
||||
"background_sync_active": sync_enabled and app_password_stored,
|
||||
}
|
||||
|
||||
logger.info(f"\n{username} results:")
|
||||
logger.info("\\n%s results:", username)
|
||||
logger.info(" Settings accessed: ✓")
|
||||
logger.info(f" App password generated: {'✓' if app_password else '✗'}")
|
||||
logger.info(f" Sync enabled: {'✓' if sync_enabled else '✗'}")
|
||||
logger.info(f" App password stored: {'✓' if app_password_stored else '✗'}")
|
||||
logger.info(" App password generated: %s", "✓" if app_password else "✗")
|
||||
logger.info(" Sync enabled: %s", "✓" if sync_enabled else "✗")
|
||||
logger.info(
|
||||
f" Background sync active: {'✓' if (sync_enabled and app_password_stored) else '✗'}"
|
||||
" App password stored: %s", "✓" if app_password_stored else "✗"
|
||||
)
|
||||
logger.info(
|
||||
" Background sync active: %s",
|
||||
"✓" if (sync_enabled and app_password_stored) else "✗",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during {username} test: {e}")
|
||||
logger.error("Error during %s test: %s", username, e)
|
||||
results[username] = {
|
||||
"settings_accessed": False,
|
||||
"app_password_generated": False,
|
||||
@@ -1018,18 +1042,18 @@ async def test_multi_user_astrolabe_background_sync_enablement(
|
||||
await context.close()
|
||||
|
||||
# Verify all users succeeded
|
||||
logger.info(f"\n{'=' * 60}")
|
||||
logger.info("\\n%s", "=" * 60)
|
||||
logger.info("Test Summary")
|
||||
logger.info(f"{'=' * 60}")
|
||||
logger.info("%s", "=" * 60)
|
||||
|
||||
for username, result in results.items():
|
||||
logger.info(f"\n{username}:")
|
||||
logger.info("\\n%s:", username)
|
||||
for key, value in result.items():
|
||||
if key != "error":
|
||||
status = "✓" if value else "✗"
|
||||
logger.info(f" {key}: {status}")
|
||||
logger.info(" %s: %s", key, status)
|
||||
elif value:
|
||||
logger.info(f" error: {value}")
|
||||
logger.info(" error: %s", value)
|
||||
|
||||
# Assert all users successfully enabled background sync
|
||||
for username in test_users:
|
||||
@@ -1051,7 +1075,8 @@ async def test_multi_user_astrolabe_background_sync_enablement(
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"\n✓ All {len(test_users)} users successfully enabled background sync via app passwords!"
|
||||
"\\n✓ All %s users successfully enabled background sync via app passwords!",
|
||||
len(test_users),
|
||||
)
|
||||
|
||||
|
||||
@@ -1065,7 +1090,7 @@ async def revoke_background_sync_access(page: Page, username: str) -> bool:
|
||||
Returns:
|
||||
True if revocation was successful
|
||||
"""
|
||||
logger.info(f"Revoking background sync access for {username}...")
|
||||
logger.info("Revoking background sync access for %s...", username)
|
||||
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
@@ -1080,7 +1105,7 @@ async def revoke_background_sync_access(page: Page, username: str) -> bool:
|
||||
def log_response(resp):
|
||||
response_info = f"{resp.status} {resp.url}"
|
||||
network_responses.append(response_info)
|
||||
logger.info(f"Response: {response_info}")
|
||||
logger.info("Response: %s", response_info)
|
||||
|
||||
def log_console(msg):
|
||||
console_messages.append(f"[{msg.type}] {msg.text}")
|
||||
@@ -1102,11 +1127,11 @@ async def revoke_background_sync_access(page: Page, username: str) -> bool:
|
||||
active_text = page.get_by_text("Active", exact=True)
|
||||
if not await active_text.is_visible(timeout=2000):
|
||||
logger.warning(
|
||||
f"Background sync not active for {username}, nothing to revoke"
|
||||
"Background sync not active for %s, nothing to revoke", username
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
logger.warning(f"Could not find Active badge for {username}")
|
||||
logger.warning("Could not find Active badge for %s", username)
|
||||
return False
|
||||
|
||||
# Find the "Revoke Access" button
|
||||
@@ -1134,21 +1159,21 @@ async def revoke_background_sync_access(page: Page, username: str) -> bool:
|
||||
await anyio.sleep(2)
|
||||
|
||||
# Log network requests after clicking
|
||||
logger.info(f"Network requests after Revoke for {username}:")
|
||||
logger.info("Network requests after Revoke for %s:", username)
|
||||
for req in network_requests[-10:]:
|
||||
logger.info(f" {req}")
|
||||
logger.info(" %s", req)
|
||||
|
||||
# Log network responses
|
||||
logger.info(f"Network responses after Revoke for {username}:")
|
||||
logger.info("Network responses after Revoke for %s:", username)
|
||||
for resp in network_responses[-10:]:
|
||||
logger.info(f" {resp}")
|
||||
logger.info(" %s", resp)
|
||||
|
||||
# Check specifically for the revoke POST response
|
||||
revoke_responses = [r for r in network_responses if "credentials/revoke" in r]
|
||||
if revoke_responses:
|
||||
logger.info(f"Revoke endpoint response: {revoke_responses[-1]}")
|
||||
logger.info("Revoke endpoint response: %s", revoke_responses[-1])
|
||||
if "200" not in revoke_responses[-1]:
|
||||
logger.error(f"Revoke POST did not return 200 OK: {revoke_responses[-1]}")
|
||||
logger.error("Revoke POST did not return 200 OK: %s", revoke_responses[-1])
|
||||
return False
|
||||
else:
|
||||
logger.warning("No response found for credentials/revoke endpoint!")
|
||||
@@ -1159,16 +1184,16 @@ async def revoke_background_sync_access(page: Page, username: str) -> bool:
|
||||
|
||||
# Log any console messages
|
||||
if console_messages:
|
||||
logger.info(f"Console messages for {username}:")
|
||||
logger.info("Console messages for %s:", username)
|
||||
for msg in console_messages:
|
||||
logger.info(f" {msg}")
|
||||
logger.info(" %s", msg)
|
||||
|
||||
# Check for error notifications (toast messages)
|
||||
try:
|
||||
error_toast = page.locator(".toastify.toast-error, .toast-error")
|
||||
if await error_toast.count() > 0:
|
||||
error_text = await error_toast.first.text_content()
|
||||
logger.error(f"Error notification for {username}: {error_text}")
|
||||
logger.error("Error notification for %s: %s", username, error_text)
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1177,14 +1202,14 @@ async def revoke_background_sync_access(page: Page, username: str) -> bool:
|
||||
try:
|
||||
active_text = page.get_by_text("Active", exact=True)
|
||||
if await active_text.is_visible(timeout=2000):
|
||||
logger.error(f"Active badge still visible for {username} after revoke!")
|
||||
logger.error("Active badge still visible for %s after revoke!", username)
|
||||
screenshot_path = f"/tmp/astrolabe_revoke_still_active_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(f"✓ Background sync access revoked for {username}")
|
||||
logger.info("✓ Background sync access revoked for %s", username)
|
||||
return True
|
||||
|
||||
|
||||
@@ -1197,7 +1222,7 @@ async def verify_app_password_deleted(username: str) -> bool:
|
||||
Returns:
|
||||
True if background sync credentials no longer exist
|
||||
"""
|
||||
logger.info(f"Verifying background sync credentials deleted for {username}...")
|
||||
logger.info("Verifying background sync credentials deleted for %s...", username)
|
||||
|
||||
query = f"""
|
||||
SELECT userid, configkey, configvalue
|
||||
@@ -1230,18 +1255,20 @@ async def verify_app_password_deleted(username: str) -> bool:
|
||||
)
|
||||
|
||||
output = result.stdout
|
||||
logger.debug(f"Background sync credentials query result:\n{output}")
|
||||
logger.debug("Background sync credentials query result:\\n%s", output)
|
||||
|
||||
# After deletion, we should NOT see background_sync_password
|
||||
if "background_sync_password" not in output:
|
||||
logger.info(f"✓ Background sync credentials deleted for {username}")
|
||||
logger.info("✓ Background sync credentials deleted for %s", username)
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Background sync credentials still exist for {username}")
|
||||
logger.warning("Background sync credentials still exist for %s", username)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking background sync credentials for {username}: {e}")
|
||||
logger.error(
|
||||
"Error checking background sync credentials for %s: %s", username, e
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
@@ -1316,7 +1343,9 @@ async def test_revoke_background_sync_access(
|
||||
f"Background sync credentials not deleted for {username}"
|
||||
)
|
||||
|
||||
logger.info(f"\n✓ Successfully revoked background sync access for {username}!")
|
||||
logger.info(
|
||||
"\\n✓ Successfully revoked background sync access for %s!", username
|
||||
)
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
|
||||
@@ -58,7 +58,7 @@ async def wait_for_vector_sync(
|
||||
while waited < timeout_seconds:
|
||||
sync_status = await mcp_client.call_tool("nc_get_vector_sync_status", {})
|
||||
if sync_status.isError:
|
||||
logger.warning(f"Vector sync status error: {sync_status}")
|
||||
logger.warning("Vector sync status error: %s", sync_status)
|
||||
return False, None
|
||||
|
||||
status_data = json.loads(sync_status.content[0].text)
|
||||
@@ -66,14 +66,18 @@ async def wait_for_vector_sync(
|
||||
pending_count = status_data.get("pending_count", 1)
|
||||
|
||||
logger.info(
|
||||
f"Sync status at {waited}s: indexed={indexed_count}, "
|
||||
f"pending={pending_count}, status={status_data.get('status')}"
|
||||
"Sync status at %ss: indexed=%s, pending=%s, status=%s",
|
||||
waited,
|
||||
indexed_count,
|
||||
pending_count,
|
||||
status_data.get("status"),
|
||||
)
|
||||
|
||||
if indexed_count > initial_indexed_count and pending_count == 0:
|
||||
logger.info(
|
||||
f"✓ Sync complete: {indexed_count} documents indexed "
|
||||
f"(was {initial_indexed_count})"
|
||||
"✓ Sync complete: %s documents indexed (was %s)",
|
||||
indexed_count,
|
||||
initial_indexed_count,
|
||||
)
|
||||
return True, status_data
|
||||
|
||||
@@ -142,7 +146,7 @@ async def test_astrolabe_plotly_visualization_with_basic_auth(
|
||||
# Phase 2: Complete full Astrolabe authorization (OAuth + app password)
|
||||
await login_to_nextcloud(page, username, password)
|
||||
auth_result = await complete_astrolabe_authorization(page, username, password)
|
||||
logger.info(f"Authorization result: {auth_result}")
|
||||
logger.info("Authorization result: %s", auth_result)
|
||||
|
||||
# Create MCP client session as alice - all MCP operations inside this block
|
||||
async with create_mcp_client_session(
|
||||
@@ -160,7 +164,7 @@ async def test_astrolabe_plotly_visualization_with_basic_auth(
|
||||
|
||||
initial_data = json.loads(initial_sync.content[0].text)
|
||||
initial_count = initial_data.get("indexed_count", 0)
|
||||
logger.info(f"Initial indexed count: {initial_count}")
|
||||
logger.info("Initial indexed count: %s", initial_count)
|
||||
|
||||
# Create note with unique searchable term
|
||||
unique_term = f"plotly_viz_test_{uuid.uuid4().hex[:8]}"
|
||||
@@ -189,7 +193,7 @@ The visualization should show this document as a point in PCA-reduced space.
|
||||
|
||||
note_data = json.loads(note_response.content[0].text)
|
||||
note_id = note_data.get("id")
|
||||
logger.info(f"Created test note ID: {note_id}")
|
||||
logger.info("Created test note ID: %s", note_id)
|
||||
|
||||
# Phase 4: Wait for vector indexing
|
||||
sync_complete, status = await wait_for_vector_sync(
|
||||
@@ -205,7 +209,7 @@ The visualization should show this document as a point in PCA-reduced space.
|
||||
search_input = page.locator(".mcp-search-input input")
|
||||
await search_input.wait_for(timeout=10000, state="visible")
|
||||
await search_input.fill(unique_term)
|
||||
logger.info(f"Entered search query: {unique_term}")
|
||||
logger.info("Entered search query: %s", unique_term)
|
||||
|
||||
# Trigger search by pressing Enter on the input field
|
||||
# This is wired to performSearch via @keyup.enter in the Vue component
|
||||
@@ -246,7 +250,7 @@ The visualization should show this document as a point in PCA-reduced space.
|
||||
for attempt in range(60): # 60 attempts, 500ms each = 30s total
|
||||
if await error_note.count() > 0:
|
||||
error_text = await error_note.text_content()
|
||||
logger.error(f"Search error: {error_text}")
|
||||
logger.error("Search error: %s", error_text)
|
||||
pytest.fail(f"Search failed with error: {error_text}")
|
||||
|
||||
if await no_results_text.count() > 0:
|
||||
@@ -261,13 +265,13 @@ The visualization should show this document as a point in PCA-reduced space.
|
||||
|
||||
if await results_text_pattern.count() > 0:
|
||||
results_text = await results_text_pattern.first.text_content()
|
||||
logger.info(f"Found results: {results_text}")
|
||||
logger.info("Found results: %s", results_text)
|
||||
found_state = True
|
||||
break
|
||||
|
||||
if attempt % 10 == 0:
|
||||
logger.info(
|
||||
f"Waiting for results... (attempt {attempt + 1}/60)"
|
||||
"Waiting for results... (attempt %s/60)", attempt + 1
|
||||
)
|
||||
|
||||
await anyio.sleep(0.5)
|
||||
@@ -275,8 +279,8 @@ The visualization should show this document as a point in PCA-reduced space.
|
||||
if not found_state:
|
||||
await page.screenshot(path="/tmp/astrolabe_search_timeout.png")
|
||||
page_content = await page.content()
|
||||
logger.error(f"Search state not resolved. Page URL: {page.url}")
|
||||
logger.error(f"Page content snippet: {page_content[:2000]}")
|
||||
logger.error("Search state not resolved. Page URL: %s", page.url)
|
||||
logger.error("Page content snippet: %s", page_content[:2000])
|
||||
raise AssertionError("Search did not complete within timeout")
|
||||
|
||||
except AssertionError:
|
||||
@@ -285,8 +289,8 @@ The visualization should show this document as a point in PCA-reduced space.
|
||||
# Take another screenshot and get page content for debugging
|
||||
await page.screenshot(path="/tmp/astrolabe_search_timeout.png")
|
||||
page_content = await page.content()
|
||||
logger.error(f"Search state not resolved. Page URL: {page.url}")
|
||||
logger.error(f"Page content snippet: {page_content[:2000]}")
|
||||
logger.error("Search state not resolved. Page URL: %s", page.url)
|
||||
logger.error("Page content snippet: %s", page_content[:2000])
|
||||
raise AssertionError(f"Search did not complete: {e}")
|
||||
|
||||
logger.info("Results loaded")
|
||||
@@ -316,7 +320,7 @@ The visualization should show this document as a point in PCA-reduced space.
|
||||
result_items = page.locator(".mcp-result-item")
|
||||
result_count = await result_items.count()
|
||||
assert result_count > 0, "No search results displayed"
|
||||
logger.info(f"✓ Found {result_count} search result(s)")
|
||||
logger.info("✓ Found %s search result(s)", result_count)
|
||||
|
||||
# Verify our note appears in results
|
||||
found_note = False
|
||||
@@ -326,7 +330,7 @@ The visualization should show this document as a point in PCA-reduced space.
|
||||
title_text = await title_elem.text_content()
|
||||
if title_text and unique_term in title_text:
|
||||
found_note = True
|
||||
logger.info(f"✓ Found test note in results: {title_text}")
|
||||
logger.info("✓ Found test note in results: %s", title_text)
|
||||
break
|
||||
|
||||
assert found_note, f"Created note with '{unique_term}' not found in results"
|
||||
@@ -342,14 +346,14 @@ The visualization should show this document as a point in PCA-reduced space.
|
||||
"nc_notes_delete_note", {"note_id": note_id}
|
||||
)
|
||||
if not delete_response.isError:
|
||||
logger.info(f"✓ Cleaned up test note {note_id}")
|
||||
logger.info("✓ Cleaned up test note %s", note_id)
|
||||
note_id = None # Mark as cleaned
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to delete note {note_id}: {delete_response}"
|
||||
"Failed to delete note %s: %s", note_id, delete_response
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cleanup failed for note {note_id}: {e}")
|
||||
logger.warning("Cleanup failed for note %s: %s", note_id, e)
|
||||
|
||||
finally:
|
||||
# Cleanup note if not already cleaned (create new client for cleanup)
|
||||
@@ -364,13 +368,13 @@ The visualization should show this document as a point in PCA-reduced space.
|
||||
"nc_notes_delete_note", {"note_id": note_id}
|
||||
)
|
||||
if not delete_response.isError:
|
||||
logger.info(f"✓ Cleaned up test note {note_id} (finally)")
|
||||
logger.info("✓ Cleaned up test note %s (finally)", note_id)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to delete note {note_id}: {delete_response}"
|
||||
"Failed to delete note %s: %s", note_id, delete_response
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cleanup failed for note {note_id}: {e}")
|
||||
logger.warning("Cleanup failed for note %s: %s", note_id, e)
|
||||
|
||||
# Close browser context
|
||||
await context.close()
|
||||
|
||||
@@ -45,7 +45,7 @@ async def login_to_nextcloud(page: Page, username: str, password: str):
|
||||
"""
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
logger.info(f"Logging in to Nextcloud as {username}...")
|
||||
logger.info("Logging in to Nextcloud as %s...", username)
|
||||
await page.goto(f"{nextcloud_url}/login", wait_until="networkidle")
|
||||
|
||||
# Fill in login form
|
||||
@@ -62,7 +62,7 @@ async def login_to_nextcloud(page: Page, username: str, password: str):
|
||||
assert "/login" not in current_url, (
|
||||
f"Login failed for {username}, still on login page"
|
||||
)
|
||||
logger.info(f"✓ Successfully logged in as {username}")
|
||||
logger.info("✓ Successfully logged in as %s", username)
|
||||
|
||||
|
||||
async def generate_app_password(
|
||||
@@ -78,7 +78,7 @@ async def generate_app_password(
|
||||
Returns:
|
||||
The generated app password string
|
||||
"""
|
||||
logger.info(f"Generating app password for {username}...")
|
||||
logger.info("Generating app password for %s...", username)
|
||||
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
@@ -89,7 +89,7 @@ async def generate_app_password(
|
||||
# Fill the app password input field
|
||||
app_password_input = page.locator('input[placeholder="App name"]')
|
||||
await app_password_input.fill(app_name)
|
||||
logger.info(f"Entered app name: {app_name}")
|
||||
logger.info("Entered app name: %s", app_name)
|
||||
|
||||
# Wait for Vue.js to react and enable the button
|
||||
await anyio.sleep(1.0)
|
||||
@@ -116,12 +116,12 @@ async def generate_app_password(
|
||||
value = await input_elem.input_value()
|
||||
if value and "-" in value and len(value) > 20:
|
||||
app_password = value.strip()
|
||||
logger.info(f"Found app password in input {idx}")
|
||||
logger.info("Found app password in input %s", idx)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to find app password dialog: {e}")
|
||||
logger.error("Failed to find app password dialog: %s", e)
|
||||
|
||||
if not app_password:
|
||||
screenshot_path = f"/tmp/app_password_generation_{username}.png"
|
||||
@@ -137,7 +137,7 @@ async def generate_app_password(
|
||||
):
|
||||
raise ValueError(f"App password format validation failed: {app_password}")
|
||||
|
||||
logger.info(f"✓ Generated app password for {username}")
|
||||
logger.info("✓ Generated app password for %s", username)
|
||||
|
||||
# Close the dialog
|
||||
close_button = page.get_by_role("button", name="Close")
|
||||
@@ -163,7 +163,7 @@ async def save_app_password_in_astrolabe(
|
||||
Returns:
|
||||
True if the password was saved successfully (based on network response)
|
||||
"""
|
||||
logger.info(f"Saving app password in Astrolabe for {username}...")
|
||||
logger.info("Saving app password in Astrolabe for %s...", username)
|
||||
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
@@ -174,7 +174,7 @@ async def save_app_password_in_astrolabe(
|
||||
nonlocal credentials_response_status
|
||||
if "background-sync/credentials" in resp.url or "storeAppPassword" in resp.url:
|
||||
credentials_response_status = resp.status
|
||||
logger.info(f"Credentials endpoint response: {resp.status} {resp.url}")
|
||||
logger.info("Credentials endpoint response: %s %s", resp.status, resp.url)
|
||||
|
||||
page.on("response", capture_response)
|
||||
|
||||
@@ -188,7 +188,7 @@ async def save_app_password_in_astrolabe(
|
||||
try:
|
||||
complete_badge = page.locator('text="Complete"').first
|
||||
if await complete_badge.is_visible(timeout=2000):
|
||||
logger.info(f"✓ App password already configured for {username}")
|
||||
logger.info("✓ App password already configured for %s", username)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
@@ -208,7 +208,7 @@ async def save_app_password_in_astrolabe(
|
||||
|
||||
# Enter the app password
|
||||
await app_password_input.fill(app_password)
|
||||
logger.info(f"Entered app password for {username}")
|
||||
logger.info("Entered app password for %s", username)
|
||||
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
@@ -223,11 +223,13 @@ async def save_app_password_in_astrolabe(
|
||||
|
||||
# Verify the save was successful by checking network response
|
||||
if credentials_response_status == 200:
|
||||
logger.info(f"✓ App password saved successfully for {username}")
|
||||
logger.info("✓ App password saved successfully for %s", username)
|
||||
return True
|
||||
else:
|
||||
logger.error(
|
||||
f"App password save failed for {username}, status: {credentials_response_status}"
|
||||
"App password save failed for %s, status: %s",
|
||||
username,
|
||||
credentials_response_status,
|
||||
)
|
||||
screenshot_path = f"/tmp/astrolabe_save_failed_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
@@ -284,7 +286,7 @@ def get_background_sync_credentials(username: str) -> dict | None:
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting credentials for {username}: {e}")
|
||||
logger.error("Error getting credentials for %s: %s", username, e)
|
||||
return None
|
||||
|
||||
|
||||
@@ -325,11 +327,11 @@ def delete_user_credentials(username: str) -> bool:
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
logger.info(f"Deleted credentials for {username}")
|
||||
logger.info("Deleted credentials for %s", username)
|
||||
return result.returncode == 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting credentials for {username}: {e}")
|
||||
logger.error("Error deleting credentials for %s: %s", username, e)
|
||||
return False
|
||||
|
||||
|
||||
@@ -489,7 +491,7 @@ async def test_credential_isolation_between_users(
|
||||
# Verify stored
|
||||
creds = get_background_sync_credentials(username)
|
||||
assert creds is not None, f"Credentials not stored for {username}"
|
||||
logger.info(f"✓ Credentials provisioned for {username}")
|
||||
logger.info("✓ Credentials provisioned for %s", username)
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
|
||||
@@ -26,7 +26,7 @@ async def board_with_two_stacks(nc_client: NextcloudClient):
|
||||
board_title = f"Reorder Test Board {unique_suffix}"
|
||||
board = None
|
||||
|
||||
logger.info(f"Creating board with two stacks: {board_title}")
|
||||
logger.info("Creating board with two stacks: %s", board_title)
|
||||
try:
|
||||
board = await nc_client.deck.create_board(board_title, "0000FF")
|
||||
board_id = board.id
|
||||
@@ -40,7 +40,7 @@ async def board_with_two_stacks(nc_client: NextcloudClient):
|
||||
"title": source_stack.title,
|
||||
"order": source_stack.order,
|
||||
}
|
||||
logger.info(f"Created source stack with ID: {source_stack.id}")
|
||||
logger.info("Created source stack with ID: %s", source_stack.id)
|
||||
|
||||
# Create target stack (stack 2)
|
||||
target_stack = await nc_client.deck.create_stack(
|
||||
@@ -51,7 +51,7 @@ async def board_with_two_stacks(nc_client: NextcloudClient):
|
||||
"title": target_stack.title,
|
||||
"order": target_stack.order,
|
||||
}
|
||||
logger.info(f"Created target stack with ID: {target_stack.id}")
|
||||
logger.info("Created target stack with ID: %s", target_stack.id)
|
||||
|
||||
board_data = {
|
||||
"id": board_id,
|
||||
@@ -63,11 +63,11 @@ async def board_with_two_stacks(nc_client: NextcloudClient):
|
||||
|
||||
finally:
|
||||
if board:
|
||||
logger.info(f"Cleaning up board ID: {board.id}")
|
||||
logger.info("Cleaning up board ID: %s", board.id)
|
||||
try:
|
||||
await nc_client.deck.delete_board(board.id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error cleaning up board: {e}")
|
||||
logger.warning("Error cleaning up board: %s", e)
|
||||
|
||||
|
||||
async def test_reorder_card_move_to_different_stack(
|
||||
@@ -90,7 +90,7 @@ async def test_reorder_card_move_to_different_stack(
|
||||
board_id, source_stack_id, card_title, description="Card to be moved"
|
||||
)
|
||||
card_id = card.id
|
||||
logger.info(f"Created card ID: {card_id} in source stack ID: {source_stack_id}")
|
||||
logger.info("Created card ID: %s in source stack ID: %s", card_id, source_stack_id)
|
||||
|
||||
try:
|
||||
# Verify card is in source stack
|
||||
@@ -99,12 +99,14 @@ async def test_reorder_card_move_to_different_stack(
|
||||
f"Card should start in source stack {source_stack_id}, "
|
||||
f"but is in {card_before.stackId}"
|
||||
)
|
||||
logger.info(f"Verified card is in source stack: {source_stack_id}")
|
||||
logger.info("Verified card is in source stack: %s", source_stack_id)
|
||||
|
||||
# Move card to target stack
|
||||
logger.info(
|
||||
f"Moving card {card_id} from stack {source_stack_id} "
|
||||
f"to stack {target_stack_id}"
|
||||
"Moving card %s from stack %s to stack %s",
|
||||
card_id,
|
||||
source_stack_id,
|
||||
target_stack_id,
|
||||
)
|
||||
await nc_client.deck.reorder_card(
|
||||
board_id=board_id,
|
||||
@@ -122,7 +124,7 @@ async def test_reorder_card_move_to_different_stack(
|
||||
f"Card should have moved to target stack {target_stack_id}, "
|
||||
f"but is in {card_after.stackId}"
|
||||
)
|
||||
logger.info(f"SUCCESS: Card moved to target stack {target_stack_id}")
|
||||
logger.info("SUCCESS: Card moved to target stack %s", target_stack_id)
|
||||
|
||||
finally:
|
||||
# Clean up - try to delete from target stack first, then source
|
||||
@@ -132,7 +134,7 @@ async def test_reorder_card_move_to_different_stack(
|
||||
try:
|
||||
await nc_client.deck.delete_card(board_id, source_stack_id, card_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error cleaning up card: {e}")
|
||||
logger.warning("Error cleaning up card: %s", e)
|
||||
|
||||
|
||||
async def test_reorder_card_within_same_stack(
|
||||
@@ -151,7 +153,7 @@ async def test_reorder_card_within_same_stack(
|
||||
card2 = await nc_client.deck.create_card(
|
||||
board_id, source_stack_id, f"Card 2 {unique_suffix}", order=1
|
||||
)
|
||||
logger.info(f"Created cards {card1.id} (order 0) and {card2.id} (order 1)")
|
||||
logger.info("Created cards %s (order 0) and %s (order 1)", card1.id, card2.id)
|
||||
|
||||
try:
|
||||
# Reorder card1 to position after card2
|
||||
@@ -162,7 +164,7 @@ async def test_reorder_card_within_same_stack(
|
||||
order=2, # Move to position 2
|
||||
target_stack_id=source_stack_id, # Same stack
|
||||
)
|
||||
logger.info(f"Reordered card {card1.id} to order 2")
|
||||
logger.info("Reordered card %s to order 2", card1.id)
|
||||
|
||||
# Verify card is still in the same stack
|
||||
card_after = await nc_client.deck.get_card(board_id, source_stack_id, card1.id)
|
||||
@@ -174,4 +176,4 @@ async def test_reorder_card_within_same_stack(
|
||||
await nc_client.deck.delete_card(board_id, source_stack_id, card1.id)
|
||||
await nc_client.deck.delete_card(board_id, source_stack_id, card2.id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error cleaning up cards: {e}")
|
||||
logger.warning("Error cleaning up cards: %s", e)
|
||||
|
||||
@@ -129,7 +129,7 @@ async def indexed_manual_pdf(nc_client, nc_mcp_client):
|
||||
|
||||
manual_path = os.getenv("RAG_MANUAL_PATH", DEFAULT_MANUAL_PATH)
|
||||
|
||||
logger.info(f"Setting up indexed manual PDF: {manual_path}")
|
||||
logger.info("Setting up indexed manual PDF: %s", manual_path)
|
||||
|
||||
# Get file info to verify file exists and get file ID. After the
|
||||
# round-7 contract widening, get_file_info raises HTTPStatusError on
|
||||
@@ -145,16 +145,16 @@ async def indexed_manual_pdf(nc_client, nc_mcp_client):
|
||||
pytest.skip(f"Manual PDF unreadable at '{manual_path}' (malformed PROPFIND)")
|
||||
|
||||
file_id = file_info["id"]
|
||||
logger.info(f"Found manual PDF: {manual_path} (file_id={file_id})")
|
||||
logger.info("Found manual PDF: %s (file_id=%s)", manual_path, file_id)
|
||||
|
||||
# Create or get the vector-index tag
|
||||
tag = await nc_client.webdav.get_or_create_tag("vector-index")
|
||||
tag_id = tag["id"]
|
||||
logger.info(f"Using tag 'vector-index' (tag_id={tag_id})")
|
||||
logger.info("Using tag 'vector-index' (tag_id=%s)", tag_id)
|
||||
|
||||
# Assign tag to file
|
||||
await nc_client.webdav.assign_tag_to_file(file_id, tag_id)
|
||||
logger.info(f"Tagged file {file_id} with vector-index tag")
|
||||
logger.info("Tagged file %s with vector-index tag", file_id)
|
||||
|
||||
# Wait for vector sync to complete indexing
|
||||
max_attempts = 60
|
||||
@@ -176,23 +176,26 @@ async def indexed_manual_pdf(nc_client, nc_mcp_client):
|
||||
pending = content.get("pending_count", 1)
|
||||
|
||||
logger.info(
|
||||
f"Attempt {attempt}/{max_attempts}: "
|
||||
f"indexed={indexed}, pending={pending}"
|
||||
"Attempt %s/%s: indexed=%s, pending=%s",
|
||||
attempt,
|
||||
max_attempts,
|
||||
indexed,
|
||||
pending,
|
||||
)
|
||||
|
||||
if indexed > 0 and pending == 0:
|
||||
logger.info(
|
||||
f"Vector indexing complete: {indexed} documents indexed"
|
||||
"Vector indexing complete: %s documents indexed", indexed
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Attempt {attempt}: Error checking status: {e}")
|
||||
logger.warning("Attempt %s: Error checking status: %s", attempt, e)
|
||||
|
||||
if attempt < max_attempts:
|
||||
await anyio.sleep(poll_interval)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Vector indexing may not be complete after {max_attempts} attempts"
|
||||
"Vector indexing may not be complete after %s attempts", max_attempts
|
||||
)
|
||||
|
||||
yield {
|
||||
|
||||
@@ -57,7 +57,7 @@ async def test_unstructured_api_enabled_parsing(
|
||||
await nc_client.webdav.write_file(
|
||||
test_file, pdf_content, content_type="application/pdf"
|
||||
)
|
||||
logger.info(f"Uploaded PDF file: {test_file}")
|
||||
logger.info("Uploaded PDF file: %s", test_file)
|
||||
|
||||
# Read the PDF using MCP tool (should parse via Unstructured API)
|
||||
mcp_result = await nc_mcp_client.call_tool(
|
||||
@@ -123,7 +123,7 @@ async def test_unstructured_api_with_docx(
|
||||
docx_content,
|
||||
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
)
|
||||
logger.info(f"Uploaded DOCX file: {test_file}")
|
||||
logger.info("Uploaded DOCX file: %s", test_file)
|
||||
|
||||
# Read the file using MCP tool
|
||||
mcp_result = await nc_mcp_client.call_tool(
|
||||
|
||||
Reference in New Issue
Block a user