fix(deck): address PR #759 review feedback

- Validate description_max_length is positive (raises ValueError on 0
  or negative); the prior code would have wiped descriptions to a
  single ellipsis character on description_max_length=0.
- Extract filter logic into testable module-level helpers
  (_apply_board_filters, _apply_stack_filters, _apply_card_filters)
  and replace the dense `continue`-based loop in deck_get_stacks with
  the reviewer's elif form.
- Document the truncation length quirk in the helper docstring (result
  is description_max_length + 1 chars when truncation fires).
- Add 13 new unit tests covering the include/exclude flags on board,
  stacks, and flat card lists, plus the new validation paths and an
  explicit "description fits within limit, no ellipsis" case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-03 00:41:14 +02:00
co-authored by Claude Opus 4.7
parent d8cd073e66
commit 7d633a945d
2 changed files with 368 additions and 30 deletions
+91 -26
View File
@@ -34,14 +34,81 @@ logger = logging.getLogger(__name__)
def _truncate_card_descriptions(
cards: list[DeckCard], description_max_length: int | None
) -> None:
"""Truncate each card's description in-place when it exceeds the limit."""
"""Truncate each card's description in-place when it strictly exceeds the
limit.
Descriptions whose length is less than or equal to ``description_max_length``
are left untouched (no ellipsis is appended). Descriptions longer than the
limit are truncated to ``description_max_length`` characters and an
ellipsis ("") is appended, so the resulting string is
``description_max_length + 1`` characters total.
Args:
cards: Cards to mutate in place.
description_max_length: Positive truncation threshold, or ``None`` to
skip truncation entirely.
Raises:
ValueError: If ``description_max_length`` is not positive.
"""
if description_max_length is None:
return
if description_max_length <= 0:
raise ValueError(
f"description_max_length must be positive, got {description_max_length}"
)
for card in cards:
if card.description and len(card.description) > description_max_length:
card.description = card.description[:description_max_length] + ""
def _apply_board_filters(
board: DeckBoard,
*,
include_acl: bool,
include_users: bool,
include_labels: bool,
) -> DeckBoard:
"""Drop board sub-fields the caller didn't request (in-place)."""
if not include_acl:
board.acl = []
if not include_users:
board.users = []
if not include_labels:
board.labels = []
return board
def _apply_stack_filters(
stack: DeckStack,
*,
include_cards: bool,
include_archived_cards: bool,
description_max_length: int | None,
) -> DeckStack:
"""Apply card-shaping filters to a single stack (in-place)."""
if not include_cards:
stack.cards = None
elif stack.cards:
if not include_archived_cards:
stack.cards = [c for c in stack.cards if not c.archived]
_truncate_card_descriptions(stack.cards, description_max_length)
return stack
def _apply_card_filters(
cards: list[DeckCard],
*,
include_archived: bool,
description_max_length: int | None,
) -> list[DeckCard]:
"""Apply filters to a flat list of cards. Returns a (possibly new) list."""
if not include_archived:
cards = [c for c in cards if not c.archived]
_truncate_card_descriptions(cards, description_max_length)
return cards
def configure_deck_tools(mcp: FastMCP):
"""Configure Nextcloud Deck tools and resources for the MCP server."""
@@ -175,13 +242,12 @@ def configure_deck_tools(mcp: FastMCP):
"""
client = await get_client(ctx)
board = await client.deck.get_board(board_id)
if not include_acl:
board.acl = []
if not include_users:
board.users = []
if not include_labels:
board.labels = []
return board
return _apply_board_filters(
board,
include_acl=include_acl,
include_users=include_users,
include_labels=include_labels,
)
@mcp.tool(
title="List Deck Stacks",
@@ -212,13 +278,12 @@ def configure_deck_tools(mcp: FastMCP):
client = await get_client(ctx)
stacks = await client.deck.get_stacks(board_id)
for stack in stacks:
if not include_cards:
stack.cards = None
continue
if stack.cards:
if not include_archived_cards:
stack.cards = [c for c in stack.cards if not c.archived]
_truncate_card_descriptions(stack.cards, description_max_length)
_apply_stack_filters(
stack,
include_cards=include_cards,
include_archived_cards=include_archived_cards,
description_max_length=description_max_length,
)
return ListStacksResponse(stacks=stacks, total=len(stacks))
@mcp.tool(
@@ -248,13 +313,12 @@ def configure_deck_tools(mcp: FastMCP):
"""
client = await get_client(ctx)
stack = await client.deck.get_stack(board_id, stack_id)
if not include_cards:
stack.cards = None
elif stack.cards:
if not include_archived_cards:
stack.cards = [c for c in stack.cards if not c.archived]
_truncate_card_descriptions(stack.cards, description_max_length)
return stack
return _apply_stack_filters(
stack,
include_cards=include_cards,
include_archived_cards=include_archived_cards,
description_max_length=description_max_length,
)
@mcp.tool(
title="List Archived Deck Stacks",
@@ -312,10 +376,11 @@ def configure_deck_tools(mcp: FastMCP):
"""
client = await get_client(ctx)
stack = await client.deck.get_stack(board_id, stack_id)
cards = stack.cards or []
if not include_archived:
cards = [c for c in cards if not c.archived]
_truncate_card_descriptions(cards, description_max_length)
cards = _apply_card_filters(
stack.cards or [],
include_archived=include_archived,
description_max_length=description_max_length,
)
return ListCardsResponse(cards=cards, total=len(cards))
@mcp.tool(