Skip to content

Commit 6358542

Browse files
bokelleyclaude
andauthored
feat: add __str__ methods and filter params tests (#92)
* test: add tests confirming filter parameters exist in SDK The Python SDK already supports is_responsive and name_search parameters in ListCreativeFormatsRequest, as per the AdCP specification. These tests document this support. Note: The test agent server at test-agent.adcontextprotocol.org does not yet expose these parameters in its MCP tool schema. This is a server-side issue, not a client SDK issue. The SDK correctly passes these parameters when supported by the server. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: add __str__ methods to response types for human-readable messages Implements issue #91: Response types now generate standardized, human-readable messages when converted to strings. This enables consistent messaging across MCP tool results, A2A task communications, and REST API responses. Implemented formatters for: - GetProductsResponse - ListCreativeFormatsResponse - GetSignalsResponse - ListAuthorizedPropertiesResponse - ListCreativesResponse - CreateMediaBuyResponse (success/error variants) - UpdateMediaBuyResponse (success/error variants) - SyncCreativesResponse (success/error variants) - ActivateSignalResponse (success/error variants) - PreviewCreativeResponse (single/batch variants) - BuildCreativeResponse (success/error variants) - GetMediaBuyDeliveryResponse - ProvidePerformanceFeedbackResponse (success/error variants) Messages use proper singular/plural forms and include relevant counts. Non-response types fall back to Pydantic's default representation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: improve grammar and i18n readiness in response messages Changes message format from verb conjugation ("match/matches") to present participle ("matching") for better multilingual support. Also explicitly specifies irregular plural for "media buy" → "media buys". Removes unused pytest import from test file. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: use collections.abc.Callable and shorten line length Addresses ruff linter errors: - Import Callable from collections.abc instead of typing (UP035) - Create MessageFormatter type alias to shorten line length (E501) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: use conversational message for zero products Changes "Found 0 products..." to "No products matched your requirements." for a more natural user experience. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: change __str__ to .summary() method Switches from overriding __str__ to an explicit .summary() method: - Avoids shadowing 'message' field in generated types (Error, etc.) - Keeps str() for Pydantic's default repr (better for debugging) - More explicit API: response.summary() vs str(response) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c3ec172 commit 6358542

File tree

4 files changed

+631
-0
lines changed

4 files changed

+631
-0
lines changed

src/adcp/types/base.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,189 @@
22

33
"""Base model for AdCP types with spec-compliant serialization."""
44

5+
from collections.abc import Callable
56
from typing import Any
67

78
from pydantic import BaseModel
89

10+
# Type alias to shorten long type annotations
11+
MessageFormatter = Callable[[Any], str]
12+
13+
14+
def _pluralize(count: int, singular: str, plural: str | None = None) -> str:
15+
"""Return singular or plural form based on count."""
16+
if count == 1:
17+
return singular
18+
return plural if plural else f"{singular}s"
19+
20+
21+
# Registry of human-readable message formatters for response types.
22+
# Key is the class name, value is a callable that takes the instance and returns a message.
23+
_RESPONSE_MESSAGE_REGISTRY: dict[str, MessageFormatter] = {}
24+
25+
26+
def _register_response_message(cls_name: str) -> Callable[[MessageFormatter], MessageFormatter]:
27+
"""Decorator to register a message formatter for a response type."""
28+
29+
def decorator(func: MessageFormatter) -> MessageFormatter:
30+
_RESPONSE_MESSAGE_REGISTRY[cls_name] = func
31+
return func
32+
33+
return decorator
34+
35+
36+
# Response message formatters
37+
@_register_response_message("GetProductsResponse")
38+
def _get_products_message(self: Any) -> str:
39+
products = getattr(self, "products", None)
40+
if products is None or len(products) == 0:
41+
return "No products matched your requirements."
42+
count = len(products)
43+
return f"Found {count} {_pluralize(count, 'product')} matching your requirements."
44+
45+
46+
@_register_response_message("ListCreativeFormatsResponse")
47+
def _list_creative_formats_message(self: Any) -> str:
48+
formats = getattr(self, "formats", None)
49+
if formats is None:
50+
return "No creative formats found."
51+
count = len(formats)
52+
return f"Found {count} supported creative {_pluralize(count, 'format')}."
53+
54+
55+
@_register_response_message("GetSignalsResponse")
56+
def _get_signals_message(self: Any) -> str:
57+
signals = getattr(self, "signals", None)
58+
if signals is None:
59+
return "No signals found."
60+
count = len(signals)
61+
return f"Found {count} {_pluralize(count, 'signal')} available for targeting."
62+
63+
64+
@_register_response_message("ListAuthorizedPropertiesResponse")
65+
def _list_authorized_properties_message(self: Any) -> str:
66+
domains = getattr(self, "publisher_domains", None)
67+
if domains is None:
68+
return "No authorized properties found."
69+
count = len(domains)
70+
return f"Authorized to represent {count} publisher {_pluralize(count, 'domain')}."
71+
72+
73+
@_register_response_message("ListCreativesResponse")
74+
def _list_creatives_message(self: Any) -> str:
75+
creatives = getattr(self, "creatives", None)
76+
if creatives is None:
77+
return "No creatives found."
78+
count = len(creatives)
79+
return f"Found {count} {_pluralize(count, 'creative')} in the system."
80+
81+
82+
@_register_response_message("CreateMediaBuyResponse1")
83+
def _create_media_buy_success_message(self: Any) -> str:
84+
media_buy_id = getattr(self, "media_buy_id", None)
85+
packages = getattr(self, "packages", None)
86+
package_count = len(packages) if packages else 0
87+
return (
88+
f"Media buy {media_buy_id} created with "
89+
f"{package_count} {_pluralize(package_count, 'package')}."
90+
)
91+
92+
93+
@_register_response_message("CreateMediaBuyResponse2")
94+
def _create_media_buy_error_message(self: Any) -> str:
95+
errors = getattr(self, "errors", None)
96+
error_count = len(errors) if errors else 0
97+
return f"Media buy creation failed with {error_count} {_pluralize(error_count, 'error')}."
98+
99+
100+
@_register_response_message("UpdateMediaBuyResponse1")
101+
def _update_media_buy_success_message(self: Any) -> str:
102+
media_buy_id = getattr(self, "media_buy_id", None)
103+
return f"Media buy {media_buy_id} updated successfully."
104+
105+
106+
@_register_response_message("UpdateMediaBuyResponse2")
107+
def _update_media_buy_error_message(self: Any) -> str:
108+
errors = getattr(self, "errors", None)
109+
error_count = len(errors) if errors else 0
110+
return f"Media buy update failed with {error_count} {_pluralize(error_count, 'error')}."
111+
112+
113+
@_register_response_message("SyncCreativesResponse1")
114+
def _sync_creatives_success_message(self: Any) -> str:
115+
creatives = getattr(self, "creatives", None)
116+
creative_count = len(creatives) if creatives else 0
117+
return f"Synced {creative_count} {_pluralize(creative_count, 'creative')} successfully."
118+
119+
120+
@_register_response_message("SyncCreativesResponse2")
121+
def _sync_creatives_error_message(self: Any) -> str:
122+
errors = getattr(self, "errors", None)
123+
error_count = len(errors) if errors else 0
124+
return f"Creative sync failed with {error_count} {_pluralize(error_count, 'error')}."
125+
126+
127+
@_register_response_message("ActivateSignalResponse1")
128+
def _activate_signal_success_message(self: Any) -> str:
129+
return "Signal activated successfully."
130+
131+
132+
@_register_response_message("ActivateSignalResponse2")
133+
def _activate_signal_error_message(self: Any) -> str:
134+
errors = getattr(self, "errors", None)
135+
error_count = len(errors) if errors else 0
136+
return f"Signal activation failed with {error_count} {_pluralize(error_count, 'error')}."
137+
138+
139+
@_register_response_message("PreviewCreativeResponse1")
140+
def _preview_creative_single_message(self: Any) -> str:
141+
previews = getattr(self, "previews", None)
142+
preview_count = len(previews) if previews else 0
143+
return f"Generated {preview_count} {_pluralize(preview_count, 'preview')}."
144+
145+
146+
@_register_response_message("PreviewCreativeResponse2")
147+
def _preview_creative_batch_message(self: Any) -> str:
148+
results = getattr(self, "results", None)
149+
result_count = len(results) if results else 0
150+
return f"Generated previews for {result_count} {_pluralize(result_count, 'manifest')}."
151+
152+
153+
@_register_response_message("BuildCreativeResponse1")
154+
def _build_creative_success_message(self: Any) -> str:
155+
return "Creative built successfully."
156+
157+
158+
@_register_response_message("BuildCreativeResponse2")
159+
def _build_creative_error_message(self: Any) -> str:
160+
errors = getattr(self, "errors", None)
161+
error_count = len(errors) if errors else 0
162+
return f"Creative build failed with {error_count} {_pluralize(error_count, 'error')}."
163+
164+
165+
@_register_response_message("GetMediaBuyDeliveryResponse")
166+
def _get_media_buy_delivery_message(self: Any) -> str:
167+
deliveries = getattr(self, "media_buy_deliveries", None)
168+
if deliveries is None:
169+
return "No delivery data available."
170+
count = len(deliveries)
171+
return f"Retrieved delivery data for {count} media {_pluralize(count, 'buy', 'buys')}."
172+
173+
174+
@_register_response_message("ProvidePerformanceFeedbackResponse1")
175+
def _provide_performance_feedback_success_message(self: Any) -> str:
176+
return "Performance feedback recorded successfully."
177+
178+
179+
@_register_response_message("ProvidePerformanceFeedbackResponse2")
180+
def _provide_performance_feedback_error_message(self: Any) -> str:
181+
errors = getattr(self, "errors", None)
182+
error_count = len(errors) if errors else 0
183+
return (
184+
f"Performance feedback recording failed with "
185+
f"{error_count} {_pluralize(error_count, 'error')}."
186+
)
187+
9188

10189
class AdCPBaseModel(BaseModel):
11190
"""Base model for AdCP types with spec-compliant serialization.
@@ -24,3 +203,17 @@ def model_dump_json(self, **kwargs: Any) -> str:
24203
if "exclude_none" not in kwargs:
25204
kwargs["exclude_none"] = True
26205
return super().model_dump_json(**kwargs)
206+
207+
def summary(self) -> str:
208+
"""Human-readable summary for protocol responses.
209+
210+
Returns a standardized human-readable message suitable for MCP tool
211+
results, A2A task communications, and REST API responses.
212+
213+
For types without a registered formatter, returns a generic message
214+
with the class name.
215+
"""
216+
formatter = _RESPONSE_MESSAGE_REGISTRY.get(self.__class__.__name__)
217+
if formatter:
218+
return formatter(self)
219+
return f"{self.__class__.__name__} response"

tests/test_public_api.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,3 +282,53 @@ def test_public_api_has_version():
282282
assert hasattr(adcp, "__version__"), "adcp package should export __version__"
283283
assert isinstance(adcp.__version__, str), "__version__ should be a string"
284284
assert len(adcp.__version__) > 0, "__version__ should not be empty"
285+
286+
287+
def test_list_creative_formats_request_has_filter_params():
288+
"""ListCreativeFormatsRequest type has filter parameters per AdCP spec.
289+
290+
The SDK supports is_responsive and name_search parameters for filtering
291+
creative formats. These parameters are part of the AdCP specification.
292+
"""
293+
from adcp import ListCreativeFormatsRequest
294+
295+
model_fields = ListCreativeFormatsRequest.model_fields
296+
297+
# Core filter parameters from AdCP spec
298+
expected_fields = [
299+
"is_responsive", # Filter for responsive formats
300+
"name_search", # Search formats by name (case-insensitive partial match)
301+
"asset_types", # Filter by asset types (image, video, etc.)
302+
"type", # Filter by format category (display, video, etc.)
303+
"format_ids", # Return only specific format IDs
304+
"min_width", # Minimum width filter
305+
"max_width", # Maximum width filter
306+
"min_height", # Minimum height filter
307+
"max_height", # Maximum height filter
308+
"context", # Context object for request
309+
"ext", # Extension object
310+
]
311+
312+
for field_name in expected_fields:
313+
assert field_name in model_fields, (
314+
f"ListCreativeFormatsRequest missing field: {field_name}"
315+
)
316+
317+
318+
def test_list_creative_formats_request_filter_params_types():
319+
"""ListCreativeFormatsRequest filter parameters have correct types."""
320+
from adcp import ListCreativeFormatsRequest
321+
322+
# Create request with filter parameters - should not raise
323+
request = ListCreativeFormatsRequest(
324+
is_responsive=True,
325+
name_search="mobile",
326+
)
327+
328+
assert request.is_responsive is True
329+
assert request.name_search == "mobile"
330+
331+
# Verify serialization includes the filter parameters
332+
data = request.model_dump(exclude_none=True)
333+
assert data["is_responsive"] is True
334+
assert data["name_search"] == "mobile"

0 commit comments

Comments
 (0)