Merge pull request #647 from cbcoutinho/feature/collectives-support

feat: add Nextcloud Collectives app support
This commit is contained in:
Chris Coutinho
2026-03-28 09:40:50 +01:00
committed by GitHub
15 changed files with 2228 additions and 6 deletions
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
set -euox pipefail
echo "Installing and configuring collectives app for testing..."
# Collectives depends on Circles (teams) - ensure it's enabled
# Circles is bundled with Nextcloud, so just enable it
php /var/www/html/occ app:enable circles
# Check if development collectives app is mounted at /opt/apps/collectives
if [ -d /opt/apps/collectives ]; then
echo "Development collectives app found at /opt/apps/collectives"
# Remove any existing collectives app in apps (from app store or old symlink)
if [ -e /var/www/html/custom_apps/collectives ]; then
echo "Removing existing collectives in apps..."
rm -rf /var/www/html/custom_apps/collectives
fi
# Create symlink from apps to the mounted development version
# Per Nextcloud docs: apps outside server root need symlinks in server root
echo "Creating symlink: custom_apps/collectives -> /opt/apps/collectives"
ln -sf /opt/apps/collectives /var/www/html/custom_apps/collectives
echo "Enabling collectives app from /opt/apps (development mode via symlink)"
php /var/www/html/occ app:enable collectives
elif [ -d /var/www/html/custom_apps/collectives ]; then
echo "collectives app directory found in apps (already installed)"
php /var/www/html/occ app:enable collectives
else
echo "collectives app not found, installing from app store..."
php /var/www/html/occ app:install collectives
php /var/www/html/occ app:enable collectives
fi
+2
View File
@@ -117,6 +117,7 @@ from nextcloud_mcp_server.observability.metrics import (
) )
from nextcloud_mcp_server.server import ( from nextcloud_mcp_server.server import (
configure_calendar_tools, configure_calendar_tools,
configure_collectives_tools,
configure_contacts_tools, configure_contacts_tools,
configure_cookbook_tools, configure_cookbook_tools,
configure_deck_tools, configure_deck_tools,
@@ -1274,6 +1275,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
"webdav": configure_webdav_tools, "webdav": configure_webdav_tools,
"sharing": configure_sharing_tools, "sharing": configure_sharing_tools,
"calendar": configure_calendar_tools, "calendar": configure_calendar_tools,
"collectives": configure_collectives_tools,
"contacts": configure_contacts_tools, "contacts": configure_contacts_tools,
"cookbook": configure_cookbook_tools, "cookbook": configure_cookbook_tools,
"deck": configure_deck_tools, "deck": configure_deck_tools,
+2
View File
@@ -14,6 +14,7 @@ from httpx import (
from ..controllers.notes_search import NotesSearchController from ..controllers.notes_search import NotesSearchController
from ..http import nextcloud_httpx_transport from ..http import nextcloud_httpx_transport
from .calendar import CalendarClient from .calendar import CalendarClient
from .collectives import CollectivesClient
from .contacts import ContactsClient from .contacts import ContactsClient
from .cookbook import CookbookClient from .cookbook import CookbookClient
from .deck import DeckClient from .deck import DeckClient
@@ -81,6 +82,7 @@ class NextcloudClient:
) # Uses AsyncDavClient internally ) # Uses AsyncDavClient internally
self.contacts = ContactsClient(self._client, username) self.contacts = ContactsClient(self._client, username)
self.cookbook = CookbookClient(self._client, username) self.cookbook = CookbookClient(self._client, username)
self.collectives = CollectivesClient(self._client, username)
self.deck = DeckClient(self._client, username) self.deck = DeckClient(self._client, username)
self.news = NewsClient(self._client, username) self.news = NewsClient(self._client, username)
self.users = UsersClient(self._client, username) self.users = UsersClient(self._client, username)
+310
View File
@@ -0,0 +1,310 @@
"""Client for Nextcloud Collectives app API (OCS)."""
import logging
from typing import Any
from nextcloud_mcp_server.client.base import BaseNextcloudClient
logger = logging.getLogger(__name__)
API_BASE = "/ocs/v2.php/apps/collectives/api/v1.0"
_UNSET = object()
"""Sentinel to distinguish 'not provided' from an explicit None."""
class OCSError(Exception):
"""Error returned in the OCS response envelope."""
def __init__(self, status_code: int, message: str):
self.status_code = status_code
self.message = message
super().__init__(f"OCS error {status_code}: {message}")
class CollectivesClient(BaseNextcloudClient):
"""Client for Nextcloud Collectives app operations."""
app_name = "collectives"
_OCS_HEADERS: dict[str, str] = {
"OCS-APIRequest": "true",
"Accept": "application/json",
}
_OCS_HEADERS_JSON: dict[str, str] = {
**_OCS_HEADERS,
"Content-Type": "application/json",
}
def _unwrap_ocs(self, response_json: dict[str, Any]) -> Any:
"""Unwrap OCS envelope, validating the status before returning data."""
ocs = response_json.get("ocs")
if ocs is None:
raise OCSError(500, "Response is not an OCS envelope")
meta = ocs.get("meta", {})
status_code = meta.get("statuscode", 200)
if status_code >= 400:
message = meta.get("message", "OCS error")
raise OCSError(status_code, message)
if "data" not in ocs:
raise OCSError(500, "OCS response missing 'data' field")
return ocs["data"]
# Collectives
async def get_collectives(self) -> list[dict[str, Any]]:
"""List all collectives the user has access to."""
response = await self._make_request(
"GET", f"{API_BASE}/collectives", headers=self._OCS_HEADERS
)
data = self._unwrap_ocs(response.json())
return data["collectives"]
async def create_collective(
self, name: str, emoji: str | None = None
) -> dict[str, Any]:
"""Create a new collective."""
json_data: dict[str, Any] = {"name": name}
if emoji is not None:
json_data["emoji"] = emoji
response = await self._make_request(
"POST",
f"{API_BASE}/collectives",
json=json_data,
headers=self._OCS_HEADERS_JSON,
)
data = self._unwrap_ocs(response.json())
return data["collective"]
async def update_collective(
self, collective_id: int, emoji: str | None | object = _UNSET
) -> dict[str, Any]:
"""Update a collective (emoji).
Pass emoji=None to clear the emoji. Omit emoji entirely to leave
it unchanged.
Raises:
ValueError: If no fields are provided to update.
"""
json_data: dict[str, Any] = {}
if emoji is not _UNSET:
json_data["emoji"] = emoji
if not json_data:
raise ValueError("At least one field must be provided to update")
response = await self._make_request(
"PUT",
f"{API_BASE}/collectives/{collective_id}",
json=json_data,
headers=self._OCS_HEADERS_JSON,
)
data = self._unwrap_ocs(response.json())
return data["collective"]
async def trash_collective(self, collective_id: int) -> None:
"""Move a collective to trash (soft delete)."""
response = await self._make_request(
"DELETE",
f"{API_BASE}/collectives/{collective_id}",
headers=self._OCS_HEADERS,
)
self._unwrap_ocs(response.json())
async def delete_collective(self, collective_id: int) -> None:
"""Permanently delete a collective (must be trashed first).
This is irreversible. The collective must be in the trash before
calling this method.
"""
response = await self._make_request(
"DELETE",
f"{API_BASE}/collectives/trash/{collective_id}",
headers=self._OCS_HEADERS,
)
self._unwrap_ocs(response.json())
# Trash (collectives)
async def get_trashed_collectives(self) -> list[dict[str, Any]]:
"""List trashed collectives."""
response = await self._make_request(
"GET", f"{API_BASE}/collectives/trash", headers=self._OCS_HEADERS
)
data = self._unwrap_ocs(response.json())
return data["collectives"]
async def restore_collective(self, collective_id: int) -> dict[str, Any]:
"""Restore a collective from trash."""
response = await self._make_request(
"PATCH",
f"{API_BASE}/collectives/trash/{collective_id}",
headers=self._OCS_HEADERS,
)
data = self._unwrap_ocs(response.json())
return data["collective"]
# Pages
async def get_pages(self, collective_id: int) -> list[dict[str, Any]]:
"""List all pages in a collective."""
response = await self._make_request(
"GET",
f"{API_BASE}/collectives/{collective_id}/pages",
headers=self._OCS_HEADERS,
)
data = self._unwrap_ocs(response.json())
return data["pages"]
async def get_page(self, collective_id: int, page_id: int) -> dict[str, Any]:
"""Get a single page's metadata."""
response = await self._make_request(
"GET",
f"{API_BASE}/collectives/{collective_id}/pages/{page_id}",
headers=self._OCS_HEADERS,
)
data = self._unwrap_ocs(response.json())
return data["page"]
async def create_page(
self, collective_id: int, parent_id: int, title: str
) -> dict[str, Any]:
"""Create a new page under a parent page."""
json_data = {"title": title}
response = await self._make_request(
"POST",
f"{API_BASE}/collectives/{collective_id}/pages/{parent_id}",
json=json_data,
headers=self._OCS_HEADERS_JSON,
)
data = self._unwrap_ocs(response.json())
return data["page"]
async def move_page(
self,
collective_id: int,
page_id: int,
parent_id: int | None = None,
title: str | None = None,
index: int = 0,
copy: bool = False,
) -> dict[str, Any]:
"""Move or copy a page within a collective."""
json_data: dict[str, Any] = {"index": index, "copy": copy}
if parent_id is not None:
json_data["parentId"] = parent_id
if title is not None:
json_data["title"] = title
response = await self._make_request(
"PUT",
f"{API_BASE}/collectives/{collective_id}/pages/{page_id}",
json=json_data,
headers=self._OCS_HEADERS_JSON,
)
data = self._unwrap_ocs(response.json())
return data["page"]
async def trash_page(self, collective_id: int, page_id: int) -> None:
"""Move a page to trash (soft delete)."""
response = await self._make_request(
"DELETE",
f"{API_BASE}/collectives/{collective_id}/pages/{page_id}",
headers=self._OCS_HEADERS,
)
self._unwrap_ocs(response.json())
async def set_page_emoji(
self, collective_id: int, page_id: int, emoji: str | None
) -> dict[str, Any]:
"""Set or clear the emoji on a page."""
# Sending {"emoji": null} intentionally clears the emoji on the server
json_data = {"emoji": emoji}
response = await self._make_request(
"PUT",
f"{API_BASE}/collectives/{collective_id}/pages/{page_id}/emoji",
json=json_data,
headers=self._OCS_HEADERS_JSON,
)
data = self._unwrap_ocs(response.json())
return data["page"]
# Search
async def search_pages(
self, collective_id: int, query: str
) -> list[dict[str, Any]]:
"""Full-text search within a collective."""
response = await self._make_request(
"GET",
f"{API_BASE}/collectives/{collective_id}/search",
params={"searchString": query},
headers=self._OCS_HEADERS,
)
data = self._unwrap_ocs(response.json())
return data["pages"]
# Tags
async def get_tags(self, collective_id: int) -> list[dict[str, Any]]:
"""List all tags in a collective."""
response = await self._make_request(
"GET",
f"{API_BASE}/collectives/{collective_id}/tags",
headers=self._OCS_HEADERS,
)
data = self._unwrap_ocs(response.json())
return data["tags"]
async def create_tag(
self, collective_id: int, name: str, color: str
) -> dict[str, Any]:
"""Create a new tag in a collective."""
json_data = {"name": name, "color": color}
response = await self._make_request(
"POST",
f"{API_BASE}/collectives/{collective_id}/tags",
json=json_data,
headers=self._OCS_HEADERS_JSON,
)
data = self._unwrap_ocs(response.json())
return data["tag"]
async def assign_tag(self, collective_id: int, page_id: int, tag_id: int) -> None:
"""Assign a tag to a page."""
response = await self._make_request(
"PUT",
f"{API_BASE}/collectives/{collective_id}/pages/{page_id}/tags/{tag_id}",
headers=self._OCS_HEADERS,
)
self._unwrap_ocs(response.json())
async def remove_tag(self, collective_id: int, page_id: int, tag_id: int) -> None:
"""Remove a tag from a page."""
response = await self._make_request(
"DELETE",
f"{API_BASE}/collectives/{collective_id}/pages/{page_id}/tags/{tag_id}",
headers=self._OCS_HEADERS,
)
self._unwrap_ocs(response.json())
# Trash
async def get_trashed_pages(self, collective_id: int) -> list[dict[str, Any]]:
"""List trashed pages in a collective."""
response = await self._make_request(
"GET",
f"{API_BASE}/collectives/{collective_id}/pages/trash",
headers=self._OCS_HEADERS,
)
data = self._unwrap_ocs(response.json())
return data["pages"]
async def restore_page(self, collective_id: int, page_id: int) -> dict[str, Any]:
"""Restore a page from trash."""
response = await self._make_request(
"PATCH",
f"{API_BASE}/collectives/{collective_id}/pages/trash/{page_id}",
headers=self._OCS_HEADERS,
)
data = self._unwrap_ocs(response.json())
return data["page"]
+2
View File
@@ -74,5 +74,7 @@ ALL_SUPPORTED_SCOPES: frozenset[str] = frozenset(
"sharing:write", "sharing:write",
"news:read", "news:read",
"news:write", "news:write",
"collectives:read",
"collectives:write",
} }
) )
+159
View File
@@ -0,0 +1,159 @@
"""Pydantic models for Nextcloud Collectives app."""
import re
from pydantic import BaseModel, Field, field_validator
from .base import BaseResponse, StatusResponse
# Domain Models
class Collective(BaseModel):
"""A Nextcloud Collective (wiki/knowledge base)."""
id: int = Field(description="Collective ID")
circleId: str = Field(description="Linked Circle/Team ID")
emoji: str | None = Field(default=None, description="Collective emoji")
name: str = Field(description="Collective name")
level: int = Field(description="User's membership level")
canEdit: bool = Field(description="Whether the user can edit")
canShare: bool = Field(description="Whether the user can share")
pageMode: int = Field(description="Default page mode: 0=view, 1=edit")
class PageInfo(BaseModel):
"""A page within a Collective."""
id: int = Field(description="Page ID")
title: str = Field(description="Page title")
emoji: str | None = Field(default=None, description="Page emoji")
fileName: str = Field(description="Markdown file name")
filePath: str = Field(description="File path within the collective")
collectivePath: str | None = Field(
default=None, description="Collective folder path in user's files"
)
parentId: int = Field(description="Parent page ID (0 for root)")
timestamp: int = Field(description="Last modification Unix timestamp")
size: int = Field(description="Content size in bytes")
lastUserId: str | None = Field(default=None, description="Last editor user ID")
lastUserDisplayName: str | None = Field(
default=None, description="Last editor display name"
)
subpageOrder: list[int] = Field(
default_factory=list, description="Ordered subpage IDs"
)
isFullWidth: bool | None = Field(default=None, description="Full-width page layout")
trashTimestamp: int | None = Field(
default=None, description="Timestamp when the page was trashed"
)
class CollectiveTag(BaseModel):
"""A tag within a Collective."""
id: int = Field(description="Tag ID")
collectiveId: int = Field(description="Parent collective ID")
name: str = Field(description="Tag name")
color: str = Field(description="Hex color code (e.g. 'FF0000')")
@field_validator("color")
@classmethod
def validate_hex_color(cls, v: str) -> str:
if not re.fullmatch(r"[0-9A-Fa-f]{3}(?:[0-9A-Fa-f]{3})?", v):
raise ValueError(f"Invalid hex color: {v!r}")
return v
# Response Models
class ListCollectivesResponse(BaseResponse):
"""Response for listing collectives."""
collectives: list[Collective] = Field(description="List of collectives")
total: int = Field(description="Total number of collectives")
class CreateCollectiveResponse(BaseResponse):
"""Response for creating a collective."""
id: int = Field(description="Created collective ID")
name: str = Field(description="Created collective name")
emoji: str | None = Field(default=None, description="Collective emoji")
class CollectiveOperationResponse(StatusResponse):
"""Response for collective update operations."""
collective_id: int = Field(description="ID of the affected collective")
class ListPagesResponse(BaseResponse):
"""Response for listing pages in a collective."""
pages: list[PageInfo] = Field(description="List of pages")
total: int = Field(description="Total number of pages")
collective_id: int = Field(description="Collective ID")
class GetPageResponse(BaseResponse):
"""Response for getting a single page with content."""
page: PageInfo = Field(description="Page metadata")
content: str | None = Field(
default=None,
description="Page markdown content (fetched via WebDAV)",
)
class CreatePageResponse(BaseResponse):
"""Response for creating a page."""
id: int = Field(description="Created page ID")
title: str = Field(description="Created page title")
collective_id: int = Field(description="Collective ID")
parent_id: int = Field(description="Parent page ID")
class PageOperationResponse(StatusResponse):
"""Response for page operations (update, trash, emoji, tag)."""
page_id: int = Field(description="ID of the affected page")
collective_id: int = Field(description="Collective ID")
class SearchPagesResponse(BaseResponse):
"""Response for full-text search within a collective."""
results: list[PageInfo] = Field(description="Matching pages")
total: int = Field(description="Total number of results")
query: str = Field(description="Search query")
collective_id: int = Field(description="Collective ID")
class ListTrashedPagesResponse(ListPagesResponse):
"""Response for listing trashed pages in a collective."""
class ListTrashedCollectivesResponse(BaseResponse):
"""Response for listing trashed collectives."""
collectives: list[Collective] = Field(description="List of trashed collectives")
total: int = Field(description="Total number of trashed collectives")
class ListTagsResponse(BaseResponse):
"""Response for listing tags in a collective."""
tags: list[CollectiveTag] = Field(description="List of tags")
total: int = Field(description="Total number of tags")
collective_id: int = Field(description="Collective ID")
class CreateTagResponse(BaseResponse):
"""Response for creating a tag."""
id: int = Field(description="Created tag ID")
name: str = Field(description="Tag name")
color: str = Field(description="Tag color")
+2
View File
@@ -1,4 +1,5 @@
from .calendar import configure_calendar_tools from .calendar import configure_calendar_tools
from .collectives import configure_collectives_tools
from .contacts import configure_contacts_tools from .contacts import configure_contacts_tools
from .cookbook import configure_cookbook_tools from .cookbook import configure_cookbook_tools
from .deck import configure_deck_tools from .deck import configure_deck_tools
@@ -11,6 +12,7 @@ from .webdav import configure_webdav_tools
__all__ = [ __all__ = [
"configure_calendar_tools", "configure_calendar_tools",
"configure_collectives_tools",
"configure_contacts_tools", "configure_contacts_tools",
"configure_cookbook_tools", "configure_cookbook_tools",
"configure_deck_tools", "configure_deck_tools",
+624
View File
@@ -0,0 +1,624 @@
"""MCP tool definitions for Nextcloud Collectives app."""
import logging
from httpx import HTTPStatusError
from mcp.server.fastmcp import Context, FastMCP
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData, ToolAnnotations
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.client.collectives import OCSError
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.collectives import (
Collective,
CollectiveOperationResponse,
CollectiveTag,
CreateCollectiveResponse,
CreatePageResponse,
CreateTagResponse,
GetPageResponse,
ListCollectivesResponse,
ListPagesResponse,
ListTagsResponse,
ListTrashedCollectivesResponse,
ListTrashedPagesResponse,
PageInfo,
PageOperationResponse,
SearchPagesResponse,
)
from nextcloud_mcp_server.observability.metrics import instrument_tool
logger = logging.getLogger(__name__)
def _handle_collectives_error(e: OCSError | HTTPStatusError) -> McpError:
"""Convert OCS or HTTP errors to McpError."""
if isinstance(e, OCSError):
return McpError(ErrorData(code=-32603, message=e.message))
return McpError(ErrorData(code=-32603, message=str(e)))
def configure_collectives_tools(mcp: FastMCP):
"""Configure Nextcloud Collectives tools for the MCP server."""
# --- Read Tools ---
@mcp.tool(
title="List Collectives",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("collectives:read")
@instrument_tool
async def collectives_get_collectives(
ctx: Context,
) -> ListCollectivesResponse:
"""List all Nextcloud Collectives the user has access to"""
client = await get_client(ctx)
try:
raw_collectives = await client.collectives.get_collectives()
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
collectives = [Collective(**c) for c in raw_collectives]
return ListCollectivesResponse(collectives=collectives, total=len(collectives))
@mcp.tool(
title="List Collective Pages",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("collectives:read")
@instrument_tool
async def collectives_get_pages(
ctx: Context, collective_id: int
) -> ListPagesResponse:
"""List all pages in a Nextcloud Collective
Args:
collective_id: ID of the collective
"""
client = await get_client(ctx)
try:
raw_pages = await client.collectives.get_pages(collective_id)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
pages = [PageInfo(**p) for p in raw_pages]
return ListPagesResponse(
pages=pages, total=len(pages), collective_id=collective_id
)
@mcp.tool(
title="Get Collective Page",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("collectives:read")
@instrument_tool
async def collectives_get_page(
ctx: Context, collective_id: int, page_id: int
) -> GetPageResponse:
"""Get a page's metadata and markdown content from a Nextcloud Collective.
Content is fetched via WebDAV using the page's file path. To update
page content, use the nc_webdav_write_file tool with the path
collectivePath/filePath/fileName (omit filePath for root-level pages).
Args:
collective_id: ID of the collective
page_id: ID of the page
"""
client = await get_client(ctx)
try:
raw_page = await client.collectives.get_page(collective_id, page_id)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
page = PageInfo(**raw_page)
# Fetch content via WebDAV
# Path structure: collectivePath/filePath/fileName
# filePath is empty for root-level pages, contains subdirectory for nested pages
content = None
if page.collectivePath and page.fileName:
parts = [page.collectivePath]
if page.filePath:
parts.append(page.filePath)
parts.append(page.fileName)
webdav_path = "/".join(p.strip("/") for p in parts)
try:
file_bytes, _ = await client.webdav.read_file(webdav_path)
content = file_bytes.decode("utf-8")
except (HTTPStatusError, OSError, UnicodeDecodeError) as e:
logger.warning(
"Failed to read page content via WebDAV: %s: %s",
webdav_path,
e,
)
return GetPageResponse(page=page, content=content)
@mcp.tool(
title="Search Collective Pages",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("collectives:read")
@instrument_tool
async def collectives_search_pages(
ctx: Context, collective_id: int, query: str
) -> SearchPagesResponse:
"""Full-text search within a Nextcloud Collective
Args:
collective_id: ID of the collective
query: Search query string
"""
client = await get_client(ctx)
try:
raw_pages = await client.collectives.search_pages(collective_id, query)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
pages = [PageInfo(**p) for p in raw_pages]
return SearchPagesResponse(
results=pages,
total=len(pages),
query=query,
collective_id=collective_id,
)
@mcp.tool(
title="List Collective Tags",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("collectives:read")
@instrument_tool
async def collectives_get_tags(
ctx: Context, collective_id: int
) -> ListTagsResponse:
"""List all tags in a Nextcloud Collective
Args:
collective_id: ID of the collective
"""
client = await get_client(ctx)
try:
raw_tags = await client.collectives.get_tags(collective_id)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
tags = [CollectiveTag(**t) for t in raw_tags]
return ListTagsResponse(tags=tags, total=len(tags), collective_id=collective_id)
@mcp.tool(
title="List Trashed Collective Pages",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("collectives:read")
@instrument_tool
async def collectives_get_trashed_pages(
ctx: Context, collective_id: int
) -> ListTrashedPagesResponse:
"""List trashed pages in a Nextcloud Collective
Args:
collective_id: ID of the collective
"""
client = await get_client(ctx)
try:
raw_pages = await client.collectives.get_trashed_pages(collective_id)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
pages = [PageInfo(**p) for p in raw_pages]
return ListTrashedPagesResponse(
pages=pages, total=len(pages), collective_id=collective_id
)
@mcp.tool(
title="List Trashed Collectives",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("collectives:read")
@instrument_tool
async def collectives_get_trashed_collectives(
ctx: Context,
) -> ListTrashedCollectivesResponse:
"""List all trashed Nextcloud Collectives.
Returns collectives that have been soft-deleted and can be restored
or permanently deleted.
"""
client = await get_client(ctx)
try:
raw = await client.collectives.get_trashed_collectives()
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
collectives = [Collective(**c) for c in raw]
return ListTrashedCollectivesResponse(
collectives=collectives, total=len(collectives)
)
# --- Write Tools ---
@mcp.tool(
title="Create Collective",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("collectives:write")
@instrument_tool
async def collectives_create_collective(
ctx: Context, name: str, emoji: str | None = None
) -> CreateCollectiveResponse:
"""Create a new Nextcloud Collective
Args:
name: Name of the collective
emoji: Optional emoji for the collective
"""
client = await get_client(ctx)
try:
raw = await client.collectives.create_collective(name, emoji)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
collective = Collective(**raw)
return CreateCollectiveResponse(
id=collective.id, name=collective.name, emoji=collective.emoji
)
@mcp.tool(
title="Set Collective Emoji",
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
)
@require_scopes("collectives:write")
@instrument_tool
async def collectives_set_collective_emoji(
ctx: Context, collective_id: int, emoji: str | None = None
) -> CollectiveOperationResponse:
"""Set or clear the emoji on a Nextcloud Collective.
Setting the same emoji twice produces the same result (idempotent).
Pass emoji=None to clear the emoji.
Args:
collective_id: ID of the collective
emoji: Emoji to set, or None to clear
"""
client = await get_client(ctx)
try:
raw = await client.collectives.update_collective(collective_id, emoji)
except ValueError as e:
raise McpError(ErrorData(code=-32603, message=str(e))) from e
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
collective = Collective(**raw)
return CollectiveOperationResponse(
collective_id=collective.id,
status_code=200,
message=f"Collective emoji set to: {collective.emoji}",
)
@mcp.tool(
title="Trash Collective",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("collectives:write")
@instrument_tool
async def collectives_trash_collective(
ctx: Context, collective_id: int
) -> CollectiveOperationResponse:
"""Move a Nextcloud Collective to trash (soft delete).
The collective can be restored or permanently deleted afterwards.
Args:
collective_id: ID of the collective to trash
"""
client = await get_client(ctx)
try:
await client.collectives.trash_collective(collective_id)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
return CollectiveOperationResponse(
collective_id=collective_id,
status_code=200,
message="Collective moved to trash",
)
@mcp.tool(
title="Delete Collective",
annotations=ToolAnnotations(
destructiveHint=True, idempotentHint=False, openWorldHint=True
),
)
@require_scopes("collectives:write")
@instrument_tool
async def collectives_delete_collective(
ctx: Context, collective_id: int
) -> CollectiveOperationResponse:
"""Permanently delete a Nextcloud Collective.
WARNING: This is irreversible. The collective must be in the trash
first (use collectives_trash_collective). All pages and content
will be permanently destroyed.
Args:
collective_id: ID of the trashed collective to permanently delete
"""
client = await get_client(ctx)
try:
await client.collectives.delete_collective(collective_id)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
return CollectiveOperationResponse(
collective_id=collective_id,
status_code=200,
message="Collective permanently deleted",
)
@mcp.tool(
title="Restore Collective",
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
)
@require_scopes("collectives:write")
@instrument_tool
async def collectives_restore_collective(
ctx: Context, collective_id: int
) -> CollectiveOperationResponse:
"""Restore a Nextcloud Collective from trash.
Args:
collective_id: ID of the trashed collective to restore
"""
client = await get_client(ctx)
try:
raw = await client.collectives.restore_collective(collective_id)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
collective = Collective(**raw)
return CollectiveOperationResponse(
collective_id=collective.id,
status_code=200,
message=f"Collective '{collective.name}' restored from trash",
)
@mcp.tool(
title="Create Collective Page",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("collectives:write")
@instrument_tool
async def collectives_create_page(
ctx: Context, collective_id: int, parent_id: int, title: str
) -> CreatePageResponse:
"""Create a new page in a Nextcloud Collective.
Pages are created as empty markdown files. Use nc_webdav_write_file
with the path collectivePath/filePath/fileName to add content after
creation (omit filePath for root-level pages).
Args:
collective_id: ID of the collective
parent_id: ID of the parent page (use 0 for top-level pages)
title: Title of the new page
"""
client = await get_client(ctx)
try:
raw = await client.collectives.create_page(collective_id, parent_id, title)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
page = PageInfo(**raw)
return CreatePageResponse(
id=page.id,
title=page.title,
collective_id=collective_id,
parent_id=page.parentId,
)
@mcp.tool(
title="Move Collective Page",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("collectives:write")
@instrument_tool
async def collectives_move_page(
ctx: Context,
collective_id: int,
page_id: int,
parent_id: int | None = None,
title: str | None = None,
index: int = 0,
copy: bool = False,
) -> PageOperationResponse:
"""Move or copy a page within a Nextcloud Collective
Args:
collective_id: ID of the collective
page_id: ID of the page to move/copy
parent_id: Target parent page ID
title: New title (optional)
index: Position in subpage order (default 0)
copy: If true, copy instead of move
"""
client = await get_client(ctx)
try:
raw = await client.collectives.move_page(
collective_id, page_id, parent_id, title, index, copy
)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
page = PageInfo(**raw)
action = "copied" if copy else "moved"
return PageOperationResponse(
page_id=page.id,
collective_id=collective_id,
status_code=200,
message=f"Page {action} (title: {page.title}, parent: {page.parentId})",
)
@mcp.tool(
title="Trash Collective Page",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("collectives:write")
@instrument_tool
async def collectives_trash_page(
ctx: Context, collective_id: int, page_id: int
) -> PageOperationResponse:
"""Move a page to trash in a Nextcloud Collective (soft delete).
Trashed pages can be restored with collectives_restore_page. The
Collectives API does not support permanent page deletion; trashed
pages are cleaned up by Nextcloud's retention policy.
Args:
collective_id: ID of the collective
page_id: ID of the page to trash
"""
client = await get_client(ctx)
try:
await client.collectives.trash_page(collective_id, page_id)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
return PageOperationResponse(
page_id=page_id,
collective_id=collective_id,
status_code=200,
message="Page moved to trash",
)
@mcp.tool(
title="Restore Collective Page",
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
)
@require_scopes("collectives:write")
@instrument_tool
async def collectives_restore_page(
ctx: Context, collective_id: int, page_id: int
) -> PageOperationResponse:
"""Restore a page from trash in a Nextcloud Collective
Args:
collective_id: ID of the collective
page_id: ID of the page to restore
"""
client = await get_client(ctx)
try:
raw = await client.collectives.restore_page(collective_id, page_id)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
page = PageInfo(**raw)
return PageOperationResponse(
page_id=page.id,
collective_id=collective_id,
status_code=200,
message=f"Page restored from trash (title: {page.title})",
)
@mcp.tool(
title="Set Collective Page Emoji",
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
)
@require_scopes("collectives:write")
@instrument_tool
async def collectives_set_page_emoji(
ctx: Context,
collective_id: int,
page_id: int,
emoji: str | None = None,
) -> PageOperationResponse:
"""Set or clear the emoji on a Nextcloud Collective page
Args:
collective_id: ID of the collective
page_id: ID of the page
emoji: Emoji to set, or null to clear
"""
client = await get_client(ctx)
try:
raw = await client.collectives.set_page_emoji(collective_id, page_id, emoji)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
page = PageInfo(**raw)
return PageOperationResponse(
page_id=page.id,
collective_id=collective_id,
status_code=200,
message=f"Page emoji updated (emoji: {page.emoji})",
)
@mcp.tool(
title="Create Collective Tag",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("collectives:write")
@instrument_tool
async def collectives_create_tag(
ctx: Context, collective_id: int, name: str, color: str
) -> CreateTagResponse:
"""Create a new tag in a Nextcloud Collective
Args:
collective_id: ID of the collective
name: Tag name
color: Hex color code (e.g. "FF0000")
"""
client = await get_client(ctx)
try:
raw = await client.collectives.create_tag(collective_id, name, color)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
tag = CollectiveTag(**raw)
return CreateTagResponse(id=tag.id, name=tag.name, color=tag.color)
@mcp.tool(
title="Assign Tag to Collective Page",
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
)
@require_scopes("collectives:write")
@instrument_tool
async def collectives_assign_tag(
ctx: Context, collective_id: int, page_id: int, tag_id: int
) -> PageOperationResponse:
"""Assign a tag to a page in a Nextcloud Collective
Args:
collective_id: ID of the collective
page_id: ID of the page
tag_id: ID of the tag to assign
"""
client = await get_client(ctx)
try:
await client.collectives.assign_tag(collective_id, page_id, tag_id)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
return PageOperationResponse(
page_id=page_id,
collective_id=collective_id,
status_code=200,
message=f"Tag {tag_id} assigned to page",
)
@mcp.tool(
title="Remove Tag from Collective Page",
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
)
@require_scopes("collectives:write")
@instrument_tool
async def collectives_remove_tag(
ctx: Context, collective_id: int, page_id: int, tag_id: int
) -> PageOperationResponse:
"""Remove a tag from a page in a Nextcloud Collective.
This is a reversible operation — the tag still exists and can be
reassigned with collectives_assign_tag.
Args:
collective_id: ID of the collective
page_id: ID of the page
tag_id: ID of the tag to remove
"""
client = await get_client(ctx)
try:
await client.collectives.remove_tag(collective_id, page_id, tag_id)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
return PageOperationResponse(
page_id=page_id,
collective_id=collective_id,
status_code=200,
message=f"Tag {tag_id} removed from page",
)
+1 -3
View File
@@ -641,9 +641,7 @@ def configure_deck_tools(mcp: FastMCP):
@mcp.tool( @mcp.tool(
title="Remove Label from Deck Card", title="Remove Label from Deck Card",
annotations=ToolAnnotations( annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
) )
@require_scopes("deck:write") @require_scopes("deck:write")
@instrument_tool @instrument_tool
+1
View File
@@ -136,6 +136,7 @@ dev = [
"ruff>=0.11.13", "ruff>=0.11.13",
"reportlab>=4.0.0", "reportlab>=4.0.0",
"ty>=0.0.1a25", "ty>=0.0.1a25",
"pytest-otel>=2.0.1",
] ]
[project.scripts] [project.scripts]
@@ -0,0 +1,537 @@
"""Unit tests for CollectivesClient API methods."""
import httpx
import pytest
from nextcloud_mcp_server.client.collectives import CollectivesClient, OCSError
from tests.client.conftest import create_mock_response
pytestmark = pytest.mark.unit
# --- OCS mock helpers ---
def _ocs_response(data: dict | list) -> httpx.Response:
"""Wrap data in an OCS envelope and return as mock response."""
return create_mock_response(
status_code=200,
json_data={"ocs": {"meta": {"status": "ok", "statuscode": 200}, "data": data}},
)
def _sample_collective(
collective_id: int = 1, name: str = "Test Wiki", emoji: str | None = None
) -> dict:
return {
"id": collective_id,
"circleId": "circle-abc",
"emoji": emoji,
"name": name,
"level": 9,
"canEdit": True,
"canShare": True,
"pageMode": 0,
}
def _sample_page(
page_id: int = 10,
title: str = "Test Page",
parent_id: int = 0,
collective_path: str = "Collectives/Test Wiki",
) -> dict:
return {
"id": page_id,
"title": title,
"emoji": None,
"fileName": f"{title}.md",
"filePath": "",
"collectivePath": collective_path,
"parentId": parent_id,
"timestamp": 1700000000,
"size": 42,
"lastUserId": "testuser",
"lastUserDisplayName": "Test User",
"subpageOrder": [],
"isFullWidth": False,
}
def _sample_tag(
tag_id: int = 1, name: str = "important", color: str = "FF0000"
) -> dict:
return {
"id": tag_id,
"collectiveId": 1,
"name": name,
"color": color,
}
# --- Collectives ---
async def test_get_collectives(mocker):
"""Test listing collectives unwraps OCS envelope correctly."""
mock_response = _ocs_response(
{
"collectives": [
_sample_collective(1, "Wiki A"),
_sample_collective(2, "Wiki B"),
]
}
)
mocker.patch.object(CollectivesClient, "_make_request", return_value=mock_response)
client = CollectivesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
result = await client.get_collectives()
assert isinstance(result, list)
assert len(result) == 2
assert result[0]["id"] == 1
assert result[1]["name"] == "Wiki B"
async def test_create_collective(mocker):
"""Test creating a collective sends name and emoji."""
mock_response = _ocs_response(
{"collective": _sample_collective(5, "New Wiki", "📚")}
)
mock_request = mocker.patch.object(
CollectivesClient, "_make_request", return_value=mock_response
)
client = CollectivesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
result = await client.create_collective("New Wiki", emoji="📚")
assert result["id"] == 5
assert result["name"] == "New Wiki"
assert result["emoji"] == "📚"
call_args = mock_request.call_args
assert call_args[0][0] == "POST"
assert call_args[1]["json"]["name"] == "New Wiki"
assert call_args[1]["json"]["emoji"] == "📚"
async def test_trash_collective(mocker):
"""Test trashing a collective sends DELETE to correct endpoint."""
mock_response = _ocs_response({})
mock_request = mocker.patch.object(
CollectivesClient, "_make_request", return_value=mock_response
)
client = CollectivesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
await client.trash_collective(collective_id=5)
call_args = mock_request.call_args
assert call_args[0][0] == "DELETE"
assert "/collectives/5" in call_args[0][1]
assert "/trash" not in call_args[0][1]
async def test_delete_collective(mocker):
"""Test permanently deleting a collective sends DELETE to trash endpoint."""
mock_response = _ocs_response({})
mock_request = mocker.patch.object(
CollectivesClient, "_make_request", return_value=mock_response
)
client = CollectivesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
await client.delete_collective(collective_id=5)
call_args = mock_request.call_args
assert call_args[0][0] == "DELETE"
assert "/collectives/trash/5" in call_args[0][1]
# --- Pages ---
async def test_get_pages(mocker):
"""Test listing pages in a collective."""
mock_response = _ocs_response(
{"pages": [_sample_page(10, "Page A"), _sample_page(20, "Page B")]}
)
mocker.patch.object(CollectivesClient, "_make_request", return_value=mock_response)
client = CollectivesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
result = await client.get_pages(collective_id=1)
assert len(result) == 2
assert result[0]["title"] == "Page A"
assert result[1]["id"] == 20
async def test_get_page(mocker):
"""Test getting a single page metadata."""
mock_response = _ocs_response({"page": _sample_page(10, "My Page")})
mock_request = mocker.patch.object(
CollectivesClient, "_make_request", return_value=mock_response
)
client = CollectivesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
result = await client.get_page(collective_id=1, page_id=10)
assert result["id"] == 10
assert result["title"] == "My Page"
assert result["collectivePath"] == "Collectives/Test Wiki"
call_args = mock_request.call_args
assert "/collectives/1/pages/10" in call_args[0][1]
async def test_create_page(mocker):
"""Test creating a page under a parent."""
mock_response = _ocs_response({"page": _sample_page(30, "New Page", parent_id=10)})
mock_request = mocker.patch.object(
CollectivesClient, "_make_request", return_value=mock_response
)
client = CollectivesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
result = await client.create_page(collective_id=1, parent_id=10, title="New Page")
assert result["id"] == 30
assert result["parentId"] == 10
call_args = mock_request.call_args
assert call_args[0][0] == "POST"
assert "/collectives/1/pages/10" in call_args[0][1]
assert call_args[1]["json"]["title"] == "New Page"
async def test_trash_page(mocker):
"""Test trashing a page sends DELETE."""
mock_response = _ocs_response({})
mock_request = mocker.patch.object(
CollectivesClient, "_make_request", return_value=mock_response
)
client = CollectivesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
await client.trash_page(collective_id=1, page_id=10)
call_args = mock_request.call_args
assert call_args[0][0] == "DELETE"
assert "/collectives/1/pages/10" in call_args[0][1]
async def test_move_page(mocker):
"""Test moving a page sends PUT with correct params."""
mock_response = _ocs_response(
{"page": _sample_page(10, "Moved Page", parent_id=20)}
)
mock_request = mocker.patch.object(
CollectivesClient, "_make_request", return_value=mock_response
)
client = CollectivesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
result = await client.move_page(
collective_id=1, page_id=10, parent_id=20, title="Moved Page"
)
assert result["parentId"] == 20
call_args = mock_request.call_args
assert call_args[0][0] == "PUT"
assert call_args[1]["json"]["parentId"] == 20
# --- Search ---
async def test_search_pages(mocker):
"""Test full-text search sends query parameter."""
mock_response = _ocs_response({"pages": [_sample_page(10, "Result Page")]})
mock_request = mocker.patch.object(
CollectivesClient, "_make_request", return_value=mock_response
)
client = CollectivesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
result = await client.search_pages(collective_id=1, query="test query")
assert len(result) == 1
assert result[0]["title"] == "Result Page"
call_args = mock_request.call_args
assert call_args[1]["params"]["searchString"] == "test query"
# --- Tags ---
async def test_get_tags(mocker):
"""Test listing tags."""
mock_response = _ocs_response(
{"tags": [_sample_tag(1, "important"), _sample_tag(2, "draft", "00FF00")]}
)
mocker.patch.object(CollectivesClient, "_make_request", return_value=mock_response)
client = CollectivesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
result = await client.get_tags(collective_id=1)
assert len(result) == 2
assert result[0]["name"] == "important"
assert result[1]["color"] == "00FF00"
async def test_create_tag(mocker):
"""Test creating a tag."""
mock_response = _ocs_response({"tag": _sample_tag(3, "review", "0000FF")})
mock_request = mocker.patch.object(
CollectivesClient, "_make_request", return_value=mock_response
)
client = CollectivesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
result = await client.create_tag(collective_id=1, name="review", color="0000FF")
assert result["id"] == 3
assert result["name"] == "review"
call_args = mock_request.call_args
assert call_args[0][0] == "POST"
assert call_args[1]["json"]["name"] == "review"
async def test_assign_tag(mocker):
"""Test assigning a tag to a page."""
mock_response = _ocs_response({})
mock_request = mocker.patch.object(
CollectivesClient, "_make_request", return_value=mock_response
)
client = CollectivesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
await client.assign_tag(collective_id=1, page_id=10, tag_id=3)
call_args = mock_request.call_args
assert call_args[0][0] == "PUT"
assert "/pages/10/tags/3" in call_args[0][1]
async def test_remove_tag(mocker):
"""Test removing a tag from a page."""
mock_response = _ocs_response({})
mock_request = mocker.patch.object(
CollectivesClient, "_make_request", return_value=mock_response
)
client = CollectivesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
await client.remove_tag(collective_id=1, page_id=10, tag_id=3)
call_args = mock_request.call_args
assert call_args[0][0] == "DELETE"
assert "/pages/10/tags/3" in call_args[0][1]
# --- Trash ---
async def test_get_trashed_pages(mocker):
"""Test listing trashed pages."""
trashed = _sample_page(10, "Trashed Page")
trashed["trashTimestamp"] = 1700000000
mock_response = _ocs_response({"pages": [trashed]})
mocker.patch.object(CollectivesClient, "_make_request", return_value=mock_response)
client = CollectivesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
result = await client.get_trashed_pages(collective_id=1)
assert len(result) == 1
assert result[0]["title"] == "Trashed Page"
async def test_restore_page(mocker):
"""Test restoring a page from trash."""
mock_response = _ocs_response({"page": _sample_page(10, "Restored Page")})
mock_request = mocker.patch.object(
CollectivesClient, "_make_request", return_value=mock_response
)
client = CollectivesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
result = await client.restore_page(collective_id=1, page_id=10)
assert result["title"] == "Restored Page"
call_args = mock_request.call_args
assert call_args[0][0] == "PATCH"
assert "/pages/trash/10" in call_args[0][1]
# --- Error Handling ---
async def test_ocs_missing_data_raises_ocs_error(mocker):
"""Test that OCS envelope without 'data' key raises OCSError."""
mock_response = create_mock_response(
status_code=200,
json_data={
"ocs": {
"meta": {"status": "ok", "statuscode": 200},
}
},
)
mocker.patch.object(CollectivesClient, "_make_request", return_value=mock_response)
client = CollectivesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
with pytest.raises(OCSError, match="missing 'data' field"):
await client.get_collectives()
async def test_non_ocs_envelope_raises_ocs_error(mocker):
"""Test that a non-OCS response (e.g. proxy error) raises OCSError."""
mock_response = create_mock_response(
status_code=200,
json_data={"error": "Bad Gateway"},
)
mocker.patch.object(CollectivesClient, "_make_request", return_value=mock_response)
client = CollectivesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
with pytest.raises(OCSError, match="not an OCS envelope"):
await client.get_collectives()
async def test_ocs_error_status_raises(mocker):
"""Test that OCS envelope with error statuscode raises OCSError."""
mock_response = create_mock_response(
status_code=200,
json_data={
"ocs": {
"meta": {
"status": "failure",
"statuscode": 403,
"message": "Not permitted",
},
"data": {},
}
},
)
mocker.patch.object(CollectivesClient, "_make_request", return_value=mock_response)
client = CollectivesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
with pytest.raises(OCSError, match="Not permitted"):
await client.get_collectives()
async def test_get_collectives_403(mocker):
"""Test 403 response raises HTTPStatusError."""
mock_response = create_mock_response(
status_code=403, json_data={"message": "Forbidden"}
)
mock_response.raise_for_status = lambda: (_ for _ in ()).throw(
httpx.HTTPStatusError(
"Forbidden", request=mock_response.request, response=mock_response
)
)
mocker.patch.object(
CollectivesClient,
"_make_request",
side_effect=httpx.HTTPStatusError(
"Forbidden",
request=httpx.Request("GET", "http://test"),
response=mock_response,
),
)
client = CollectivesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
with pytest.raises(httpx.HTTPStatusError):
await client.get_collectives()
async def test_get_page_404(mocker):
"""Test 404 response raises HTTPStatusError."""
mock_response = create_mock_response(
status_code=404, json_data={"message": "Not found"}
)
mocker.patch.object(
CollectivesClient,
"_make_request",
side_effect=httpx.HTTPStatusError(
"Not Found",
request=httpx.Request("GET", "http://test"),
response=mock_response,
),
)
client = CollectivesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
with pytest.raises(httpx.HTTPStatusError):
await client.get_page(collective_id=1, page_id=999)
# --- Additional coverage ---
async def test_update_collective_no_fields_raises_value_error(mocker):
"""Test that update_collective raises ValueError when called with no fields."""
client = CollectivesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
# Omitting emoji entirely (not passing it) should raise
with pytest.raises(ValueError, match="At least one field"):
await client.update_collective(collective_id=1)
async def test_update_collective_clear_emoji(mocker):
"""Test that update_collective sends null emoji to clear it."""
mock_response = _ocs_response(
{"collective": _sample_collective(1, "Test Wiki", emoji=None)}
)
mock_request = mocker.patch.object(
CollectivesClient, "_make_request", return_value=mock_response
)
client = CollectivesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
result = await client.update_collective(collective_id=1, emoji=None)
assert result["emoji"] is None
call_args = mock_request.call_args
assert call_args[1]["json"] == {"emoji": None}
async def test_set_page_emoji_clear(mocker):
"""Test that set_page_emoji sends null emoji to clear it."""
page = _sample_page(10, "Test Page")
page["emoji"] = None
mock_response = _ocs_response({"page": page})
mock_request = mocker.patch.object(
CollectivesClient, "_make_request", return_value=mock_response
)
client = CollectivesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
result = await client.set_page_emoji(collective_id=1, page_id=10, emoji=None)
assert result["emoji"] is None
call_args = mock_request.call_args
assert call_args[1]["json"] == {"emoji": None}
async def test_get_trashed_collectives(mocker):
"""Test listing trashed collectives."""
mock_response = _ocs_response(
{"collectives": [_sample_collective(1, "Trashed Wiki")]}
)
mock_request = mocker.patch.object(
CollectivesClient, "_make_request", return_value=mock_response
)
client = CollectivesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
result = await client.get_trashed_collectives()
assert len(result) == 1
assert result[0]["name"] == "Trashed Wiki"
call_args = mock_request.call_args
assert "/collectives/trash" in call_args[0][1]
assert call_args[0][0] == "GET"
async def test_restore_collective(mocker):
"""Test restoring a collective from trash."""
mock_response = _ocs_response(
{"collective": _sample_collective(5, "Restored Wiki")}
)
mock_request = mocker.patch.object(
CollectivesClient, "_make_request", return_value=mock_response
)
client = CollectivesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
result = await client.restore_collective(collective_id=5)
assert result["name"] == "Restored Wiki"
call_args = mock_request.call_args
assert call_args[0][0] == "PATCH"
assert "/collectives/trash/5" in call_args[0][1]
+8 -3
View File
@@ -58,8 +58,9 @@ async def test_destructive_tools_have_correct_annotations(nc_mcp_client: ClientS
"""Verify destructive operations are marked correctly.""" """Verify destructive operations are marked correctly."""
tools = await nc_mcp_client.list_tools() tools = await nc_mcp_client.list_tools()
# Known destructive operations # Known destructive operations (permanently delete data).
destructive_keywords = ["delete", "remove", "revoke"] # "remove" is excluded — removing associations (labels, tags) is reversible.
destructive_keywords = ["delete", "revoke"]
for tool in tools.tools: for tool in tools.tools:
has_destructive_keyword = any( has_destructive_keyword = any(
@@ -77,8 +78,12 @@ async def test_delete_operations_are_idempotent(nc_mcp_client: ClientSession):
"""Verify delete operations are marked as idempotent (ADR-017 decision).""" """Verify delete operations are marked as idempotent (ADR-017 decision)."""
tools = await nc_mcp_client.list_tools() tools = await nc_mcp_client.list_tools()
# Exceptions: delete operations that require a precondition (e.g. must be
# trashed first), so calling twice produces an error on the second call.
non_idempotent_deletes = {"collectives_delete_collective"}
for tool in tools.tools: for tool in tools.tools:
if "delete" in tool.name.lower(): if "delete" in tool.name.lower() and tool.name not in non_idempotent_deletes:
assert tool.annotations is not None, f"Tool {tool.name} missing annotations" assert tool.annotations is not None, f"Tool {tool.name} missing annotations"
assert tool.annotations.idempotentHint is True, ( assert tool.annotations.idempotentHint is True, (
f"Delete tool {tool.name} should be idempotent (same end state)" f"Delete tool {tool.name} should be idempotent (same end state)"
+497
View File
@@ -0,0 +1,497 @@
"""Integration tests for Nextcloud Collectives MCP tools."""
import json
import logging
import uuid
import pytest
from mcp import ClientSession
logger = logging.getLogger(__name__)
pytestmark = pytest.mark.integration
# --- Fixtures ---
@pytest.fixture(scope="session")
async def temporary_collective(nc_mcp_client: ClientSession):
"""Create a temporary collective for testing. Cleaned up after session."""
unique_suffix = uuid.uuid4().hex[:8]
name = f"MCP Test Collective {unique_suffix}"
result = await nc_mcp_client.call_tool(
"collectives_create_collective",
{"name": name, "emoji": "🧪"},
)
assert result.isError is False, f"Failed to create collective: {result.content}"
data = json.loads(result.content[0].text)
collective_id = data["id"]
logger.info(f"Created temporary collective: {name} (ID: {collective_id})")
# Get the landing page ID — filter by parentId == 0 (root page)
pages_result = await nc_mcp_client.call_tool(
"collectives_get_pages",
{"collective_id": collective_id},
)
pages_data = json.loads(pages_result.content[0].text)
root_pages = [p for p in pages_data["pages"] if p["parentId"] == 0]
assert root_pages, "Expected at least one root page (landing page)"
landing_page_id = root_pages[0]["id"]
yield {
"id": collective_id,
"name": name,
"landing_page_id": landing_page_id,
}
# Cleanup: trash and permanently delete the collective via MCP tools
try:
await nc_mcp_client.call_tool(
"collectives_trash_collective",
{"collective_id": collective_id},
)
await nc_mcp_client.call_tool(
"collectives_delete_collective",
{"collective_id": collective_id},
)
logger.info(f"Cleaned up collective: {collective_id}")
except Exception as e:
logger.warning(f"Cleanup of collective {collective_id} failed: {e}")
# --- Tool Discovery ---
async def test_collectives_tools_available(nc_mcp_client: ClientSession):
"""Verify all Collectives MCP tools are registered."""
tools = await nc_mcp_client.list_tools()
tool_names = [tool.name for tool in tools.tools]
expected_tools = [
"collectives_get_collectives",
"collectives_create_collective",
"collectives_set_collective_emoji",
"collectives_trash_collective",
"collectives_delete_collective",
"collectives_get_pages",
"collectives_get_page",
"collectives_create_page",
"collectives_move_page",
"collectives_trash_page",
"collectives_restore_page",
"collectives_set_page_emoji",
"collectives_search_pages",
"collectives_get_tags",
"collectives_create_tag",
"collectives_assign_tag",
"collectives_remove_tag",
"collectives_get_trashed_pages",
"collectives_get_trashed_collectives",
"collectives_restore_collective",
]
for expected in expected_tools:
assert expected in tool_names, (
f"Expected tool '{expected}' not found in available tools"
)
logger.info(f"All {len(expected_tools)} Collectives tools registered")
# --- Collective CRUD ---
async def test_collectives_list(
nc_mcp_client: ClientSession, temporary_collective: dict
):
"""Test listing collectives includes the temporary one."""
result = await nc_mcp_client.call_tool("collectives_get_collectives", {})
assert result.isError is False
data = json.loads(result.content[0].text)
assert data["success"] is True
assert data["total"] >= 1
collective_ids = [c["id"] for c in data["collectives"]]
assert temporary_collective["id"] in collective_ids
logger.info(f"Found {data['total']} collectives")
async def test_collectives_set_collective_emoji(
nc_mcp_client: ClientSession, temporary_collective: dict
):
"""Test setting a collective's emoji."""
result = await nc_mcp_client.call_tool(
"collectives_set_collective_emoji",
{"collective_id": temporary_collective["id"], "emoji": "📖"},
)
assert result.isError is False
data = json.loads(result.content[0].text)
assert data["success"] is True
assert data["collective_id"] == temporary_collective["id"]
logger.info("Collective emoji updated")
async def test_collectives_clear_collective_emoji(
nc_mcp_client: ClientSession, temporary_collective: dict
):
"""Test clearing a collective's emoji by passing null."""
cid = temporary_collective["id"]
# Set an emoji first
set_result = await nc_mcp_client.call_tool(
"collectives_set_collective_emoji",
{"collective_id": cid, "emoji": "🔬"},
)
assert set_result.isError is False
# Clear the emoji by passing null
clear_result = await nc_mcp_client.call_tool(
"collectives_set_collective_emoji",
{"collective_id": cid, "emoji": None},
)
assert clear_result.isError is False
data = json.loads(clear_result.content[0].text)
assert data["collective_id"] == cid
logger.info("Collective emoji cleared")
# --- Page CRUD ---
async def test_collectives_page_workflow(
nc_mcp_client: ClientSession, temporary_collective: dict
):
"""Test the full page lifecycle: create, read, set emoji, trash, restore."""
cid = temporary_collective["id"]
landing_id = temporary_collective["landing_page_id"]
# 1. Create a page
unique_title = f"Test Page {uuid.uuid4().hex[:8]}"
create_result = await nc_mcp_client.call_tool(
"collectives_create_page",
{"collective_id": cid, "parent_id": landing_id, "title": unique_title},
)
assert create_result.isError is False
create_data = json.loads(create_result.content[0].text)
page_id = create_data["id"]
assert create_data["collective_id"] == cid
assert create_data["parent_id"] == landing_id
logger.info(f"Created page: {unique_title} (ID: {page_id})")
# 2. List pages — should include the new page
list_result = await nc_mcp_client.call_tool(
"collectives_get_pages",
{"collective_id": cid},
)
assert list_result.isError is False
list_data = json.loads(list_result.content[0].text)
page_ids = [p["id"] for p in list_data["pages"]]
assert page_id in page_ids
logger.info(f"Page found in list ({list_data['total']} pages)")
# 3. Get page with content
get_result = await nc_mcp_client.call_tool(
"collectives_get_page",
{"collective_id": cid, "page_id": page_id},
)
assert get_result.isError is False
get_data = json.loads(get_result.content[0].text)
assert get_data["page"]["id"] == page_id
assert get_data["page"]["title"] == unique_title
# New pages have empty content (empty string or None)
logger.info("Page metadata retrieved")
# 4. Set page emoji
emoji_result = await nc_mcp_client.call_tool(
"collectives_set_page_emoji",
{"collective_id": cid, "page_id": page_id, "emoji": "🚀"},
)
assert emoji_result.isError is False
logger.info("Page emoji set")
# 5. Trash the page
trash_result = await nc_mcp_client.call_tool(
"collectives_trash_page",
{"collective_id": cid, "page_id": page_id},
)
assert trash_result.isError is False
logger.info("Page trashed")
# 6. Verify page is in trash
trashed_result = await nc_mcp_client.call_tool(
"collectives_get_trashed_pages",
{"collective_id": cid},
)
assert trashed_result.isError is False
trashed_data = json.loads(trashed_result.content[0].text)
trashed_ids = [p["id"] for p in trashed_data["pages"]]
assert page_id in trashed_ids
logger.info("Page found in trash")
# 7. Restore from trash
restore_result = await nc_mcp_client.call_tool(
"collectives_restore_page",
{"collective_id": cid, "page_id": page_id},
)
assert restore_result.isError is False
logger.info("Page restored from trash")
# 8. Verify page is back in pages list
list_result2 = await nc_mcp_client.call_tool(
"collectives_get_pages",
{"collective_id": cid},
)
list_data2 = json.loads(list_result2.content[0].text)
page_ids2 = [p["id"] for p in list_data2["pages"]]
assert page_id in page_ids2
logger.info("Page verified restored to pages list")
async def test_collectives_get_landing_page_content(
nc_mcp_client: ClientSession, temporary_collective: dict
):
"""Test that the landing page has auto-generated content readable via WebDAV."""
cid = temporary_collective["id"]
landing_id = temporary_collective["landing_page_id"]
result = await nc_mcp_client.call_tool(
"collectives_get_page",
{"collective_id": cid, "page_id": landing_id},
)
assert result.isError is False
data = json.loads(result.content[0].text)
assert data["page"]["fileName"] == "Readme.md"
assert data["content"] is not None, (
"Landing page should have auto-generated content"
)
assert len(data["content"]) > 0, "Landing page should have non-empty content"
logger.info(f"Landing page content: {len(data['content'])} bytes")
async def test_collectives_move_page(
nc_mcp_client: ClientSession, temporary_collective: dict
):
"""Test moving a page (rename)."""
cid = temporary_collective["id"]
landing_id = temporary_collective["landing_page_id"]
# Create a page to move
create_result = await nc_mcp_client.call_tool(
"collectives_create_page",
{
"collective_id": cid,
"parent_id": landing_id,
"title": f"Movable Page {uuid.uuid4().hex[:8]}",
},
)
assert create_result.isError is False
page_id = json.loads(create_result.content[0].text)["id"]
# Move (rename) the page
new_title = f"Renamed Page {uuid.uuid4().hex[:8]}"
move_result = await nc_mcp_client.call_tool(
"collectives_move_page",
{
"collective_id": cid,
"page_id": page_id,
"title": new_title,
},
)
assert move_result.isError is False
data = json.loads(move_result.content[0].text)
assert data["page_id"] == page_id
assert "moved" in data["message"]
assert new_title in data["message"]
logger.info(f"Page renamed to: {new_title}")
# Cleanup
await nc_mcp_client.call_tool(
"collectives_trash_page",
{"collective_id": cid, "page_id": page_id},
)
# --- Tags ---
async def test_collectives_tag_workflow(
nc_mcp_client: ClientSession, temporary_collective: dict
):
"""Test tag lifecycle: create tag, assign to page, remove from page."""
cid = temporary_collective["id"]
landing_id = temporary_collective["landing_page_id"]
# 1. Create a tag
tag_name = f"test-tag-{uuid.uuid4().hex[:6]}"
create_tag_result = await nc_mcp_client.call_tool(
"collectives_create_tag",
{"collective_id": cid, "name": tag_name, "color": "FF5733"},
)
assert create_tag_result.isError is False
tag_data = json.loads(create_tag_result.content[0].text)
tag_id = tag_data["id"]
assert tag_data["name"] == tag_name
assert tag_data["color"] == "FF5733"
logger.info(f"Created tag: {tag_name} (ID: {tag_id})")
# 2. List tags — should include the new tag
list_tags_result = await nc_mcp_client.call_tool(
"collectives_get_tags",
{"collective_id": cid},
)
assert list_tags_result.isError is False
tags_data = json.loads(list_tags_result.content[0].text)
tag_ids = [t["id"] for t in tags_data["tags"]]
assert tag_id in tag_ids
logger.info(f"Tag found in list ({tags_data['total']} tags)")
# 3. Create a page to tag
page_result = await nc_mcp_client.call_tool(
"collectives_create_page",
{
"collective_id": cid,
"parent_id": landing_id,
"title": f"Tagged Page {uuid.uuid4().hex[:8]}",
},
)
assert page_result.isError is False
page_id = json.loads(page_result.content[0].text)["id"]
# 4. Assign tag to page
assign_result = await nc_mcp_client.call_tool(
"collectives_assign_tag",
{"collective_id": cid, "page_id": page_id, "tag_id": tag_id},
)
assert assign_result.isError is False
logger.info(f"Tag {tag_id} assigned to page {page_id}")
# 5. Remove tag from page
remove_result = await nc_mcp_client.call_tool(
"collectives_remove_tag",
{"collective_id": cid, "page_id": page_id, "tag_id": tag_id},
)
assert remove_result.isError is False
logger.info(f"Tag {tag_id} removed from page {page_id}")
# Cleanup
await nc_mcp_client.call_tool(
"collectives_trash_page",
{"collective_id": cid, "page_id": page_id},
)
# --- Search ---
async def test_collectives_search(
nc_mcp_client: ClientSession, temporary_collective: dict
):
"""Test full-text search within a collective."""
cid = temporary_collective["id"]
# Search for text in the landing page (contains "Welcome")
result = await nc_mcp_client.call_tool(
"collectives_search_pages",
{"collective_id": cid, "query": "Welcome"},
)
assert result.isError is False
data = json.loads(result.content[0].text)
assert data["success"] is True
assert data["query"] == "Welcome"
assert data["collective_id"] == cid
# Search may or may not find results depending on indexing timing
logger.info(f"Search returned {data['total']} results for 'Welcome'")
# --- Collective Trash / Restore / Delete ---
async def test_collectives_trash_restore_delete_workflow(
nc_mcp_client: ClientSession,
):
"""Test the full collective lifecycle: create, trash, list trashed, restore, trash, delete."""
# Create a throwaway collective
name = f"Lifecycle Test {uuid.uuid4().hex[:8]}"
create_result = await nc_mcp_client.call_tool(
"collectives_create_collective",
{"name": name},
)
assert create_result.isError is False
created = json.loads(create_result.content[0].text)
cid = created["id"]
logger.info(f"Created collective {name} (ID: {cid})")
# Trash the collective
trash_result = await nc_mcp_client.call_tool(
"collectives_trash_collective",
{"collective_id": cid},
)
assert trash_result.isError is False
logger.info("Collective moved to trash")
# List trashed collectives — should include ours
list_trash_result = await nc_mcp_client.call_tool(
"collectives_get_trashed_collectives",
{},
)
assert list_trash_result.isError is False
trash_data = json.loads(list_trash_result.content[0].text)
trashed_ids = [c["id"] for c in trash_data["collectives"]]
assert cid in trashed_ids
logger.info(f"Found {trash_data['total']} trashed collectives")
# Restore the collective
restore_result = await nc_mcp_client.call_tool(
"collectives_restore_collective",
{"collective_id": cid},
)
assert restore_result.isError is False
restore_data = json.loads(restore_result.content[0].text)
assert restore_data["collective_id"] == cid
assert "restored" in restore_data["message"].lower()
logger.info("Collective restored from trash")
# Trash again, then permanently delete
trash_result2 = await nc_mcp_client.call_tool(
"collectives_trash_collective",
{"collective_id": cid},
)
assert trash_result2.isError is False
delete_result = await nc_mcp_client.call_tool(
"collectives_delete_collective",
{"collective_id": cid},
)
assert delete_result.isError is False
delete_data = json.loads(delete_result.content[0].text)
assert "permanently deleted" in delete_data["message"].lower()
logger.info("Collective permanently deleted")
# --- Error Handling ---
async def test_collectives_get_page_not_found(nc_mcp_client: ClientSession):
"""Test getting a non-existent page returns an error."""
result = await nc_mcp_client.call_tool(
"collectives_get_page",
{"collective_id": 999999, "page_id": 999999},
)
assert result.isError is True
logger.info("Non-existent page correctly returned error")
async def test_collectives_get_pages_not_found(nc_mcp_client: ClientSession):
"""Test listing pages for a non-existent collective returns an error."""
result = await nc_mcp_client.call_tool(
"collectives_get_pages",
{"collective_id": 999999},
)
assert result.isError is True
logger.info("Non-existent collective correctly returned error")
Generated
+48
View File
@@ -2132,6 +2132,7 @@ dev = [
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-cov" }, { name = "pytest-cov" },
{ name = "pytest-mock" }, { name = "pytest-mock" },
{ name = "pytest-otel" },
{ name = "pytest-playwright-asyncio" }, { name = "pytest-playwright-asyncio" },
{ name = "pytest-timeout" }, { name = "pytest-timeout" },
{ name = "reportlab" }, { name = "reportlab" },
@@ -2184,6 +2185,7 @@ dev = [
{ name = "pytest", specifier = ">=8.3.5" }, { name = "pytest", specifier = ">=8.3.5" },
{ name = "pytest-cov", specifier = ">=6.1.1" }, { name = "pytest-cov", specifier = ">=6.1.1" },
{ name = "pytest-mock", specifier = ">=3.15.1" }, { name = "pytest-mock", specifier = ">=3.15.1" },
{ name = "pytest-otel", specifier = ">=2.0.1" },
{ name = "pytest-playwright-asyncio", specifier = ">=0.7.1" }, { name = "pytest-playwright-asyncio", specifier = ">=0.7.1" },
{ name = "pytest-timeout", specifier = ">=2.3.1" }, { name = "pytest-timeout", specifier = ">=2.3.1" },
{ name = "reportlab", specifier = ">=4.0.0" }, { name = "reportlab", specifier = ">=4.0.0" },
@@ -2350,6 +2352,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/05/85/d831a9bc0a9e0e1a304ff3d12c1489a5fbc9bf6690a15dcbdae372bbca45/opentelemetry_api-1.39.0-py3-none-any.whl", hash = "sha256:3c3b3ca5c5687b1b5b37e5c5027ff68eacea8675241b29f13110a8ffbb8f0459", size = 66357, upload-time = "2025-12-03T13:19:33.043Z" }, { url = "https://files.pythonhosted.org/packages/05/85/d831a9bc0a9e0e1a304ff3d12c1489a5fbc9bf6690a15dcbdae372bbca45/opentelemetry_api-1.39.0-py3-none-any.whl", hash = "sha256:3c3b3ca5c5687b1b5b37e5c5027ff68eacea8675241b29f13110a8ffbb8f0459", size = 66357, upload-time = "2025-12-03T13:19:33.043Z" },
] ]
[[package]]
name = "opentelemetry-exporter-otlp"
version = "1.39.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-exporter-otlp-proto-grpc" },
{ name = "opentelemetry-exporter-otlp-proto-http" },
]
sdist = { url = "https://files.pythonhosted.org/packages/13/be/0e9d889f47e55cadc4041e5b53d4e0cc688f9a74811134fb0ba7cbee6905/opentelemetry_exporter_otlp-1.39.0.tar.gz", hash = "sha256:b405da0287b895fe4e2450dedb2a5b072debba1dfcfed5bdb3d1d183d8daa296", size = 6146, upload-time = "2025-12-03T13:19:58.381Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/35/212d2cae4fa9a2c02e74438612268b640ab577b8ccb04590371eb4e0f542/opentelemetry_exporter_otlp-1.39.0-py3-none-any.whl", hash = "sha256:fe155d6968d581b325574ad6dc267c8de299397b18d11feeda2206d0a47928a9", size = 7017, upload-time = "2025-12-03T13:19:35.686Z" },
]
[[package]] [[package]]
name = "opentelemetry-exporter-otlp-proto-common" name = "opentelemetry-exporter-otlp-proto-common"
version = "1.39.0" version = "1.39.0"
@@ -2380,6 +2395,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/56/e8/d420b94ffddfd8cff85bb4aa5d98da26ce7935dc3cf3eca6b83cd39ab436/opentelemetry_exporter_otlp_proto_grpc-1.39.0-py3-none-any.whl", hash = "sha256:758641278050de9bb895738f35ff8840e4a47685b7e6ef4a201fe83196ba7a05", size = 19765, upload-time = "2025-12-03T13:19:38.143Z" }, { url = "https://files.pythonhosted.org/packages/56/e8/d420b94ffddfd8cff85bb4aa5d98da26ce7935dc3cf3eca6b83cd39ab436/opentelemetry_exporter_otlp_proto_grpc-1.39.0-py3-none-any.whl", hash = "sha256:758641278050de9bb895738f35ff8840e4a47685b7e6ef4a201fe83196ba7a05", size = 19765, upload-time = "2025-12-03T13:19:38.143Z" },
] ]
[[package]]
name = "opentelemetry-exporter-otlp-proto-http"
version = "1.39.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp-proto-common" },
{ name = "opentelemetry-proto" },
{ name = "opentelemetry-sdk" },
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/dc/1e9bf3f6a28e29eba516bc0266e052996d02bc7e92675f3cd38169607609/opentelemetry_exporter_otlp_proto_http-1.39.0.tar.gz", hash = "sha256:28d78fc0eb82d5a71ae552263d5012fa3ebad18dfd189bf8d8095ba0e65ee1ed", size = 17287, upload-time = "2025-12-03T13:20:01.134Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/46/e4a102e17205bb05a50dbf24ef0e92b66b648cd67db9a68865af06a242fd/opentelemetry_exporter_otlp_proto_http-1.39.0-py3-none-any.whl", hash = "sha256:5789cb1375a8b82653328c0ce13a054d285f774099faf9d068032a49de4c7862", size = 19639, upload-time = "2025-12-03T13:19:39.536Z" },
]
[[package]] [[package]]
name = "opentelemetry-instrumentation" name = "opentelemetry-instrumentation"
version = "0.60b0" version = "0.60b0"
@@ -3298,6 +3331,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
] ]
[[package]]
name = "pytest-otel"
version = "2.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp" },
{ name = "opentelemetry-sdk" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ca/5e/771f8dbdf55ae57603d89bb26e046c13e8bee3492c74cb3afb6044166e9a/pytest_otel-2.0.1.tar.gz", hash = "sha256:3d529dc34105862cca39fd1258d00dd3d17f3b7d92ebf0953d55326bb017af3d", size = 17880, upload-time = "2025-12-08T14:56:40.393Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/16/fa/0cfe23bac571f68b4f427ad7ae313ddd8de881ec2ff49dcea196a2a96592/pytest_otel-2.0.1-py2.py3-none-any.whl", hash = "sha256:501f36f02f55578ca34c3ccbe55e81cc1e14bb130da13f7cf0b6480d54a9e5db", size = 14530, upload-time = "2025-12-08T14:56:41.731Z" },
]
[[package]] [[package]]
name = "pytest-playwright-asyncio" name = "pytest-playwright-asyncio"
version = "0.7.2" version = "0.7.2"