adcp 2.18.0__py3-none-any.whl → 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. adcp/ADCP_VERSION +1 -1
  2. adcp/__init__.py +6 -14
  3. adcp/__main__.py +94 -51
  4. adcp/adagents.py +91 -19
  5. adcp/client.py +865 -0
  6. adcp/protocols/a2a.py +84 -0
  7. adcp/protocols/base.py +101 -0
  8. adcp/protocols/mcp.py +87 -1
  9. adcp/server/__init__.py +49 -0
  10. adcp/server/base.py +368 -0
  11. adcp/server/content_standards.py +561 -0
  12. adcp/server/governance.py +491 -0
  13. adcp/server/mcp_tools.py +471 -0
  14. adcp/server/proposal.py +334 -0
  15. adcp/server/sponsored_intelligence.py +444 -0
  16. adcp/types/__init__.py +111 -23
  17. adcp/types/_ergonomic.py +35 -18
  18. adcp/types/_generated.py +419 -44
  19. adcp/types/aliases.py +13 -20
  20. adcp/types/base.py +1 -1
  21. adcp/types/generated_poc/adagents.py +103 -6
  22. adcp/types/generated_poc/content_standards/__init__.py +3 -0
  23. adcp/types/generated_poc/content_standards/artifact.py +208 -0
  24. adcp/types/generated_poc/content_standards/artifact_webhook_payload.py +64 -0
  25. adcp/types/generated_poc/content_standards/calibrate_content_request.py +17 -0
  26. adcp/types/generated_poc/content_standards/calibrate_content_response.py +74 -0
  27. adcp/types/generated_poc/content_standards/content_standards.py +66 -0
  28. adcp/types/generated_poc/content_standards/create_content_standards_request.py +97 -0
  29. adcp/types/generated_poc/content_standards/create_content_standards_response.py +52 -0
  30. adcp/types/generated_poc/content_standards/get_content_standards_request.py +21 -0
  31. adcp/types/generated_poc/content_standards/get_content_standards_response.py +43 -0
  32. adcp/types/generated_poc/content_standards/get_media_buy_artifacts_request.py +64 -0
  33. adcp/types/generated_poc/content_standards/get_media_buy_artifacts_response.py +117 -0
  34. adcp/types/generated_poc/content_standards/list_content_standards_request.py +31 -0
  35. adcp/types/generated_poc/content_standards/list_content_standards_response.py +48 -0
  36. adcp/types/generated_poc/content_standards/update_content_standards_request.py +101 -0
  37. adcp/types/generated_poc/content_standards/update_content_standards_response.py +34 -0
  38. adcp/types/generated_poc/content_standards/validate_content_delivery_request.py +59 -0
  39. adcp/types/generated_poc/content_standards/validate_content_delivery_response.py +85 -0
  40. adcp/types/generated_poc/core/activation_key.py +1 -1
  41. adcp/types/generated_poc/core/assets/audio_asset.py +1 -1
  42. adcp/types/generated_poc/core/assets/css_asset.py +1 -1
  43. adcp/types/generated_poc/core/assets/daast_asset.py +1 -1
  44. adcp/types/generated_poc/core/assets/html_asset.py +1 -1
  45. adcp/types/generated_poc/core/assets/image_asset.py +1 -1
  46. adcp/types/generated_poc/core/assets/javascript_asset.py +1 -1
  47. adcp/types/generated_poc/core/assets/text_asset.py +1 -1
  48. adcp/types/generated_poc/core/assets/url_asset.py +1 -1
  49. adcp/types/generated_poc/core/assets/vast_asset.py +1 -1
  50. adcp/types/generated_poc/core/assets/video_asset.py +1 -1
  51. adcp/types/generated_poc/core/assets/webhook_asset.py +1 -1
  52. adcp/types/generated_poc/core/async_response_data.py +1 -1
  53. adcp/types/generated_poc/core/brand_manifest.py +68 -5
  54. adcp/types/generated_poc/core/brand_manifest_ref.py +1 -1
  55. adcp/types/generated_poc/core/context.py +1 -1
  56. adcp/types/generated_poc/core/creative_asset.py +8 -7
  57. adcp/types/generated_poc/core/creative_assignment.py +1 -1
  58. adcp/types/generated_poc/core/creative_filters.py +4 -14
  59. adcp/types/generated_poc/core/creative_manifest.py +1 -1
  60. adcp/types/generated_poc/core/creative_policy.py +1 -1
  61. adcp/types/generated_poc/core/delivery_metrics.py +1 -1
  62. adcp/types/generated_poc/core/deployment.py +1 -1
  63. adcp/types/generated_poc/core/destination.py +1 -1
  64. adcp/types/generated_poc/core/error.py +1 -1
  65. adcp/types/generated_poc/core/ext.py +1 -1
  66. adcp/types/generated_poc/core/format.py +6 -5
  67. adcp/types/generated_poc/core/format_id.py +1 -1
  68. adcp/types/generated_poc/core/frequency_cap.py +1 -1
  69. adcp/types/generated_poc/core/identifier.py +27 -0
  70. adcp/types/generated_poc/core/mcp_webhook_payload.py +1 -1
  71. adcp/types/generated_poc/core/measurement.py +1 -1
  72. adcp/types/generated_poc/core/media_buy.py +1 -1
  73. adcp/types/generated_poc/core/media_buy_features.py +29 -0
  74. adcp/types/generated_poc/core/offering.py +80 -0
  75. adcp/types/generated_poc/core/package.py +1 -1
  76. adcp/types/generated_poc/core/performance_feedback.py +1 -1
  77. adcp/types/generated_poc/core/placement.py +1 -1
  78. adcp/types/generated_poc/core/pricing_option.py +8 -14
  79. adcp/types/generated_poc/core/product.py +1 -1
  80. adcp/types/generated_poc/core/product_allocation.py +48 -0
  81. adcp/types/generated_poc/core/product_filters.py +72 -7
  82. adcp/types/generated_poc/core/promoted_offerings.py +12 -21
  83. adcp/types/generated_poc/core/promoted_products.py +1 -1
  84. adcp/types/generated_poc/core/property.py +1 -1
  85. adcp/types/generated_poc/core/property_id.py +1 -1
  86. adcp/types/generated_poc/core/property_list_ref.py +26 -0
  87. adcp/types/generated_poc/core/property_tag.py +1 -1
  88. adcp/types/generated_poc/core/proposal.py +64 -0
  89. adcp/types/generated_poc/core/protocol_envelope.py +1 -1
  90. adcp/types/generated_poc/core/publisher_property_selector.py +1 -1
  91. adcp/types/generated_poc/core/push_notification_config.py +1 -1
  92. adcp/types/generated_poc/core/reporting_capabilities.py +1 -1
  93. adcp/types/generated_poc/core/reporting_webhook.py +70 -0
  94. adcp/types/generated_poc/core/response.py +1 -1
  95. adcp/types/generated_poc/core/signal_filters.py +1 -1
  96. adcp/types/generated_poc/core/start_timing.py +4 -4
  97. adcp/types/generated_poc/core/sub_asset.py +1 -1
  98. adcp/types/generated_poc/core/targeting.py +55 -14
  99. adcp/types/generated_poc/creative/list_creative_formats_request.py +1 -1
  100. adcp/types/generated_poc/creative/list_creative_formats_response.py +1 -1
  101. adcp/types/generated_poc/creative/preview_creative_request.py +1 -1
  102. adcp/types/generated_poc/creative/preview_creative_response.py +3 -2
  103. adcp/types/generated_poc/creative/preview_render.py +1 -1
  104. adcp/types/generated_poc/enums/adcp_domain.py +3 -1
  105. adcp/types/generated_poc/enums/asset_content_type.py +1 -1
  106. adcp/types/generated_poc/enums/auth_scheme.py +1 -1
  107. adcp/types/generated_poc/enums/available_metric.py +1 -1
  108. adcp/types/generated_poc/enums/channels.py +18 -8
  109. adcp/types/generated_poc/enums/co_branding_requirement.py +1 -1
  110. adcp/types/generated_poc/enums/creative_action.py +1 -1
  111. adcp/types/generated_poc/enums/creative_agent_capability.py +1 -1
  112. adcp/types/generated_poc/enums/creative_sort_field.py +1 -1
  113. adcp/types/generated_poc/enums/creative_status.py +2 -1
  114. adcp/types/generated_poc/enums/daast_tracking_event.py +1 -1
  115. adcp/types/generated_poc/enums/daast_version.py +1 -1
  116. adcp/types/generated_poc/enums/delivery_type.py +1 -1
  117. adcp/types/generated_poc/enums/dimension_unit.py +1 -1
  118. adcp/types/generated_poc/enums/feed_format.py +1 -1
  119. adcp/types/generated_poc/enums/feedback_source.py +1 -1
  120. adcp/types/generated_poc/enums/format_category.py +1 -1
  121. adcp/types/generated_poc/enums/format_id_parameter.py +1 -1
  122. adcp/types/generated_poc/enums/frequency_cap_scope.py +1 -1
  123. adcp/types/generated_poc/enums/geo_level.py +14 -0
  124. adcp/types/generated_poc/enums/history_entry_type.py +1 -1
  125. adcp/types/generated_poc/enums/http_method.py +1 -1
  126. adcp/types/generated_poc/enums/identifier_types.py +1 -1
  127. adcp/types/generated_poc/enums/javascript_module_type.py +1 -1
  128. adcp/types/generated_poc/enums/landing_page_requirement.py +1 -1
  129. adcp/types/generated_poc/enums/markdown_flavor.py +1 -1
  130. adcp/types/generated_poc/enums/media_buy_status.py +1 -1
  131. adcp/types/generated_poc/enums/metric_type.py +1 -1
  132. adcp/types/generated_poc/enums/metro_system.py +15 -0
  133. adcp/types/generated_poc/enums/notification_type.py +1 -1
  134. adcp/types/generated_poc/enums/pacing.py +1 -1
  135. adcp/types/generated_poc/enums/postal_system.py +19 -0
  136. adcp/types/generated_poc/enums/preview_output_format.py +1 -1
  137. adcp/types/generated_poc/enums/pricing_model.py +1 -1
  138. adcp/types/generated_poc/enums/property_type.py +2 -1
  139. adcp/types/generated_poc/enums/publisher_identifier_types.py +1 -1
  140. adcp/types/generated_poc/enums/reporting_frequency.py +1 -1
  141. adcp/types/generated_poc/enums/signal_catalog_type.py +1 -1
  142. adcp/types/generated_poc/enums/sort_direction.py +1 -1
  143. adcp/types/generated_poc/enums/task_status.py +1 -1
  144. adcp/types/generated_poc/enums/task_type.py +7 -1
  145. adcp/types/generated_poc/enums/update_frequency.py +1 -1
  146. adcp/types/generated_poc/enums/url_asset_type.py +1 -1
  147. adcp/types/generated_poc/enums/validation_mode.py +1 -1
  148. adcp/types/generated_poc/enums/vast_tracking_event.py +1 -1
  149. adcp/types/generated_poc/enums/vast_version.py +1 -1
  150. adcp/types/generated_poc/enums/webhook_response_type.py +1 -1
  151. adcp/types/generated_poc/enums/webhook_security_method.py +1 -1
  152. adcp/types/generated_poc/extensions/__init__.py +3 -0
  153. adcp/types/generated_poc/extensions/extension_meta.py +58 -0
  154. adcp/types/generated_poc/media_buy/build_creative_request.py +1 -1
  155. adcp/types/generated_poc/media_buy/build_creative_response.py +1 -1
  156. adcp/types/generated_poc/media_buy/create_media_buy_async_response_input_required.py +1 -1
  157. adcp/types/generated_poc/media_buy/create_media_buy_async_response_submitted.py +1 -1
  158. adcp/types/generated_poc/media_buy/create_media_buy_async_response_working.py +1 -1
  159. adcp/types/generated_poc/media_buy/create_media_buy_request.py +54 -26
  160. adcp/types/generated_poc/media_buy/create_media_buy_response.py +1 -1
  161. adcp/types/generated_poc/media_buy/get_media_buy_delivery_request.py +1 -1
  162. adcp/types/generated_poc/media_buy/get_media_buy_delivery_response.py +1 -1
  163. adcp/types/generated_poc/media_buy/get_products_async_response_input_required.py +1 -1
  164. adcp/types/generated_poc/media_buy/get_products_async_response_submitted.py +1 -1
  165. adcp/types/generated_poc/media_buy/get_products_async_response_working.py +1 -1
  166. adcp/types/generated_poc/media_buy/get_products_request.py +18 -3
  167. adcp/types/generated_poc/media_buy/get_products_response.py +14 -2
  168. adcp/types/generated_poc/media_buy/list_authorized_properties_request.py +1 -1
  169. adcp/types/generated_poc/media_buy/list_authorized_properties_response.py +2 -2
  170. adcp/types/generated_poc/media_buy/list_creative_formats_request.py +1 -1
  171. adcp/types/generated_poc/media_buy/list_creative_formats_response.py +1 -1
  172. adcp/types/generated_poc/media_buy/list_creatives_request.py +2 -2
  173. adcp/types/generated_poc/media_buy/list_creatives_response.py +7 -10
  174. adcp/types/generated_poc/media_buy/package_request.py +15 -6
  175. adcp/types/generated_poc/media_buy/package_update.py +119 -0
  176. adcp/types/generated_poc/media_buy/provide_performance_feedback_request.py +1 -1
  177. adcp/types/generated_poc/media_buy/provide_performance_feedback_response.py +1 -1
  178. adcp/types/generated_poc/media_buy/sync_creatives_async_response_input_required.py +1 -1
  179. adcp/types/generated_poc/media_buy/sync_creatives_async_response_submitted.py +1 -1
  180. adcp/types/generated_poc/media_buy/sync_creatives_async_response_working.py +1 -1
  181. adcp/types/generated_poc/media_buy/sync_creatives_request.py +1 -1
  182. adcp/types/generated_poc/media_buy/sync_creatives_response.py +1 -1
  183. adcp/types/generated_poc/media_buy/update_media_buy_async_response_input_required.py +1 -1
  184. adcp/types/generated_poc/media_buy/update_media_buy_async_response_submitted.py +1 -1
  185. adcp/types/generated_poc/media_buy/update_media_buy_async_response_working.py +1 -1
  186. adcp/types/generated_poc/media_buy/update_media_buy_request.py +20 -108
  187. adcp/types/generated_poc/media_buy/update_media_buy_response.py +1 -1
  188. adcp/types/generated_poc/pricing_options/cpc_option.py +35 -10
  189. adcp/types/generated_poc/pricing_options/cpcv_option.py +35 -10
  190. adcp/types/generated_poc/pricing_options/{cpm_auction_option.py → cpm_option.py} +23 -19
  191. adcp/types/generated_poc/pricing_options/cpp_option.py +39 -16
  192. adcp/types/generated_poc/pricing_options/cpv_option.py +37 -18
  193. adcp/types/generated_poc/pricing_options/flat_rate_option.py +45 -39
  194. adcp/types/generated_poc/pricing_options/{vcpm_auction_option.py → vcpm_option.py} +23 -14
  195. adcp/types/generated_poc/property/__init__.py +3 -0
  196. adcp/types/generated_poc/property/base_property_source.py +86 -0
  197. adcp/types/generated_poc/property/create_property_list_request.py +43 -0
  198. adcp/types/generated_poc/property/create_property_list_response.py +27 -0
  199. adcp/types/generated_poc/property/delete_property_list_request.py +22 -0
  200. adcp/types/generated_poc/property/delete_property_list_response.py +21 -0
  201. adcp/types/generated_poc/property/feature_requirement.py +42 -0
  202. adcp/types/generated_poc/property/get_property_list_request.py +34 -0
  203. adcp/types/generated_poc/property/get_property_list_response.py +61 -0
  204. adcp/types/generated_poc/property/list_property_lists_request.py +29 -0
  205. adcp/types/generated_poc/property/list_property_lists_response.py +39 -0
  206. adcp/types/generated_poc/property/property_error.py +33 -0
  207. adcp/types/generated_poc/property/property_feature.py +22 -0
  208. adcp/types/generated_poc/property/property_feature_definition.py +80 -0
  209. adcp/types/generated_poc/property/property_list.py +62 -0
  210. adcp/types/generated_poc/property/property_list_changed_webhook.py +51 -0
  211. adcp/types/generated_poc/property/property_list_filters.py +47 -0
  212. adcp/types/generated_poc/property/update_property_list_request.py +46 -0
  213. adcp/types/generated_poc/property/update_property_list_response.py +21 -0
  214. adcp/types/generated_poc/protocol/__init__.py +3 -0
  215. adcp/types/generated_poc/protocol/get_adcp_capabilities_request.py +34 -0
  216. adcp/types/generated_poc/protocol/get_adcp_capabilities_response.py +353 -0
  217. adcp/types/generated_poc/protocols/adcp_extension.py +18 -5
  218. adcp/types/generated_poc/signals/activate_signal_request.py +1 -1
  219. adcp/types/generated_poc/signals/activate_signal_response.py +1 -1
  220. adcp/types/generated_poc/signals/get_signals_request.py +1 -1
  221. adcp/types/generated_poc/signals/get_signals_response.py +1 -1
  222. adcp/types/generated_poc/sponsored_intelligence/__init__.py +3 -0
  223. adcp/types/generated_poc/sponsored_intelligence/si_capabilities.py +102 -0
  224. adcp/types/generated_poc/sponsored_intelligence/si_get_offering_request.py +34 -0
  225. adcp/types/generated_poc/sponsored_intelligence/si_get_offering_response.py +100 -0
  226. adcp/types/generated_poc/sponsored_intelligence/si_identity.py +78 -0
  227. adcp/types/generated_poc/sponsored_intelligence/si_initiate_session_request.py +46 -0
  228. adcp/types/generated_poc/sponsored_intelligence/si_initiate_session_response.py +44 -0
  229. adcp/types/generated_poc/sponsored_intelligence/si_send_message_request.py +58 -0
  230. adcp/types/generated_poc/sponsored_intelligence/si_send_message_response.py +101 -0
  231. adcp/types/generated_poc/sponsored_intelligence/si_terminate_session_request.py +60 -0
  232. adcp/types/generated_poc/sponsored_intelligence/si_terminate_session_response.py +54 -0
  233. adcp/types/generated_poc/sponsored_intelligence/si_ui_element.py +30 -0
  234. adcp/utils/format_assets.py +5 -5
  235. adcp/utils/preview_cache.py +2 -2
  236. {adcp-2.18.0.dist-info → adcp-3.0.0.dist-info}/METADATA +1 -1
  237. adcp-3.0.0.dist-info/RECORD +264 -0
  238. {adcp-2.18.0.dist-info → adcp-3.0.0.dist-info}/WHEEL +1 -1
  239. adcp/types/generated_poc/enums/standard_format_ids.py +0 -45
  240. adcp/types/generated_poc/pricing_options/cpm_fixed_option.py +0 -43
  241. adcp/types/generated_poc/pricing_options/vcpm_fixed_option.py +0 -47
  242. adcp-2.18.0.dist-info/RECORD +0 -195
  243. {adcp-2.18.0.dist-info → adcp-3.0.0.dist-info}/entry_points.txt +0 -0
  244. {adcp-2.18.0.dist-info → adcp-3.0.0.dist-info}/licenses/LICENSE +0 -0
  245. {adcp-2.18.0.dist-info → adcp-3.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,334 @@
1
+ """Proposal generation helpers.
2
+
3
+ Provides utilities for building ADCP Proposals in get_products responses.
4
+ Proposals represent recommended media plans with budget allocations across products.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime, timedelta, timezone
10
+ from typing import Any
11
+ from uuid import uuid4
12
+
13
+ from pydantic import BaseModel
14
+
15
+ from adcp.types import Error
16
+
17
+
18
+ class ProposalNotSupported(BaseModel):
19
+ """Response indicating proposal generation is not supported.
20
+
21
+ Use this when your agent supports get_products but not proposal generation.
22
+ """
23
+
24
+ proposals_supported: bool = False
25
+ reason: str = "This agent does not generate proposals"
26
+ error: Error | None = None
27
+
28
+
29
+ def proposals_not_supported(
30
+ reason: str = "This agent does not generate proposals",
31
+ ) -> ProposalNotSupported:
32
+ """Create a response indicating proposals are not supported.
33
+
34
+ Args:
35
+ reason: Human-readable explanation
36
+
37
+ Returns:
38
+ ProposalNotSupported response
39
+ """
40
+ return ProposalNotSupported(
41
+ proposals_supported=False,
42
+ reason=reason,
43
+ error=Error(
44
+ code="PROPOSALS_NOT_SUPPORTED",
45
+ message=reason,
46
+ ),
47
+ )
48
+
49
+
50
+ class AllocationBuilder:
51
+ """Builder for product allocations within a proposal."""
52
+
53
+ def __init__(
54
+ self,
55
+ product_id: str,
56
+ allocation_percentage: float,
57
+ ):
58
+ """Create an allocation builder.
59
+
60
+ Args:
61
+ product_id: ID of the product (must match a product in the response)
62
+ allocation_percentage: Percentage of budget (0-100)
63
+ """
64
+ self._data: dict[str, Any] = {
65
+ "product_id": product_id,
66
+ "allocation_percentage": allocation_percentage,
67
+ }
68
+
69
+ def with_pricing_option(self, pricing_option_id: str) -> AllocationBuilder:
70
+ """Specify which pricing option to use.
71
+
72
+ Args:
73
+ pricing_option_id: ID from the product's pricing_options array
74
+ """
75
+ self._data["pricing_option_id"] = pricing_option_id
76
+ return self
77
+
78
+ def with_rationale(self, rationale: str) -> AllocationBuilder:
79
+ """Add explanation for this allocation.
80
+
81
+ Args:
82
+ rationale: Why this product/allocation is recommended
83
+ """
84
+ self._data["rationale"] = rationale
85
+ return self
86
+
87
+ def with_sequence(self, sequence: int) -> AllocationBuilder:
88
+ """Set ordering hint for multi-line-item plans.
89
+
90
+ Args:
91
+ sequence: 1-based ordering position
92
+ """
93
+ self._data["sequence"] = sequence
94
+ return self
95
+
96
+ def with_tags(self, tags: list[str]) -> AllocationBuilder:
97
+ """Add categorical tags.
98
+
99
+ Args:
100
+ tags: Tags like 'desktop', 'mobile', 'german'
101
+ """
102
+ self._data["tags"] = tags
103
+ return self
104
+
105
+ def build(self) -> dict[str, Any]:
106
+ """Build the allocation dict."""
107
+ return self._data.copy()
108
+
109
+
110
+ class ProposalBuilder:
111
+ """Builder for ADCP Proposals.
112
+
113
+ Helps construct valid proposals for get_products responses. Proposals
114
+ represent recommended media plans with budget allocations.
115
+
116
+ Example:
117
+ proposal = (
118
+ ProposalBuilder("Q1 Brand Campaign")
119
+ .with_description("Balanced awareness campaign")
120
+ .add_allocation("product-1", 60)
121
+ .with_rationale("High-impact display")
122
+ .add_allocation("product-2", 40)
123
+ .with_rationale("Contextual targeting")
124
+ .with_budget_guidance(min=10000, recommended=25000, max=50000)
125
+ .build()
126
+ )
127
+ """
128
+
129
+ def __init__(self, name: str, proposal_id: str | None = None):
130
+ """Create a new proposal builder.
131
+
132
+ Args:
133
+ name: Human-readable name for the proposal
134
+ proposal_id: Unique ID (auto-generated if not provided)
135
+ """
136
+ self._name = name
137
+ self._proposal_id = proposal_id or f"proposal-{uuid4().hex[:8]}"
138
+ self._description: str | None = None
139
+ self._brief_alignment: str | None = None
140
+ self._expires_at: datetime | None = None
141
+ self._allocations: list[dict[str, Any]] = []
142
+ self._budget_guidance: dict[str, Any] | None = None
143
+ self._current_allocation: AllocationBuilder | None = None
144
+ self._ext: dict[str, Any] | None = None
145
+
146
+ def with_description(self, description: str) -> ProposalBuilder:
147
+ """Add description explaining the proposal strategy.
148
+
149
+ Args:
150
+ description: What the proposal achieves
151
+ """
152
+ self._finalize_allocation()
153
+ self._description = description
154
+ return self
155
+
156
+ def with_brief_alignment(self, alignment: str) -> ProposalBuilder:
157
+ """Explain how proposal aligns with campaign brief.
158
+
159
+ Args:
160
+ alignment: Alignment explanation
161
+ """
162
+ self._finalize_allocation()
163
+ self._brief_alignment = alignment
164
+ return self
165
+
166
+ def expires_in(self, days: int = 7) -> ProposalBuilder:
167
+ """Set expiration relative to now.
168
+
169
+ Args:
170
+ days: Number of days until expiration
171
+ """
172
+ self._finalize_allocation()
173
+ self._expires_at = datetime.now(timezone.utc) + timedelta(days=days)
174
+ return self
175
+
176
+ def expires_at(self, expires: datetime) -> ProposalBuilder:
177
+ """Set absolute expiration time.
178
+
179
+ Args:
180
+ expires: When the proposal expires
181
+ """
182
+ self._finalize_allocation()
183
+ self._expires_at = expires
184
+ return self
185
+
186
+ def add_allocation(
187
+ self,
188
+ product_id: str,
189
+ allocation_percentage: float,
190
+ ) -> ProposalBuilder:
191
+ """Add a product allocation.
192
+
193
+ After calling this, chain allocation methods (with_rationale, etc.)
194
+ before adding another allocation or calling build().
195
+
196
+ Args:
197
+ product_id: ID of the product
198
+ allocation_percentage: Percentage of budget (0-100)
199
+
200
+ Returns:
201
+ Self for method chaining
202
+ """
203
+ self._finalize_allocation()
204
+ self._current_allocation = AllocationBuilder(product_id, allocation_percentage)
205
+ return self
206
+
207
+ def with_pricing_option(self, pricing_option_id: str) -> ProposalBuilder:
208
+ """Set pricing option for current allocation."""
209
+ if self._current_allocation:
210
+ self._current_allocation.with_pricing_option(pricing_option_id)
211
+ return self
212
+
213
+ def with_rationale(self, rationale: str) -> ProposalBuilder:
214
+ """Add rationale for current allocation."""
215
+ if self._current_allocation:
216
+ self._current_allocation.with_rationale(rationale)
217
+ return self
218
+
219
+ def with_sequence(self, sequence: int) -> ProposalBuilder:
220
+ """Set sequence for current allocation."""
221
+ if self._current_allocation:
222
+ self._current_allocation.with_sequence(sequence)
223
+ return self
224
+
225
+ def with_tags(self, tags: list[str]) -> ProposalBuilder:
226
+ """Add tags for current allocation."""
227
+ if self._current_allocation:
228
+ self._current_allocation.with_tags(tags)
229
+ return self
230
+
231
+ def with_budget_guidance(
232
+ self,
233
+ *,
234
+ min: float | None = None,
235
+ recommended: float | None = None,
236
+ max: float | None = None,
237
+ currency: str = "USD",
238
+ ) -> ProposalBuilder:
239
+ """Add budget guidance for the proposal.
240
+
241
+ Args:
242
+ min: Minimum recommended budget
243
+ recommended: Optimal budget
244
+ max: Maximum before diminishing returns
245
+ currency: ISO 4217 currency code
246
+ """
247
+ self._finalize_allocation()
248
+ self._budget_guidance = {
249
+ "currency": currency,
250
+ }
251
+ if min is not None:
252
+ self._budget_guidance["min"] = min
253
+ if recommended is not None:
254
+ self._budget_guidance["recommended"] = recommended
255
+ if max is not None:
256
+ self._budget_guidance["max"] = max
257
+ return self
258
+
259
+ def with_extension(self, ext: dict[str, Any]) -> ProposalBuilder:
260
+ """Add extension data.
261
+
262
+ Args:
263
+ ext: Extension object
264
+ """
265
+ self._finalize_allocation()
266
+ self._ext = ext
267
+ return self
268
+
269
+ def _finalize_allocation(self) -> None:
270
+ """Finalize current allocation and add to list."""
271
+ if self._current_allocation:
272
+ self._allocations.append(self._current_allocation.build())
273
+ self._current_allocation = None
274
+
275
+ def build(self) -> dict[str, Any]:
276
+ """Build the proposal dict.
277
+
278
+ Returns:
279
+ Proposal as a dict ready for use in get_products response
280
+
281
+ Raises:
282
+ ValueError: If allocations don't sum to 100
283
+ """
284
+ self._finalize_allocation()
285
+
286
+ if not self._allocations:
287
+ raise ValueError("Proposal must have at least one allocation")
288
+
289
+ total = sum(a["allocation_percentage"] for a in self._allocations)
290
+ if abs(total - 100.0) > 0.01:
291
+ raise ValueError(
292
+ f"Allocation percentages must sum to 100, got {total}"
293
+ )
294
+
295
+ proposal: dict[str, Any] = {
296
+ "proposal_id": self._proposal_id,
297
+ "name": self._name,
298
+ "allocations": self._allocations,
299
+ }
300
+
301
+ if self._description:
302
+ proposal["description"] = self._description
303
+ if self._brief_alignment:
304
+ proposal["brief_alignment"] = self._brief_alignment
305
+ if self._expires_at:
306
+ proposal["expires_at"] = self._expires_at.isoformat()
307
+ if self._budget_guidance:
308
+ proposal["total_budget_guidance"] = self._budget_guidance
309
+ if self._ext:
310
+ proposal["ext"] = self._ext
311
+
312
+ return proposal
313
+
314
+ def validate(self) -> list[str]:
315
+ """Validate the proposal without building.
316
+
317
+ Returns:
318
+ List of validation errors (empty if valid)
319
+ """
320
+ errors: list[str] = []
321
+
322
+ if self._current_allocation:
323
+ allocations = self._allocations + [self._current_allocation.build()]
324
+ else:
325
+ allocations = self._allocations
326
+
327
+ if not allocations:
328
+ errors.append("Proposal must have at least one allocation")
329
+ else:
330
+ total = sum(a["allocation_percentage"] for a in allocations)
331
+ if abs(total - 100.0) > 0.01:
332
+ errors.append(f"Allocation percentages must sum to 100, got {total}")
333
+
334
+ return errors