Files
mcp-nextcloud/nextcloud_mcp_server/client/base.py
T
Chris CoutinhoandClaude Opus 4.7 665cb9b1eb 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>
2026-05-13 01:12:17 +02:00

185 lines
6.2 KiB
Python

"""Base client for Nextcloud operations with shared authentication."""
import logging
import time
from abc import ABC
from functools import wraps
import anyio
from httpx import AsyncClient, HTTPStatusError, RequestError, codes
from nextcloud_mcp_server.observability.metrics import (
record_nextcloud_api_call,
record_nextcloud_api_retry,
)
from nextcloud_mcp_server.observability.tracing import trace_nextcloud_api_call
logger = logging.getLogger(__name__)
def retry_on_429(func):
"""This decorator handles the 429 response from REST APIs
The `func` is assumed to be a method that is similar to `httpx.Client.get`,
and returns an `httpx.Response` object. In the case of `Too Many Requests` HTTP
response, the function will wait for a couple of seconds and retry the request.
"""
MAX_RETRIES = 5
@wraps(func)
async def wrapper(*args, **kwargs):
retries = 0
while retries < MAX_RETRIES:
try:
# Make GET API call
retries += 1
response = await func(*args, **kwargs)
break
except HTTPStatusError as e:
# If we get a '429 Client Error: Too Many Requests'
# error we wait a couple of seconds and do a retry
if e.response.status_code == codes.TOO_MANY_REQUESTS:
logger.warning(
"429 Client Error: Too Many Requests, Number of attempts: %s",
retries,
)
# Record retry metric (extract app name from args if available)
if len(args) > 0 and hasattr(args[0], "app_name"):
record_nextcloud_api_retry(app=args[0].app_name, reason="429")
await anyio.sleep(5)
elif e.response.status_code == 404:
# 404 errors are often expected (e.g., checking if attachments exist)
# Log as debug instead of warning
logger.debug(
"HTTPStatusError %s: %s, Number of attempts: %s",
e.response.status_code,
e,
retries,
)
raise
else:
logger.warning(
"HTTPStatusError %s: %s, Number of attempts: %s",
e.response.status_code,
e,
retries,
)
raise
except RequestError as e:
logger.warning(
"RequestError %s: %s, Number of attempts: %s",
e.request.url,
e,
retries,
)
raise
# If for loop ends without break statement
else:
logger.warning("All API call retries failed")
raise RuntimeError(
f"Maximum number of retries ({MAX_RETRIES}) exceeded without success"
)
return response
return wrapper
class BaseNextcloudClient(ABC):
"""Base class for all Nextcloud app clients."""
# Subclasses should set this to identify the app for metrics/tracing
app_name: str = "unknown"
def __init__(self, http_client: AsyncClient, username: str):
"""Initialize with shared HTTP client and username.
Args:
http_client: Authenticated AsyncClient instance
username: Nextcloud username for WebDAV operations
"""
self._client = http_client
self.username = username
def _get_webdav_base_path(self) -> str:
"""Helper to get the base WebDAV path for the authenticated user."""
return f"/remote.php/dav/files/{self.username}"
@staticmethod
def _resolve_url(url: str) -> str:
"""Prefix bare ``/apps/...`` paths with ``/index.php``.
Pretty URLs (URL rewriting that strips ``index.php``) are an opt-in
Nextcloud feature; without them, ``/apps/<app>/...`` returns 404 — see
issue #732. ``/index.php/apps/<app>/...`` is the universal entry point
and works on every Nextcloud install regardless of web-server config,
so we route all app-API calls through it. ``/remote.php/dav/...`` and
``/ocs/...`` have their own dedicated entry points and are unaffected.
"""
if url.startswith("/apps/"):
return "/index.php" + url
return url
@retry_on_429
async def _make_request(self, method: str, url: str, **kwargs):
"""Common request wrapper with logging, tracing, and error handling.
Args:
method: HTTP method
url: Request URL
**kwargs: Additional request parameters
Returns:
Response object
"""
url = self._resolve_url(url)
logger.debug("Making %s request to %s", method, url)
# Start timer for metrics
start_time = time.time()
status_code = 0
try:
# Wrap request in trace span
with trace_nextcloud_api_call(
app=self.app_name,
method=method,
path=url,
):
response = await self._client.request(method, url, **kwargs)
status_code = response.status_code
response.raise_for_status()
# Record successful API call metrics
duration = time.time() - start_time
record_nextcloud_api_call(
app=self.app_name,
method=method,
status_code=status_code,
duration=duration,
)
return response
except (HTTPStatusError, RequestError) as e:
# Record error metrics
if isinstance(e, HTTPStatusError):
status_code = e.response.status_code
else:
status_code = 0 # Connection error, no status code
duration = time.time() - start_time
record_nextcloud_api_call(
app=self.app_name,
method=method,
status_code=status_code,
duration=duration,
)
# Re-raise the exception
raise