feat: upgrade Python SDK to UCP v2026-04-08#48
Conversation
UCP v2026-04-08
for v2026-04-08
There was a problem hiding this comment.
Code Review
This pull request updates the UCP Python SDK schemas to support new shopping cart, catalog lookup, catalog search, and order lifecycle capabilities, while transitioning monetary fields to use typed Amount and SignedAmount models. The code review feedback suggests enhancing several of these generated Pydantic models with runtime validators to enforce documented constraints, such as ensuring at least one format is provided in Description, exactly one subtotal and total entry exist in Totals, valid price ranges in PriceFilter, proper rating bounds in Rating, and cursor presence when pagination has a next page.
| from pydantic import BaseModel, ConfigDict | ||
|
|
||
|
|
||
| class Description(BaseModel): | ||
| """ | ||
| Description content in one or more formats. At least one format must be provided. | ||
| """ | ||
|
|
||
| model_config = ConfigDict( | ||
| extra="allow", | ||
| ) | ||
| plain: str | None = None | ||
| """ | ||
| Plain text content. | ||
| """ | ||
| html: str | None = None | ||
| """ | ||
| HTML-formatted content. Security: Platforms MUST sanitize before rendering—strip scripts, event handlers, and untrusted elements. Treat all rich text as untrusted input. | ||
| """ | ||
| markdown: str | None = None | ||
| """ | ||
| Markdown-formatted content. | ||
| """ |
There was a problem hiding this comment.
The docstring specifies that 'At least one format must be provided' for the Description model. However, all fields (plain, html, markdown) are currently optional with no validation enforcing this constraint. Adding a model validator ensures compliance with the schema definition.
from pydantic import BaseModel, ConfigDict, model_validator
class Description(BaseModel):
"""
Description content in one or more formats. At least one format must be provided.
"""
model_config = ConfigDict(
extra="allow",
)
plain: str | None = None
"""
Plain text content.
"""
html: str | None = None
"""
HTML-formatted content. Security: Platforms MUST sanitize before rendering—strip scripts, event handlers, and untrusted elements. Treat all rich text as untrusted input.
"""
markdown: str | None = None
"""
Markdown-formatted content.
"""
@model_validator(mode="after")
def validate_at_least_one_format(self) -> Description:
if self.plain is None and self.html is None and self.markdown is None:
raise ValueError("At least one description format (plain, html, markdown) must be provided.")
return self|
|
||
| from __future__ import annotations | ||
|
|
||
| from pydantic import BaseModel, ConfigDict, Field, RootModel |
| class Totals(RootModel[list[Total]]): | ||
| """ | ||
| Pricing breakdown provided by the business. MUST contain exactly one subtotal and one total entry. Detail types (tax, fee, discount, fulfillment) may appear multiple times for itemization. Platforms MUST render all entries in order using display_text and amount. | ||
| """ | ||
|
|
||
| model_config = ConfigDict( | ||
| frozen=True, | ||
| ) | ||
| root: list[Total] = Field(..., title="Totals") | ||
| """ | ||
| Pricing breakdown provided by the business. MUST contain exactly one subtotal and one total entry. Detail types (tax, fee, discount, fulfillment) may appear multiple times for itemization. Platforms MUST render all entries in order using display_text and amount. | ||
| """ |
There was a problem hiding this comment.
The docstring specifies that the Totals model 'MUST contain exactly one subtotal and one total entry.' Adding a model validator enforces this structural constraint at runtime.
| class Totals(RootModel[list[Total]]): | |
| """ | |
| Pricing breakdown provided by the business. MUST contain exactly one subtotal and one total entry. Detail types (tax, fee, discount, fulfillment) may appear multiple times for itemization. Platforms MUST render all entries in order using display_text and amount. | |
| """ | |
| model_config = ConfigDict( | |
| frozen=True, | |
| ) | |
| root: list[Total] = Field(..., title="Totals") | |
| """ | |
| Pricing breakdown provided by the business. MUST contain exactly one subtotal and one total entry. Detail types (tax, fee, discount, fulfillment) may appear multiple times for itemization. Platforms MUST render all entries in order using display_text and amount. | |
| """ | |
| class Totals(RootModel[list[Total]]): | |
| """ | |
| Pricing breakdown provided by the business. MUST contain exactly one subtotal and one total entry. Detail types (tax, fee, discount, fulfillment) may appear multiple times for itemization. Platforms MUST render all entries in order using display_text and amount. | |
| """ | |
| model_config = ConfigDict( | |
| frozen=True, | |
| ) | |
| root: list[Total] = Field(..., title="Totals") | |
| """ | |
| Pricing breakdown provided by the business. MUST contain exactly one subtotal and one total entry. Detail types (tax, fee, discount, fulfillment) may appear multiple times for itemization. Platforms MUST render all entries in order using display_text and amount. | |
| """ | |
| @model_validator(mode="after") | |
| def validate_totals_entries(self) -> Totals: | |
| subtotal_count = sum(1 for t in self.root if t.type == "subtotal") | |
| total_count = sum(1 for t in self.root if t.type == "total") | |
| if subtotal_count != 1 or total_count != 1: | |
| raise ValueError("Totals must contain exactly one subtotal and one total entry.") | |
| return self |
|
|
||
| from pydantic import BaseModel, ConfigDict | ||
|
|
||
| from . import amount | ||
|
|
||
|
|
||
| class PriceFilter(BaseModel): | ||
| """ | ||
| Price range filter denominated in context.currency. When context.currency matches the presentment currency, businesses apply the filter directly. When it differs, businesses SHOULD convert filter values to the presentment currency before applying; if conversion is not supported, businesses MAY ignore the filter and SHOULD indicate this via a message. When context.currency is absent, filter denomination is ambiguous and businesses MAY ignore it. | ||
| """ | ||
|
|
||
| model_config = ConfigDict( | ||
| extra="allow", | ||
| ) | ||
| min: amount.Amount | None = None | ||
| """ | ||
| Minimum price in ISO 4217 minor units. | ||
| """ | ||
| max: amount.Amount | None = None | ||
| """ | ||
| Maximum price in ISO 4217 minor units. |
There was a problem hiding this comment.
Add a model validator to ensure that the minimum price (min) is not greater than the maximum price (max) when both are provided.
from pydantic import BaseModel, ConfigDict, model_validator
from . import amount
class PriceFilter(BaseModel):
"""
Price range filter denominated in context.currency. When context.currency matches the presentment currency, businesses apply the filter directly. When it differs, businesses SHOULD convert filter values to the presentment currency before applying; if conversion is not supported, businesses MAY ignore the filter and SHOULD indicate this via a message. When context.currency is absent, filter denomination is ambiguous and businesses MAY ignore it.
"""
model_config = ConfigDict(
extra="allow",
)
min: amount.Amount | None = None
"""
Minimum price in ISO 4217 minor units.
"""
max: amount.Amount | None = None
"""
Maximum price in ISO 4217 minor units.
"""
@model_validator(mode="after")
def validate_price_range(self) -> PriceFilter:
if self.min is not None and self.max is not None:
if self.min.root > self.max.root:
raise ValueError("Minimum price (min) cannot be greater than maximum price (max).")
return self|
|
||
| from pydantic import BaseModel, ConfigDict, Field | ||
|
|
||
|
|
||
| class Rating(BaseModel): | ||
| """ | ||
| Product rating aggregate. | ||
| """ | ||
|
|
||
| model_config = ConfigDict( | ||
| extra="allow", | ||
| ) | ||
| value: float = Field(..., ge=0.0) | ||
| """ | ||
| Average rating value. | ||
| """ | ||
| scale_min: float | None = Field(1, ge=0.0) | ||
| """ | ||
| Minimum value on the rating scale (e.g., 1 for 1-5 stars). | ||
| """ | ||
| scale_max: float = Field(..., ge=1.0) | ||
| """ | ||
| Maximum value on the rating scale (e.g., 5 for 5-star). | ||
| """ | ||
| count: int | None = Field(None, ge=0) | ||
| """ | ||
| Number of reviews contributing to the rating. | ||
| """ |
There was a problem hiding this comment.
Add a model validator to ensure that scale_min is strictly less than scale_max, and that the rating value lies within the [scale_min, scale_max] range.
from pydantic import BaseModel, ConfigDict, Field, model_validator
class Rating(BaseModel):
"""
Product rating aggregate.
"""
model_config = ConfigDict(
extra="allow",
)
value: float = Field(..., ge=0.0)
"""
Average rating value.
"""
scale_min: float | None = Field(1, ge=0.0)
"""
Minimum value on the rating scale (e.g., 1 for 1-5 stars).
"""
scale_max: float = Field(..., ge=1.0)
"""
Maximum value on the rating scale (e.g., 5 for 5-star).
"""
count: int | None = Field(None, ge=0)
"""
Number of reviews contributing to the rating.
"""
@model_validator(mode="after")
def validate_rating_bounds(self) -> Rating:
min_val = self.scale_min if self.scale_min is not None else 1.0
if min_val >= self.scale_max:
raise ValueError("scale_min must be less than scale_max.")
if not (min_val <= self.value <= self.scale_max):
raise ValueError(f"Rating value {self.value} must be between {min_val} and {self.scale_max}.")
return self| # pyformat: disable | ||
|
|
||
| from __future__ import annotations | ||
|
|
| class Response(BaseModel): | ||
| """ | ||
| Pagination information in responses. | ||
| """ | ||
|
|
||
| model_config = ConfigDict( | ||
| extra="allow", | ||
| ) | ||
| cursor: str | None = None | ||
| """ | ||
| Cursor to fetch the next page of results. MUST be present when has_next_page is true. | ||
| """ | ||
| has_next_page: bool | ||
| """ | ||
| Whether more results are available. | ||
| """ | ||
| total_count: int | None = Field(None, ge=0) | ||
| """ | ||
| Total number of matching items, if available. | ||
| """ |
There was a problem hiding this comment.
The docstring for cursor specifies that it 'MUST be present when has_next_page is true.' Adding a model validator enforces this requirement at runtime.
class Response(BaseModel):
"""
Pagination information in responses.
"""
model_config = ConfigDict(
extra="allow",
)
cursor: str | None = None
"""
Cursor to fetch the next page of results. MUST be present when has_next_page is true.
"""
has_next_page: bool
"""
Whether more results are available.
"""
total_count: int | None = Field(None, ge=0)
"""
Total number of matching items, if available.
"""
@model_validator(mode="after")
def validate_cursor_presence(self) -> Response:
if self.has_next_page and self.cursor is None:
raise ValueError("cursor must be present when has_next_page is True.")
return self
Regenerates Pydantic models against UCP April 8th (v48) schemas, updates package metadata to 0.4.0, and configures per-file-ignores for generated docstrings.