Skip to content

Commit 89f2cff

Browse files
bokelleyclaude
andcommitted
feat(types): retain GeoPostalArea as deprecated alias to PostalArea legacy arm
AdCP 3.1.0-rc.10 removed the inline {system, values} GeoPostalArea type in favor of the PostalArea union (native per-country arm + legacy arm). The legacy arm (PostalArea5) is shape-compatible with the removed GeoPostalArea and accepts the same country-fused tokens (us_zip, gb_outward, ...), so the removal can be a graceful deprecation rather than a hard break. Add a PEP 562 module-level __getattr__ to adcp.types that resolves the removed GeoPostalArea name to the PostalArea legacy arm, emitting a DeprecationWarning that names the PostalArea migration target. Old import and construction code keeps working, and the constructed value validates against the PostalArea union via the legacy arm with no spurious country injection. The alias stays out of __all__, so the public-API snapshot is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b05bde4 commit 89f2cff

2 files changed

Lines changed: 181 additions & 0 deletions

File tree

src/adcp/types/__init__.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1645,3 +1645,41 @@ def __init__(self, *args: object, **kwargs: object) -> None:
16451645
"generated",
16461646
"aliases",
16471647
]
1648+
1649+
1650+
# Deprecated backward-compat aliases (kept out of ``__all__``).
1651+
#
1652+
# AdCP 3.1.0-rc.10 restructured postal-area targeting: the inline
1653+
# ``{system, values}`` ``GeoPostalArea`` shape became the ``PostalArea``
1654+
# union (``core/postal-area.json``) of a native per-country arm
1655+
# (``{country, system, values}``) and a legacy arm (``{system, values}``
1656+
# with country-fused tokens like ``us_zip``). The legacy arm
1657+
# (``PostalArea5``) has the same shape and accepts the same fused tokens as
1658+
# the removed ``GeoPostalArea``, so old construction code keeps working when
1659+
# ``GeoPostalArea`` resolves to it. The constructed value still validates
1660+
# against the ``PostalArea`` union via the legacy arm.
1661+
_DEPRECATION_MESSAGE = (
1662+
"GeoPostalArea is deprecated; use PostalArea (the native per-country arm "
1663+
"with country + postal system) instead. The legacy alias maps to the "
1664+
"PostalArea legacy arm ({system, values} with country-fused tokens like "
1665+
"'us_zip') and is retained for backward compatibility and removed in a "
1666+
"future major."
1667+
)
1668+
1669+
1670+
def __getattr__(name: str) -> object:
1671+
"""Resolve deprecated type aliases (PEP 562).
1672+
1673+
Keeps the removed ``GeoPostalArea`` name importable from ``adcp.types``
1674+
while emitting a :class:`DeprecationWarning` that names the migration
1675+
target. It resolves to the ``PostalArea`` union's legacy arm
1676+
(``PostalArea5``), which is shape-compatible with the removed type.
1677+
"""
1678+
if name == "GeoPostalArea":
1679+
import warnings
1680+
1681+
warnings.warn(_DEPRECATION_MESSAGE, DeprecationWarning, stacklevel=2)
1682+
from adcp.types._generated import PostalArea5
1683+
1684+
return PostalArea5
1685+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

tests/test_postal_area_compat.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""Backward-compat tests for the deprecated ``GeoPostalArea`` alias.
2+
3+
AdCP 3.1.0-rc.10 restructured postal-area targeting: the inline
4+
``{system, values}`` ``GeoPostalArea`` shape was replaced by the
5+
``PostalArea`` union of a native per-country arm (``{country, system,
6+
values}``) and a legacy arm (``{system, values}`` with country-fused tokens
7+
like ``us_zip``). ``GeoPostalArea`` is retained as a deprecated alias to the
8+
legacy arm so old import and construction code keeps working.
9+
10+
These tests exercise the public surface (``from adcp.types import ...``) and
11+
the public ``PostalArea`` / ``TargetingOverlay`` types — the wire contract,
12+
not the generated class layout.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import warnings
18+
19+
import pytest
20+
21+
from adcp.types import PostalArea, TargetingOverlay
22+
23+
# The 11 legacy country-fused tokens that the removed GeoPostalArea accepted.
24+
LEGACY_TOKENS = [
25+
"us_zip",
26+
"us_zip_plus_four",
27+
"gb_outward",
28+
"gb_full",
29+
"ca_fsa",
30+
"ca_full",
31+
"de_plz",
32+
"fr_code_postal",
33+
"au_postcode",
34+
"ch_plz",
35+
"at_plz",
36+
]
37+
38+
39+
def _geo_postal_area_cls() -> type:
40+
"""Access the deprecated alias without raising the DeprecationWarning."""
41+
import adcp.types
42+
43+
with warnings.catch_warnings():
44+
warnings.simplefilter("ignore", DeprecationWarning)
45+
return adcp.types.GeoPostalArea
46+
47+
48+
def test_geo_postal_area_import_still_works():
49+
"""``from adcp.types import GeoPostalArea`` does not raise ImportError."""
50+
import adcp.types
51+
52+
with warnings.catch_warnings():
53+
warnings.simplefilter("ignore", DeprecationWarning)
54+
# Attribute access exercises the same PEP 562 ``__getattr__`` path as
55+
# a ``from adcp.types import GeoPostalArea`` statement.
56+
assert adcp.types.GeoPostalArea is not None
57+
58+
59+
def test_geo_postal_area_access_emits_deprecation_warning():
60+
"""Accessing the alias emits a DeprecationWarning naming the migration."""
61+
import adcp.types
62+
63+
with pytest.warns(DeprecationWarning, match="GeoPostalArea is deprecated"):
64+
alias = adcp.types.GeoPostalArea
65+
66+
assert alias is not None
67+
68+
69+
def test_geo_postal_area_warning_names_postalarea_migration():
70+
"""The warning points migrators at the PostalArea native arm."""
71+
import adcp.types
72+
73+
with pytest.warns(DeprecationWarning) as record:
74+
_ = adcp.types.GeoPostalArea
75+
76+
message = str(record[0].message)
77+
assert "PostalArea" in message
78+
assert "backward compatibility" in message
79+
assert "future major" in message
80+
81+
82+
def test_old_construction_shape_still_works():
83+
"""Constructing the old ``{system, values}`` shape succeeds."""
84+
geo_postal_area = _geo_postal_area_cls()
85+
86+
area = geo_postal_area(system="us_zip", values=["10001", "10002"])
87+
88+
dumped = area.model_dump(mode="json")
89+
assert dumped == {"system": "us_zip", "values": ["10001", "10002"]}
90+
91+
92+
@pytest.mark.parametrize("token", LEGACY_TOKENS)
93+
def test_every_legacy_token_constructs(token: str):
94+
"""All 11 removed-type fused tokens still construct via the alias."""
95+
geo_postal_area = _geo_postal_area_cls()
96+
97+
area = geo_postal_area(system=token, values=["X1"])
98+
assert area.model_dump(mode="json") == {"system": token, "values": ["X1"]}
99+
100+
101+
def test_constructed_value_validates_against_postalarea_union_legacy_arm():
102+
"""The constructed legacy value validates through the PostalArea union.
103+
104+
Passing the constructed instance preserves the legacy arm — the union
105+
resolves to the legacy ``{system, values}`` shape rather than injecting a
106+
spurious ``country``.
107+
"""
108+
geo_postal_area = _geo_postal_area_cls()
109+
110+
area = geo_postal_area(system="gb_outward", values=["SW1A"])
111+
112+
validated = PostalArea.model_validate(area)
113+
# Legacy arm round-trips faithfully with no injected country field.
114+
assert validated.model_dump(mode="json") == {
115+
"system": "gb_outward",
116+
"values": ["SW1A"],
117+
}
118+
119+
120+
def test_legacy_value_accepted_where_geo_postal_areas_used():
121+
"""Legacy postal areas are accepted in ``TargetingOverlay.geo_postal_areas``."""
122+
geo_postal_area = _geo_postal_area_cls()
123+
124+
overlay = TargetingOverlay(
125+
geo_postal_areas=[
126+
geo_postal_area(system="us_zip", values=["10001"]),
127+
geo_postal_area(system="gb_outward", values=["SW1A"]),
128+
]
129+
)
130+
131+
dumped = overlay.model_dump(mode="json", exclude_none=True)["geo_postal_areas"]
132+
assert dumped == [
133+
{"system": "us_zip", "values": ["10001"]},
134+
{"system": "gb_outward", "values": ["SW1A"]},
135+
]
136+
137+
138+
def test_unknown_attribute_still_raises_attribute_error():
139+
"""The shim does not swallow genuinely missing attributes."""
140+
import adcp.types
141+
142+
with pytest.raises(AttributeError):
143+
_ = adcp.types.DefinitelyNotAPublicType

0 commit comments

Comments
 (0)