Skip to content

Commit 3ee0ba3

Browse files
authored
Merge pull request #1494 from PolicyEngine/warn-drop-deprecated-inputs
Warn and drop deprecated inputs instead of crashing
2 parents f5bccde + 0532642 commit 3ee0ba3

5 files changed

Lines changed: 414 additions & 2 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added a deprecation registry and warn-and-drop layer for inbound /calculate payloads. Removed model variables (e.g. `medical_out_of_pocket_expenses`, deleted in policyengine-us 1.673.0) are now stripped before the engine runs, and the response includes a structured warning telling partners which variable was ignored and how to migrate. Previously these requests returned HTTP 500 with `VariableNotFoundError`.

policyengine_household_api/endpoints/household.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
HouseholdModelUK,
1515
HouseholdModelUS,
1616
)
17+
from policyengine_household_api.utils.deprecated_inputs import (
18+
drop_deprecated_inputs,
19+
)
1720
from policyengine_household_api.utils.validate_country import validate_country
1821

1922

@@ -124,6 +127,11 @@ def get_calculate(country_id: str, add_missing: bool = False) -> Response:
124127

125128
country = COUNTRIES.get(country_id)
126129

130+
# Strip deprecated inputs before validation so partners who still pass
131+
# removed/renamed variables get a warning + working response instead
132+
# of a `VariableNotFoundError` HTTP 500.
133+
deprecation_warnings = drop_deprecated_inputs(household_json)
134+
127135
# Validate inbound payload shape before reaching the compute layer.
128136
try:
129137
_validate_household_payload(country_id, household_json)
@@ -169,10 +177,13 @@ def get_calculate(country_id: str, add_missing: bool = False) -> Response:
169177
policyengine_bundle=dict(country.policyengine_bundle),
170178
)
171179

172-
if period_warnings:
180+
warning_messages = [w.message for w in deprecation_warnings] + [
181+
w.message for w in period_warnings
182+
]
183+
if warning_messages:
173184
# Serialize to strings on the wire; the structured dataclasses
174185
# stay available for any future caller that wants the fields.
175-
response_body["warnings"] = [w.message for w in period_warnings]
186+
response_body["warnings"] = warning_messages
176187

177188
if enable_ai_explainer:
178189
response_body["computation_tree_uuid"] = str(computation_tree_uuid)
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""Detect and drop deprecated input variables before they reach the engine.
2+
3+
Without this, a partner who passes a removed model variable (e.g.
4+
``medical_out_of_pocket_expenses``, deleted in policyengine-us 1.673.0)
5+
crashes the simulation with ``VariableNotFoundError`` (HTTP 500). Dropping
6+
the input and surfacing a structured warning gives partners a soft
7+
landing — every other output computes normally; only outputs that
8+
depended on the deprecated input fall back to defaults.
9+
"""
10+
11+
from dataclasses import dataclass
12+
13+
14+
# Registry of removed/renamed model variables that legacy partner traffic
15+
# may still pass. The value is a one-line migration hint surfaced verbatim
16+
# in the warning message — keep it actionable.
17+
DEPRECATED_VARIABLES: dict[str, str] = {
18+
"medical_out_of_pocket_expenses": (
19+
"Removed in policyengine-us 1.673.0. Migrate non-premium spending "
20+
"to `other_medical_expenses` and premium spending to "
21+
"`health_insurance_premiums`."
22+
),
23+
}
24+
25+
26+
@dataclass(frozen=True)
27+
class DeprecatedVariableWarning:
28+
"""A removed/renamed variable was supplied; dropped before the engine ran."""
29+
30+
variable: str
31+
entity_plural: str
32+
entity_id: str
33+
hint: str
34+
35+
@property
36+
def message(self) -> str:
37+
location = f"`{self.entity_plural}/{self.entity_id}`"
38+
if self.entity_plural == "axes":
39+
location = f"`axes[{self.entity_id}].name`"
40+
return (
41+
f"Input `{self.variable}` on {location} is deprecated and was "
42+
f"ignored for this calculation. {self.hint}"
43+
)
44+
45+
46+
def drop_deprecated_inputs(
47+
household: dict,
48+
) -> list[DeprecatedVariableWarning]:
49+
"""Strip deprecated input keys from ``household`` in place.
50+
51+
Returns one warning per (entity, deprecated-key) occurrence. Mutates
52+
``household`` so downstream validation and the simulation never see
53+
the deprecated keys.
54+
55+
Non-dict inputs are returned unchanged with no warnings; the
56+
Pydantic schema check that runs immediately after will reject the
57+
bad shape with a 400.
58+
"""
59+
warnings: list[DeprecatedVariableWarning] = []
60+
61+
if not isinstance(household, dict):
62+
return warnings
63+
64+
for entity_plural, entity_group in household.items():
65+
if entity_plural == "axes":
66+
continue
67+
if not isinstance(entity_group, dict):
68+
continue
69+
for entity_id, variables in entity_group.items():
70+
if not isinstance(variables, dict):
71+
continue
72+
for variable_name in list(variables.keys()):
73+
hint = DEPRECATED_VARIABLES.get(variable_name)
74+
if hint is None:
75+
continue
76+
warnings.append(
77+
DeprecatedVariableWarning(
78+
variable=variable_name,
79+
entity_plural=entity_plural,
80+
entity_id=entity_id,
81+
hint=hint,
82+
)
83+
)
84+
del variables[variable_name]
85+
86+
_drop_deprecated_axes(household, warnings)
87+
88+
return warnings
89+
90+
91+
def _drop_deprecated_axes(
92+
household: dict, warnings: list[DeprecatedVariableWarning]
93+
) -> None:
94+
axes = household.get("axes")
95+
if not isinstance(axes, list):
96+
return
97+
98+
changed = False
99+
retained_entries = []
100+
101+
for entry_index, entry in enumerate(axes):
102+
if isinstance(entry, list):
103+
retained_axes = []
104+
for axis_index, axis in enumerate(entry):
105+
location = f"{entry_index}][{axis_index}"
106+
if _is_deprecated_axis(axis, location, warnings):
107+
changed = True
108+
continue
109+
retained_axes.append(axis)
110+
if retained_axes:
111+
retained_entries.append(retained_axes)
112+
continue
113+
114+
location = str(entry_index)
115+
if _is_deprecated_axis(entry, location, warnings):
116+
changed = True
117+
continue
118+
retained_entries.append(entry)
119+
120+
if not changed:
121+
return
122+
if retained_entries:
123+
household["axes"] = retained_entries
124+
else:
125+
del household["axes"]
126+
127+
128+
def _is_deprecated_axis(
129+
axis, location: str, warnings: list[DeprecatedVariableWarning]
130+
) -> bool:
131+
if not isinstance(axis, dict):
132+
return False
133+
134+
variable_name = axis.get("name")
135+
hint = DEPRECATED_VARIABLES.get(variable_name)
136+
if hint is None:
137+
return False
138+
139+
warnings.append(
140+
DeprecatedVariableWarning(
141+
variable=variable_name,
142+
entity_plural="axes",
143+
entity_id=location,
144+
hint=hint,
145+
)
146+
)
147+
return True

tests/unit/endpoints/test_household.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,80 @@ def snap_with(employment_income: int) -> float:
149149
"reaching the engine."
150150
)
151151

152+
def test__deprecated_input__is_dropped_with_warning(self, client):
153+
"""A removed model variable in the payload must not crash the request.
154+
155+
`medical_out_of_pocket_expenses` was removed from policyengine-us in
156+
1.673.0. Without warn-and-drop, partner traffic carrying it raises
157+
`VariableNotFoundError` → HTTP 500 and the partner sees no output
158+
at all. With warn-and-drop, the field is stripped before the engine
159+
sees it and a structured warning is appended to the response.
160+
"""
161+
household = {
162+
**valid_household_requesting_ctc_calculation,
163+
"people": {
164+
"you": {
165+
"age": {"2024": 40},
166+
"medical_out_of_pocket_expenses": {"2024": 0},
167+
}
168+
},
169+
}
170+
171+
response = client.post(
172+
"/us/calculate",
173+
json={"household": household},
174+
headers=self.auth_headers,
175+
)
176+
177+
assert response.status_code == 200
178+
payload = json.loads(response.data)
179+
assert payload["status"] == "ok"
180+
# CTC still computes — non-medical outputs are unaffected.
181+
assert (
182+
payload["result"]["tax_units"]["tax_unit"]["ctc"]["2024"]
183+
is not None
184+
)
185+
# The deprecation warning is surfaced in the response.
186+
assert "warnings" in payload
187+
assert any(
188+
"medical_out_of_pocket_expenses" in w and "deprecated" in w.lower()
189+
for w in payload["warnings"]
190+
)
191+
192+
def test__deprecated_axis_name__is_dropped_with_warning(self, client):
193+
household = {
194+
**valid_household_requesting_ctc_calculation,
195+
"axes": [
196+
[
197+
{
198+
"name": "medical_out_of_pocket_expenses",
199+
"period": "2024",
200+
"min": 0,
201+
"max": 1000,
202+
"count": 2,
203+
}
204+
]
205+
],
206+
}
207+
208+
response = client.post(
209+
"/us/calculate",
210+
json={"household": household},
211+
headers=self.auth_headers,
212+
)
213+
214+
assert response.status_code == 200
215+
payload = json.loads(response.data)
216+
assert payload["status"] == "ok"
217+
assert (
218+
payload["result"]["tax_units"]["tax_unit"]["ctc"]["2024"]
219+
is not None
220+
)
221+
assert any(
222+
"medical_out_of_pocket_expenses" in w and "axes[0][0].name" in w
223+
for w in payload["warnings"]
224+
)
225+
152226
def test__given_ai_explainer_tracer_fails__returns_500(self, client):
153227
with patch(
154228
"policyengine_household_api.country.generate_computation_tree",

0 commit comments

Comments
 (0)