Skip to content

Commit 8dcdbaf

Browse files
authored
Merge pull request #3576 from PolicyEngine/codex/port-deprecated-input-stripping
Strip deprecated calculate inputs
2 parents 70c41cd + 661538d commit 8dcdbaf

8 files changed

Lines changed: 1035 additions & 2 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Strip deprecated `medical_out_of_pocket_expenses` inputs from calculate requests before simulation and return a warning, and return structured `400` errors for unrecognized calculate inputs instead of surfacing engine errors.

policyengine_api/endpoints/household.py

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
from policyengine_api.constants import COUNTRY_PACKAGE_VERSIONS
55
import logging
66
from datetime import date
7+
from policyengine_api.utils.deprecated_inputs import drop_deprecated_inputs
8+
from policyengine_api.utils.input_validation import (
9+
find_unrecognized_inputs,
10+
format_unrecognized_inputs_message,
11+
)
712
from policyengine_api.utils.payload_validators import validate_country
813

914

@@ -48,6 +53,28 @@ def add_yearly_variables(household, country_id, countries=None):
4853
return household
4954

5055

56+
def get_invalid_inputs_response(household_json, policy_json, country):
57+
invalid_inputs = find_unrecognized_inputs(
58+
household_json,
59+
policy_json,
60+
country.metadata,
61+
)
62+
if not invalid_inputs:
63+
return None
64+
65+
response_body = dict(
66+
status="error",
67+
message=format_unrecognized_inputs_message(invalid_inputs),
68+
result=None,
69+
errors=[invalid_input.to_dict() for invalid_input in invalid_inputs],
70+
)
71+
return Response(
72+
json.dumps(response_body),
73+
status=400,
74+
mimetype="application/json",
75+
)
76+
77+
5178
def get_household_year(household):
5279
"""Given a household dict, get the household's year
5380
@@ -130,6 +157,8 @@ def get_household_under_policy(country_id: str, household_id: str, policy_id: st
130157
household["household_json"] = add_yearly_variables(
131158
household["household_json"], country_id
132159
)
160+
deprecated_inputs = drop_deprecated_inputs(household["household_json"])
161+
household["household_json"] = deprecated_inputs.household
133162

134163
# Retrieve from the policy table
135164

@@ -153,6 +182,13 @@ def get_household_under_policy(country_id: str, household_id: str, policy_id: st
153182
)
154183

155184
country = get_countries().get(country_id)
185+
invalid_inputs_response = get_invalid_inputs_response(
186+
household["household_json"],
187+
policy["policy_json"],
188+
country,
189+
)
190+
if invalid_inputs_response is not None:
191+
return invalid_inputs_response
156192

157193
try:
158194
result = country.calculate(
@@ -193,11 +229,15 @@ def get_household_under_policy(country_id: str, household_id: str, policy_id: st
193229
(json.dumps(result), country_id, household_id, policy_id),
194230
)
195231

196-
return dict(
232+
response_body = dict(
197233
status="ok",
198234
message=None,
199235
result=result,
200236
)
237+
warning_messages = [w.message for w in deprecated_inputs.warnings]
238+
if warning_messages:
239+
response_body["warnings"] = warning_messages
240+
return response_body
201241

202242

203243
@validate_country
@@ -216,7 +256,21 @@ def get_calculate(country_id: str, add_missing: bool = False) -> dict:
216256
# Add in any missing yearly variables to household_json
217257
household_json = add_yearly_variables(household_json, country_id)
218258

259+
# Strip deprecated inputs from a copy before the engine runs so
260+
# partners who still pass removed/renamed variables get a warning +
261+
# working response instead of a `VariableNotFoundError` HTTP 500.
262+
deprecated_inputs = drop_deprecated_inputs(household_json)
263+
household_json = deprecated_inputs.household
264+
deprecation_warnings = deprecated_inputs.warnings
265+
219266
country = get_countries().get(country_id)
267+
invalid_inputs_response = get_invalid_inputs_response(
268+
household_json,
269+
policy_json,
270+
country,
271+
)
272+
if invalid_inputs_response is not None:
273+
return invalid_inputs_response
220274

221275
try:
222276
result = country.calculate(household_json, policy_json)
@@ -232,8 +286,16 @@ def get_calculate(country_id: str, add_missing: bool = False) -> dict:
232286
mimetype="application/json",
233287
)
234288

235-
return dict(
289+
response_body = dict(
236290
status="ok",
237291
message=None,
238292
result=result,
239293
)
294+
295+
warning_messages = [w.message for w in deprecation_warnings]
296+
if warning_messages:
297+
# Serialize to strings on the wire; the structured dataclasses
298+
# stay available for any future caller that wants the fields.
299+
response_body["warnings"] = warning_messages
300+
301+
return response_body
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
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+
import copy
12+
from dataclasses import dataclass
13+
14+
15+
# Registry of removed/renamed model variables that legacy partner traffic
16+
# may still pass. The value is a one-line migration hint surfaced verbatim
17+
# in the warning message - keep it actionable.
18+
DEPRECATED_VARIABLES: dict[str, str] = {
19+
"medical_out_of_pocket_expenses": (
20+
"Removed in policyengine-us 1.673.0. Migrate non-premium spending "
21+
"to `other_medical_expenses` and premium spending to "
22+
"`health_insurance_premiums`."
23+
),
24+
}
25+
26+
27+
@dataclass(frozen=True)
28+
class DeprecatedVariableWarning:
29+
"""A removed/renamed variable was supplied; dropped before the engine ran."""
30+
31+
variable: str
32+
entity_plural: str
33+
entity_id: str
34+
hint: str
35+
36+
@property
37+
def message(self) -> str:
38+
location = f"`{self.entity_plural}/{self.entity_id}`"
39+
if self.entity_plural == "axes":
40+
location = f"`axes[{self.entity_id}].name`"
41+
return (
42+
f"Input `{self.variable}` on {location} is deprecated and was "
43+
f"ignored for this calculation. {self.hint}"
44+
)
45+
46+
47+
@dataclass(frozen=True)
48+
class DeprecatedInputsResult:
49+
"""A household copy with deprecated inputs removed plus warnings."""
50+
51+
household: dict
52+
warnings: list[DeprecatedVariableWarning]
53+
54+
55+
def drop_deprecated_inputs(
56+
household: dict,
57+
) -> DeprecatedInputsResult:
58+
"""Return a household copy with deprecated input keys stripped.
59+
60+
Returns one warning per (entity, deprecated-key) occurrence. The
61+
caller's ``household`` is never mutated; downstream simulation
62+
receives the returned copy.
63+
64+
Non-dict inputs are returned unchanged with no warnings; downstream
65+
code retains its existing bad-shape behavior.
66+
"""
67+
warnings: list[DeprecatedVariableWarning] = []
68+
69+
if not isinstance(household, dict):
70+
return DeprecatedInputsResult(household=household, warnings=warnings)
71+
72+
cleaned_household = copy.deepcopy(household)
73+
74+
for entity_plural, entity_group in cleaned_household.items():
75+
if entity_plural == "axes":
76+
continue
77+
if not isinstance(entity_group, dict):
78+
continue
79+
for entity_id, variables in entity_group.items():
80+
if not isinstance(variables, dict):
81+
continue
82+
for variable_name in list(variables.keys()):
83+
hint = DEPRECATED_VARIABLES.get(variable_name)
84+
if hint is None:
85+
continue
86+
warnings.append(
87+
DeprecatedVariableWarning(
88+
variable=variable_name,
89+
entity_plural=entity_plural,
90+
entity_id=entity_id,
91+
hint=hint,
92+
)
93+
)
94+
del variables[variable_name]
95+
96+
_drop_deprecated_axes(cleaned_household, warnings)
97+
98+
return DeprecatedInputsResult(household=cleaned_household, warnings=warnings)
99+
100+
101+
def _drop_deprecated_axes(
102+
household: dict, warnings: list[DeprecatedVariableWarning]
103+
) -> None:
104+
axes = household.get("axes")
105+
if not isinstance(axes, list):
106+
return
107+
108+
changed = False
109+
retained_entries = []
110+
111+
for entry_index, entry in enumerate(axes):
112+
if isinstance(entry, list):
113+
retained_axes = []
114+
for axis_index, axis in enumerate(entry):
115+
location = f"{entry_index}][{axis_index}"
116+
if _is_deprecated_axis(axis, location, warnings):
117+
changed = True
118+
continue
119+
retained_axes.append(axis)
120+
if retained_axes:
121+
retained_entries.append(retained_axes)
122+
continue
123+
124+
location = str(entry_index)
125+
if _is_deprecated_axis(entry, location, warnings):
126+
changed = True
127+
continue
128+
retained_entries.append(entry)
129+
130+
if not changed:
131+
return
132+
if retained_entries:
133+
household["axes"] = retained_entries
134+
else:
135+
del household["axes"]
136+
137+
138+
def _is_deprecated_axis(
139+
axis, location: str, warnings: list[DeprecatedVariableWarning]
140+
) -> bool:
141+
if not isinstance(axis, dict):
142+
return False
143+
144+
variable_name = axis.get("name")
145+
hint = DEPRECATED_VARIABLES.get(variable_name)
146+
if hint is None:
147+
return False
148+
149+
warnings.append(
150+
DeprecatedVariableWarning(
151+
variable=variable_name,
152+
entity_plural="axes",
153+
entity_id=location,
154+
hint=hint,
155+
)
156+
)
157+
return True

0 commit comments

Comments
 (0)