@@ -1576,6 +1576,268 @@ def test_injected_validator_enforces_conditional_bounds(self):
15761576 total (type = "total" , amount = - 5 )
15771577
15781578
1579+ class ConditionalArrayRetypingInjectorTest (unittest .TestCase ):
1580+ """A discriminator retyping an array property's items to a different
1581+ referenced schema file is a third if/then shape, distinct from
1582+ conditional-required and conditional-bounds above. This mirrors
1583+ fulfillment_method.json: the base `destinations` property is typed via
1584+ `items.$ref` to fulfillment_destination.json, but a `shipping` method's
1585+ destinations should really be shipping_destination.json items (postal
1586+ address fields, `type` const `shipping_address`) and a `pickup`
1587+ method's should really be location_destination.json items (`type`
1588+ const `business_location`). The generator drops both retyping branches
1589+ entirely -- no scanner in this module ever looked for this shape.
1590+ """
1591+
1592+ MODULE = (
1593+ "from __future__ import annotations\n "
1594+ "\n "
1595+ "from pydantic import BaseModel, ConfigDict\n "
1596+ "\n "
1597+ "from . import destination\n "
1598+ "\n "
1599+ "\n "
1600+ "class Method(BaseModel):\n "
1601+ ' model_config = ConfigDict(extra="allow")\n '
1602+ " type: str\n "
1603+ " destinations: list[destination.Destination] | None = None\n "
1604+ )
1605+ RULES = [
1606+ {
1607+ "discriminator" : "type" ,
1608+ "values" : ["shipping" ],
1609+ "field" : "destinations" ,
1610+ "required" : ["id" , "type" ],
1611+ "consts" : {"type" : "shipping_address" },
1612+ },
1613+ {
1614+ "discriminator" : "type" ,
1615+ "values" : ["pickup" ],
1616+ "field" : "destinations" ,
1617+ "required" : ["type" ],
1618+ "consts" : {"type" : "business_location" },
1619+ },
1620+ ]
1621+
1622+ def _schema (self ):
1623+ return {
1624+ "title" : "Method" ,
1625+ "type" : "object" ,
1626+ "properties" : {
1627+ "type" : {"type" : "string" },
1628+ "destinations" : {
1629+ "type" : "array" ,
1630+ "items" : {"$ref" : "destination.json" },
1631+ },
1632+ },
1633+ "allOf" : [
1634+ {
1635+ "if" : {
1636+ "properties" : {"type" : {"const" : "shipping" }},
1637+ "required" : ["type" ],
1638+ },
1639+ "then" : {
1640+ "properties" : {
1641+ "destinations" : {
1642+ "type" : "array" ,
1643+ "items" : {"$ref" : "shipping_destination.json" },
1644+ }
1645+ }
1646+ },
1647+ },
1648+ {
1649+ "if" : {
1650+ "properties" : {"type" : {"const" : "pickup" }},
1651+ "required" : ["type" ],
1652+ },
1653+ "then" : {
1654+ "properties" : {
1655+ "destinations" : {
1656+ "type" : "array" ,
1657+ "items" : {"$ref" : "location_destination.json" },
1658+ }
1659+ }
1660+ },
1661+ },
1662+ ],
1663+ }
1664+
1665+ def _write_schema_tree (self , tmp ):
1666+ Path (tmp , "method.json" ).write_text (json .dumps (self ._schema ()))
1667+ Path (tmp , "destination.json" ).write_text (
1668+ json .dumps (
1669+ {
1670+ "title" : "Destination" ,
1671+ "type" : "object" ,
1672+ "required" : ["id" , "type" ],
1673+ "properties" : {
1674+ "id" : {"type" : "string" },
1675+ "type" : {"type" : "string" },
1676+ },
1677+ }
1678+ )
1679+ )
1680+ Path (tmp , "shipping_destination.json" ).write_text (
1681+ json .dumps (
1682+ {
1683+ "title" : "Shipping Destination" ,
1684+ "type" : "object" ,
1685+ "required" : ["id" , "type" ],
1686+ "properties" : {
1687+ "id" : {"type" : "string" },
1688+ "type" : {"type" : "string" , "const" : "shipping_address" },
1689+ },
1690+ "allOf" : [{"$ref" : "postal_address.json" }],
1691+ }
1692+ )
1693+ )
1694+ Path (tmp , "location_destination.json" ).write_text (
1695+ json .dumps (
1696+ {
1697+ "title" : "Business Location Destination" ,
1698+ "type" : "object" ,
1699+ "required" : ["type" ],
1700+ "properties" : {
1701+ "type" : {"type" : "string" , "const" : "business_location" }
1702+ },
1703+ "allOf" : [{"$ref" : "location_summary.json" }],
1704+ }
1705+ )
1706+ )
1707+
1708+ def test_schema_scan_reads_both_retyping_branches (self ):
1709+ with tempfile .TemporaryDirectory () as tmp :
1710+ self ._write_schema_tree (tmp )
1711+ found = postprocess_models .find_conditional_array_retyping (
1712+ Path (tmp )
1713+ )
1714+ self .assertEqual (found , {"Method" : self .RULES })
1715+
1716+ def test_schema_scan_ignores_branch_matching_the_base_ref (self ):
1717+ # A then.properties.<field>.items.$ref identical to the base ref is
1718+ # not a retype -- nothing to approximate. The sibling pickup branch
1719+ # (still a genuine retype) is unaffected.
1720+ schema = self ._schema ()
1721+ schema ["allOf" ][0 ]["then" ]["properties" ]["destinations" ]["items" ][
1722+ "$ref"
1723+ ] = "destination.json"
1724+ with tempfile .TemporaryDirectory () as tmp :
1725+ self ._write_schema_tree (tmp )
1726+ Path (tmp , "method.json" ).write_text (json .dumps (schema ))
1727+ found = postprocess_models .find_conditional_array_retyping (
1728+ Path (tmp )
1729+ )
1730+ self .assertEqual (found , {"Method" : [self .RULES [1 ]]})
1731+
1732+ def test_schema_scan_skips_rule_whose_field_was_stripped (self ):
1733+ # A request variant that omits `destinations` entirely (as
1734+ # fulfillment_method_create_request.json does) makes the rule
1735+ # inapplicable, not malformed -- no warning, and no rule recorded
1736+ # for the variant's own class.
1737+ schema = self ._schema ()
1738+ schema ["title" ] = "Method Create Request"
1739+ del schema ["properties" ]["destinations" ]
1740+ with tempfile .TemporaryDirectory () as tmp :
1741+ self ._write_schema_tree (tmp )
1742+ Path (tmp , "method_create_request.json" ).write_text (
1743+ json .dumps (schema )
1744+ )
1745+ stderr = io .StringIO ()
1746+ with contextlib .redirect_stderr (stderr ):
1747+ found = postprocess_models .find_conditional_array_retyping (
1748+ Path (tmp )
1749+ )
1750+ self .assertNotIn ("MethodCreateRequest" , found )
1751+ self .assertEqual (found , {"Method" : self .RULES })
1752+ self .assertNotIn ("unsupported" , stderr .getvalue ())
1753+
1754+ def test_schema_scan_warns_when_retyped_ref_cannot_be_loaded (self ):
1755+ schema = self ._schema ()
1756+ with tempfile .TemporaryDirectory () as tmp :
1757+ Path (tmp , "method.json" ).write_text (json .dumps (schema ))
1758+ Path (tmp , "destination.json" ).write_text (
1759+ json .dumps ({"title" : "Destination" , "type" : "object" })
1760+ )
1761+ # shipping_destination.json / location_destination.json are
1762+ # deliberately absent.
1763+ stderr = io .StringIO ()
1764+ with contextlib .redirect_stderr (stderr ):
1765+ found = postprocess_models .find_conditional_array_retyping (
1766+ Path (tmp )
1767+ )
1768+ self .assertEqual (found , {})
1769+ self .assertIn ("could not be loaded" , stderr .getvalue ())
1770+
1771+ def test_injection_is_idempotent (self ):
1772+ once = postprocess_models .inject_conditional_array_retyping (
1773+ self .MODULE , "Method" , self .RULES
1774+ )
1775+ twice = postprocess_models .inject_conditional_array_retyping (
1776+ once , "Method" , self .RULES
1777+ )
1778+ self .assertEqual (once , twice )
1779+
1780+ @unittest .skipUnless (HAVE_SDK , "executing the module needs pydantic" )
1781+ def test_injected_validator_enforces_retyping (self ):
1782+ module = (
1783+ "from __future__ import annotations\n "
1784+ "\n "
1785+ "from pydantic import BaseModel, ConfigDict\n "
1786+ "\n "
1787+ "\n "
1788+ "class Destination(BaseModel):\n "
1789+ ' model_config = ConfigDict(extra="allow")\n '
1790+ " type: str\n "
1791+ " id: str\n "
1792+ "\n "
1793+ "\n "
1794+ "class Method(BaseModel):\n "
1795+ ' model_config = ConfigDict(extra="allow")\n '
1796+ " type: str\n "
1797+ " destinations: list[Destination] | None = None\n "
1798+ )
1799+ out = postprocess_models .inject_conditional_array_retyping (
1800+ module , "Method" , self .RULES
1801+ )
1802+ namespace : dict = {}
1803+ exec (compile (out , "<injected>" , "exec" ), namespace ) # noqa: S102
1804+ # Forward reference (from __future__ import annotations): Method's
1805+ # "destinations: list[Destination]" annotation resolves once both
1806+ # classes exist in the exec'd namespace.
1807+ namespace ["Method" ].model_rebuild (_types_namespace = namespace )
1808+ method_cls = namespace ["Method" ]
1809+ destination_cls = namespace ["Destination" ]
1810+ with self .assertRaises (ValidationError ):
1811+ method_cls (
1812+ type = "shipping" ,
1813+ destinations = [
1814+ destination_cls (type = "business_location" , id = "d1" )
1815+ ],
1816+ )
1817+ with self .assertRaises (ValidationError ):
1818+ method_cls (
1819+ type = "pickup" ,
1820+ destinations = [
1821+ destination_cls (type = "shipping_address" , id = "d1" )
1822+ ],
1823+ )
1824+ method_cls (
1825+ type = "shipping" ,
1826+ destinations = [destination_cls (type = "shipping_address" , id = "d1" )],
1827+ )
1828+ method_cls (
1829+ type = "pickup" ,
1830+ destinations = [destination_cls (type = "business_location" , id = "d1" )],
1831+ )
1832+ # A type carrying no rule is unconstrained (open vocabulary).
1833+ method_cls (
1834+ type = "courier" ,
1835+ destinations = [destination_cls (type = "anything" , id = "d1" )],
1836+ )
1837+ # No destinations at all is unconstrained regardless of type.
1838+ method_cls (type = "shipping" )
1839+
1840+
15791841class InjectorTest (unittest .TestCase ):
15801842 """The post-generation injector's own behavior."""
15811843
@@ -2474,5 +2736,97 @@ def test_other_unit_with_nonzero_scale_accepted(self):
24742736 self .assertEqual (unit .scale , 3 )
24752737
24762738
2739+ @unittest .skipUnless (
2740+ HAVE_SDK , "requires the installed package (pip install -e .)"
2741+ )
2742+ class FulfillmentMethodDestinationRetypingSemanticTest (unittest .TestCase ):
2743+ """fulfillment_method.json retypes `destinations` per `type`: a
2744+ `shipping` method's destinations are shipping_destination.json items
2745+ (`type` const `shipping_address`), a `pickup` method's are
2746+ location_destination.json items (`type` const `business_location`).
2747+ The committed FulfillmentMethod model, before this fix, accepted any
2748+ FulfillmentDestination (bare `type: str`, `id: str`) regardless of the
2749+ method's own type, so a `shipping` method could list a
2750+ `business_location` destination and it would validate.
2751+ """
2752+
2753+ def _method (self ):
2754+ from ucp_sdk .models .schemas .shopping .types .fulfillment_method import (
2755+ FulfillmentMethod ,
2756+ )
2757+
2758+ return FulfillmentMethod
2759+
2760+ def _destination (self ):
2761+ from ucp_sdk .models .schemas .shopping .types .fulfillment_destination import (
2762+ FulfillmentDestination ,
2763+ )
2764+
2765+ return FulfillmentDestination
2766+
2767+ def test_shipping_method_with_business_location_destination_rejected (
2768+ self ,
2769+ ):
2770+ with self .assertRaises (ValidationError ):
2771+ self ._method ()(
2772+ id = "m1" ,
2773+ type = "shipping" ,
2774+ line_item_ids = ["li1" ],
2775+ destinations = [
2776+ self ._destination ()(type = "business_location" , id = "d1" )
2777+ ],
2778+ )
2779+
2780+ def test_pickup_method_with_shipping_address_destination_rejected (self ):
2781+ with self .assertRaises (ValidationError ):
2782+ self ._method ()(
2783+ id = "m2" ,
2784+ type = "pickup" ,
2785+ line_item_ids = ["li1" ],
2786+ destinations = [
2787+ self ._destination ()(type = "shipping_address" , id = "d1" )
2788+ ],
2789+ )
2790+
2791+ def test_shipping_method_with_shipping_address_destination_accepted (
2792+ self ,
2793+ ):
2794+ method = self ._method ()(
2795+ id = "m1" ,
2796+ type = "shipping" ,
2797+ line_item_ids = ["li1" ],
2798+ destinations = [
2799+ self ._destination ()(type = "shipping_address" , id = "d1" )
2800+ ],
2801+ )
2802+ self .assertEqual (method .destinations [0 ].type , "shipping_address" )
2803+
2804+ def test_pickup_method_with_business_location_destination_accepted (self ):
2805+ method = self ._method ()(
2806+ id = "m2" ,
2807+ type = "pickup" ,
2808+ line_item_ids = ["li1" ],
2809+ destinations = [
2810+ self ._destination ()(type = "business_location" , id = "d1" )
2811+ ],
2812+ )
2813+ self .assertEqual (method .destinations [0 ].type , "business_location" )
2814+
2815+ def test_method_type_outside_the_pinned_vocabulary_is_unconstrained (
2816+ self ,
2817+ ):
2818+ # type is an open vocabulary ("Businesses MAY use additional
2819+ # values"); only shipping/pickup carry a retyping rule.
2820+ self ._method ()(
2821+ id = "m3" ,
2822+ type = "curbside" ,
2823+ line_item_ids = ["li1" ],
2824+ destinations = [self ._destination ()(type = "anything" , id = "d1" )],
2825+ )
2826+
2827+ def test_method_without_destinations_is_unconstrained (self ):
2828+ self ._method ()(id = "m4" , type = "shipping" , line_item_ids = ["li1" ])
2829+
2830+
24772831if __name__ == "__main__" :
24782832 unittest .main ()
0 commit comments