From 04292973cbb32b24d08d6f5c53c63706bfd2421e Mon Sep 17 00:00:00 2001 From: Mauko Quiroga Date: Mon, 17 Mar 2025 21:06:49 +0100 Subject: [PATCH 1/9] test: simplify household Web API test (#1326) --- tests/web_api/test_calculate.py | 41 ++++++++++++++++----------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/tests/web_api/test_calculate.py b/tests/web_api/test_calculate.py index 4dfc45f3e..cf9606504 100644 --- a/tests/web_api/test_calculate.py +++ b/tests/web_api/test_calculate.py @@ -141,6 +141,7 @@ def test_responses(test_client, test) -> None: def test_basic_calculation(test_client) -> None: + # Arrange simulation_json = json.dumps( { "persons": { @@ -158,7 +159,7 @@ def test_basic_calculation(test_client) -> None: }, }, "households": { - "first_household": { + "bill&bob": { "adults": ["bill", "bob"], "housing_tax": {"2017": None}, "accommodation_size": {"2017-01": 300}, @@ -167,29 +168,27 @@ def test_basic_calculation(test_client) -> None: }, ) + # Act response = post_json(test_client, simulation_json) - assert response.status_code == client.OK response_json = json.loads(response.data.decode("utf-8")) - assert ( - dpath.get(response_json, "persons/bill/basic_income/2017-12") == 600 - ) # Universal basic income - assert ( - dpath.get(response_json, "persons/bill/income_tax/2017-12") == 300 - ) # 15% of the salary - assert ( - dpath.get(response_json, "persons/bill/age/2017-12") == 37 - ) # 15% of the salary - assert dpath.get(response_json, "persons/bob/basic_income/2017-12") == 600 - assert ( - dpath.get( - response_json, - "persons/bob/social_security_contribution/2017-12", - ) - == 816 - ) # From social_security_contribution.yaml test - assert ( - dpath.get(response_json, "households/first_household/housing_tax/2017") == 3000 + bill_basic_income = dpath.get(response_json, "persons/bill/basic_income/2017-12") + bill_income_tax = dpath.get(response_json, "persons/bill/income_tax/2017-12") + bill_age = dpath.get(response_json, "persons/bill/age/2017-12") + bob_basic_income = dpath.get(response_json, "persons/bob/basic_income/2017-12") + bob_social_security_contribution = dpath.get( + response_json, + "persons/bob/social_security_contribution/2017-12", ) + housing_tax = dpath.get(response_json, "households/bill&bob/housing_tax/2017") + + # Assert + assert response.status_code == client.OK + assert bill_basic_income == 600 # Universal basic income + assert bill_income_tax == 300 # 15% of the salary + assert bill_age == 37 + assert bob_basic_income == 600 + assert bob_social_security_contribution == 816 + assert housing_tax == 3000 def test_enums_sending_identifier(test_client) -> None: From 85ed97180f968bc87c6c2aeb3b9c4f7c2882c026 Mon Sep 17 00:00:00 2001 From: Mauko Quiroga Date: Mon, 17 Mar 2025 21:11:06 +0100 Subject: [PATCH 2/9] test: add individual & group test (#1326) --- tests/web_api/test_calculate.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/tests/web_api/test_calculate.py b/tests/web_api/test_calculate.py index cf9606504..e0381733f 100644 --- a/tests/web_api/test_calculate.py +++ b/tests/web_api/test_calculate.py @@ -140,7 +140,37 @@ def test_responses(test_client, test) -> None: check_response(test_client, *test) -def test_basic_calculation(test_client) -> None: +def test_basic_individual_calculation(test_client) -> None: + # Arrange + simulation_json = json.dumps( + { + "persons": { + "bill": { + "birth": {"2017-12": "1980-01-01"}, + "age": {"2017-12": None}, + "salary": {"2017-12": 2000}, + "basic_income": {"2017-12": None}, + "income_tax": {"2017-12": None}, + } + } + }, + ) + + # Act + response = post_json(test_client, simulation_json) + response_json = json.loads(response.data.decode("utf-8")) + basic_income = dpath.get(response_json, "persons/bill/basic_income/2017-12") + income_tax = dpath.get(response_json, "persons/bill/income_tax/2017-12") + age = dpath.get(response_json, "persons/bill/age/2017-12") + + # Assert + assert response.status_code == client.OK + assert basic_income == 600 # Universal basic income + assert income_tax == 300 # 15% of the salary + assert age == 37 + + +def test_basic_group_calculation(test_client) -> None: # Arrange simulation_json = json.dumps( { From cb4a98e45e33c0183b095b7261f4efcbb6029e85 Mon Sep 17 00:00:00 2001 From: Mauko Quiroga Date: Mon, 17 Mar 2025 21:54:07 +0100 Subject: [PATCH 3/9] feat: add basic individual axis to Web API (#1326) --- openfisca_web_api/handlers.py | 71 ++++++++++++++++++--------------- tests/web_api/test_calculate.py | 49 +++++++++++++++++++++++ 2 files changed, 87 insertions(+), 33 deletions(-) diff --git a/openfisca_web_api/handlers.py b/openfisca_web_api/handlers.py index 84c73d4c4..dddb41157 100644 --- a/openfisca_web_api/handlers.py +++ b/openfisca_web_api/handlers.py @@ -18,47 +18,52 @@ def calculate(tax_benefit_system, input_data: dict) -> dict: path = computation[ 0 ] # format: entity_plural/entity_instance_id/openfisca_variable_name/period - entity_plural, entity_id, variable_name, period = path.split("/") + entity_plural, _entity_id, variable_name, period = path.split("/") variable = tax_benefit_system.get_variable(variable_name) result = simulation.calculate(variable_name, period) population = simulation.get_population(entity_plural) - entity_index = population.get_index(entity_id) - if variable.value_type == Enum: - entity_result = result.decode()[entity_index].name - elif variable.value_type == float: - entity_result = float( - str(result[entity_index]), - ) # To turn the float32 into a regular float without adding confusing extra decimals. There must be a better way. - elif variable.value_type == str: - entity_result = str(result[entity_index]) - else: - entity_result = result.tolist()[entity_index] - # Don't use dpath.new, because there is a problem with dpath>=2.0 - # when we have a key that is numeric, like the year. - # See https://github.com/dpath-maintainers/dpath-python/issues/160 - if computation_results == {}: - computation_results = { - entity_plural: {entity_id: {variable_name: {period: entity_result}}}, - } - elif entity_plural in computation_results: - if entity_id in computation_results[entity_plural]: - if variable_name in computation_results[entity_plural][entity_id]: - computation_results[entity_plural][entity_id][variable_name][ - period - ] = entity_result + for entity_id in population.ids: + entity_index = population.get_index(entity_id) + + if variable.value_type == Enum: + entity_result = result.decode()[entity_index].name + elif variable.value_type == float: + entity_result = float( + str(result[entity_index]), + ) # To turn the float32 into a regular float without adding confusing extra decimals. There must be a better way. + elif variable.value_type == str: + entity_result = str(result[entity_index]) + else: + entity_result = result.tolist()[entity_index] + # Don't use dpath.new, because there is a problem with dpath>=2.0 + # when we have a key that is numeric, like the year. + # See https://github.com/dpath-maintainers/dpath-python/issues/160 + if computation_results == {}: + computation_results = { + entity_plural: { + entity_id: {variable_name: {period: entity_result}} + }, + } + elif entity_plural in computation_results: + if entity_id in computation_results[entity_plural]: + if variable_name in computation_results[entity_plural][entity_id]: + computation_results[entity_plural][entity_id][variable_name][ + period + ] = entity_result + else: + computation_results[entity_plural][entity_id][variable_name] = { + period: entity_result, + } else: - computation_results[entity_plural][entity_id][variable_name] = { - period: entity_result, + computation_results[entity_plural][entity_id] = { + variable_name: {period: entity_result}, } else: - computation_results[entity_plural][entity_id] = { - variable_name: {period: entity_result}, + computation_results[entity_plural] = { + entity_id: {variable_name: {period: entity_result}}, } - else: - computation_results[entity_plural] = { - entity_id: {variable_name: {period: entity_result}}, - } + dpath.merge(input_data, computation_results) return input_data diff --git a/tests/web_api/test_calculate.py b/tests/web_api/test_calculate.py index e0381733f..972d0d18c 100644 --- a/tests/web_api/test_calculate.py +++ b/tests/web_api/test_calculate.py @@ -221,6 +221,55 @@ def test_basic_group_calculation(test_client) -> None: assert housing_tax == 3000 +def test_axes_individual(test_client) -> None: + # Arrange + simulation_json = json.dumps( + { + "persons": { + "bill": { + "income_tax": {"2025-03": None}, + } + }, + "households": { + "_": { + "adults": ["bill"], + } + }, + "axes": [ + [ + { + "count": 3, + "name": "capital_returns", + "min": 0, + "max": 1500, + "period": "2025-03", + }, + { + "count": 3, + "name": "salary", + "min": 0, + "max": 8500, + "period": "2025-03", + }, + ] + ], + }, + ) + + # Act + response = post_json(test_client, simulation_json) + response_json = json.loads(response.data.decode("utf-8")) + income_tax_1 = dpath.get(response_json, "persons/bill0/income_tax/2025-03") + income_tax_2 = dpath.get(response_json, "persons/bill1/income_tax/2025-03") + income_tax_3 = dpath.get(response_json, "persons/bill2/income_tax/2025-03") + + # Assert + assert response.status_code == client.OK + assert income_tax_1 == 0 + assert income_tax_2 == 750 + assert income_tax_3 == 1500 + + def test_enums_sending_identifier(test_client) -> None: simulation_json = json.dumps( { From 26e9ca956edfb4e95d07126d4711797565d46700 Mon Sep 17 00:00:00 2001 From: Mauko Quiroga Date: Mon, 17 Mar 2025 22:14:44 +0100 Subject: [PATCH 4/9] feat: add basic group axis to Web API (#1326) --- tests/web_api/test_calculate.py | 60 +++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/web_api/test_calculate.py b/tests/web_api/test_calculate.py index 972d0d18c..2a328b052 100644 --- a/tests/web_api/test_calculate.py +++ b/tests/web_api/test_calculate.py @@ -270,6 +270,66 @@ def test_axes_individual(test_client) -> None: assert income_tax_3 == 1500 +def test_axes_group(test_client) -> None: + # Arrange + simulation_json = json.dumps( + { + "persons": { + "bill": { + "pension": {"2025-03": 5000}, + }, + "bob": { + "capital_returns": {"2025-03": 1000}, + }, + }, + "households": { + "b&b": { + "adults": ["bill", "bob"], + "disposable_income": {"2025-03": None}, + "total_taxes": {"2025-03": None}, + "total_benefits": {"2025-03": None}, + }, + }, + "axes": [ + [ + { + "count": 3, + "name": "salary", + "min": 0, + "max": 10000, + "period": "2025-03", + }, + ] + ], + }, + ) + + # Act + response = post_json(test_client, simulation_json) + response_json = json.loads(response.data.decode("utf-8")) + income_1 = dpath.get(response_json, "households/b&b0/disposable_income/2025-03") + income_2 = dpath.get(response_json, "households/b&b1/disposable_income/2025-03") + income_3 = dpath.get(response_json, "households/b&b2/disposable_income/2025-03") + taxes_1 = dpath.get(response_json, "households/b&b0/total_taxes/2025-03") + taxes_2 = dpath.get(response_json, "households/b&b1/total_taxes/2025-03") + taxes_3 = dpath.get(response_json, "households/b&b2/total_taxes/2025-03") + benefits_1 = dpath.get(response_json, "households/b&b0/total_benefits/2025-03") + benefits_2 = dpath.get(response_json, "households/b&b1/total_benefits/2025-03") + benefits_3 = dpath.get(response_json, "households/b&b2/total_benefits/2025-03") + + # Assert + assert response.status_code == client.OK + assert income_1 == 6283.3335 + assert income_2 == 10433.333 + assert income_3 == 14423.333 + assert taxes_1 == 916.6667 + assert taxes_2 == 1766.6666 + assert taxes_3 == 2776.6667 + assert benefits_1 == 1200 + assert benefits_2 == 1200 + assert benefits_3 == 1200 + + def test_enums_sending_identifier(test_client) -> None: simulation_json = json.dumps( { From 2070de052aee3b4f069ba1510b0630c17a3c72ac Mon Sep 17 00:00:00 2001 From: Mauko Quiroga Date: Mon, 17 Mar 2025 22:26:53 +0100 Subject: [PATCH 5/9] test: indivual axis for expanded values (#1326) --- tests/web_api/test_calculate.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/web_api/test_calculate.py b/tests/web_api/test_calculate.py index 2a328b052..72164e344 100644 --- a/tests/web_api/test_calculate.py +++ b/tests/web_api/test_calculate.py @@ -228,6 +228,7 @@ def test_axes_individual(test_client) -> None: "persons": { "bill": { "income_tax": {"2025-03": None}, + "salary": {"2025-03": None}, } }, "households": { @@ -262,12 +263,18 @@ def test_axes_individual(test_client) -> None: income_tax_1 = dpath.get(response_json, "persons/bill0/income_tax/2025-03") income_tax_2 = dpath.get(response_json, "persons/bill1/income_tax/2025-03") income_tax_3 = dpath.get(response_json, "persons/bill2/income_tax/2025-03") + salary_1 = dpath.get(response_json, "persons/bill0/salary/2025-03") + salary_2 = dpath.get(response_json, "persons/bill1/salary/2025-03") + salary_3 = dpath.get(response_json, "persons/bill2/salary/2025-03") # Assert assert response.status_code == client.OK assert income_tax_1 == 0 assert income_tax_2 == 750 assert income_tax_3 == 1500 + assert salary_1 == 0 + assert salary_2 == 4250 + assert salary_3 == 8500 def test_axes_group(test_client) -> None: From cb7f5fe161ae76ce3fec315404c7bef78175e480 Mon Sep 17 00:00:00 2001 From: Mauko Quiroga Date: Mon, 17 Mar 2025 22:32:20 +0100 Subject: [PATCH 6/9] test: group axis targeting individuals (#1326) --- tests/web_api/test_calculate.py | 73 +++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/web_api/test_calculate.py b/tests/web_api/test_calculate.py index 72164e344..9887f609b 100644 --- a/tests/web_api/test_calculate.py +++ b/tests/web_api/test_calculate.py @@ -337,6 +337,79 @@ def test_axes_group(test_client) -> None: assert benefits_3 == 1200 +def test_axes_group_targeting_individuals(test_client) -> None: + # Arrange + simulation_json = json.dumps( + { + "persons": { + "bill": { + "pension": {"2025-03": 5000}, + "salary": {"2025-03": None}, + }, + "bob": { + "capital_returns": {"2025-03": 1000}, + }, + }, + "households": { + "b&b": { + "adults": ["bill", "bob"], + "disposable_income": {"2025-03": None}, + "total_taxes": {"2025-03": None}, + "total_benefits": {"2025-03": None}, + }, + }, + "axes": [ + [ + { + "count": 3, + "name": "salary", + "min": 0, + "max": 10000, + "period": "2025-03", + }, + ] + ], + }, + ) + + # Act + response = post_json(test_client, simulation_json) + response_json = json.loads(response.data.decode("utf-8")) + income_1 = dpath.get(response_json, "households/b&b0/disposable_income/2025-03") + income_2 = dpath.get(response_json, "households/b&b1/disposable_income/2025-03") + income_3 = dpath.get(response_json, "households/b&b2/disposable_income/2025-03") + taxes_1 = dpath.get(response_json, "households/b&b0/total_taxes/2025-03") + taxes_2 = dpath.get(response_json, "households/b&b1/total_taxes/2025-03") + taxes_3 = dpath.get(response_json, "households/b&b2/total_taxes/2025-03") + benefits_1 = dpath.get(response_json, "households/b&b0/total_benefits/2025-03") + benefits_2 = dpath.get(response_json, "households/b&b1/total_benefits/2025-03") + benefits_3 = dpath.get(response_json, "households/b&b2/total_benefits/2025-03") + bill_salary_1 = dpath.get(response_json, "persons/bill0/salary/2025-03") + bill_salary_2 = dpath.get(response_json, "persons/bill2/salary/2025-03") + bill_salary_3 = dpath.get(response_json, "persons/bill4/salary/2025-03") + bob_salary_1 = dpath.get(response_json, "persons/bob1/salary/2025-03") + bob_salary_2 = dpath.get(response_json, "persons/bob3/salary/2025-03") + bob_salary_3 = dpath.get(response_json, "persons/bob5/salary/2025-03") + + # Assert + assert response.status_code == client.OK + assert income_1 == 6283.3335 + assert income_2 == 10433.333 + assert income_3 == 14423.333 + assert taxes_1 == 916.6667 + assert taxes_2 == 1766.6666 + assert taxes_3 == 2776.6667 + assert benefits_1 == 1200 + assert benefits_2 == 1200 + assert benefits_3 == 1200 + assert bill_salary_1 == 0 + assert bill_salary_2 == 5000 + assert bill_salary_3 == 10000 + assert bob_salary_1 == 0 + assert bob_salary_2 == 0 + assert bob_salary_3 == 0 + + def test_enums_sending_identifier(test_client) -> None: simulation_json = json.dumps( { From 0996ab2e2f1d6cfb5be6a046fecd53e22ff4bc9f Mon Sep 17 00:00:00 2001 From: Mauko Quiroga Date: Mon, 17 Mar 2025 22:38:36 +0100 Subject: [PATCH 7/9] test: normal simulation does not expand axes (#1326) --- tests/web_api/test_calculate.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/web_api/test_calculate.py b/tests/web_api/test_calculate.py index 9887f609b..37f2b9828 100644 --- a/tests/web_api/test_calculate.py +++ b/tests/web_api/test_calculate.py @@ -168,6 +168,10 @@ def test_basic_individual_calculation(test_client) -> None: assert basic_income == 600 # Universal basic income assert income_tax == 300 # 15% of the salary assert age == 37 + with pytest.raises(KeyError): + dpath.get(response_json, "persons/bill0") + with pytest.raises(KeyError): + dpath.get(response_json, "households") def test_basic_group_calculation(test_client) -> None: @@ -219,6 +223,10 @@ def test_basic_group_calculation(test_client) -> None: assert bob_basic_income == 600 assert bob_social_security_contribution == 816 assert housing_tax == 3000 + with pytest.raises(KeyError): + dpath.get(response_json, "persons/bill0") + with pytest.raises(KeyError): + dpath.get(response_json, "households/bill&bob0") def test_axes_individual(test_client) -> None: From 2a1d389f094bf7171989e541dd2256357987c726 Mon Sep 17 00:00:00 2001 From: Mauko Quiroga Date: Tue, 18 Mar 2025 00:15:55 +0100 Subject: [PATCH 8/9] feat: delete requested calculations when axes expansion (#1326) --- openfisca_web_api/handlers.py | 24 ++++++++++++++++++++++-- tests/web_api/test_calculate.py | 22 ++++++++++++++++------ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/openfisca_web_api/handlers.py b/openfisca_web_api/handlers.py index dddb41157..1b7c3b87e 100644 --- a/openfisca_web_api/handlers.py +++ b/openfisca_web_api/handlers.py @@ -13,17 +13,33 @@ def calculate(tax_benefit_system, input_data: dict) -> dict: afilter=lambda t: t is None, yielded=True, ) + # Calculated results requested by the user. computation_results: dict = {} + # Paths to delete from the result in case of axes calculation. + paths_to_delete = [] + for computation in requested_computations: path = computation[ 0 ] # format: entity_plural/entity_instance_id/openfisca_variable_name/period - entity_plural, _entity_id, variable_name, period = path.split("/") + entity_plural, entity_id, variable_name, period = path.split("/") variable = tax_benefit_system.get_variable(variable_name) result = simulation.calculate(variable_name, period) population = simulation.get_population(entity_plural) - for entity_id in population.ids: + # With axes, entities are expanded by the given number of `counts`. + # So, for example, `bob` becomes `bob_0`, `bob_1`, `bob_2`, etc. + if input_data.get("axes") is None: + entity_ids = [entity_id] + else: + entity_ids = [ + id_ + for id_ in population.ids + if id_.startswith(entity_id) and id_ != entity_id + ] + paths_to_delete.append("/".join(path.split("/")[0:2])) + + for entity_id in entity_ids: entity_index = population.get_index(entity_id) if variable.value_type == Enum: @@ -64,6 +80,10 @@ def calculate(tax_benefit_system, input_data: dict) -> dict: entity_id: {variable_name: {period: entity_result}}, } + for path in paths_to_delete: + if dpath.search(input_data, path): + dpath.delete(input_data, path) + dpath.merge(input_data, computation_results) return input_data diff --git a/tests/web_api/test_calculate.py b/tests/web_api/test_calculate.py index 37f2b9828..9e1bfe16f 100644 --- a/tests/web_api/test_calculate.py +++ b/tests/web_api/test_calculate.py @@ -283,6 +283,10 @@ def test_axes_individual(test_client) -> None: assert salary_1 == 0 assert salary_2 == 4250 assert salary_3 == 8500 + with pytest.raises(KeyError): + dpath.get(response_json, "persons/bill") + with pytest.raises(KeyError): + dpath.get(response_json, "households/_0") def test_axes_group(test_client) -> None: @@ -343,6 +347,12 @@ def test_axes_group(test_client) -> None: assert benefits_1 == 1200 assert benefits_2 == 1200 assert benefits_3 == 1200 + with pytest.raises(KeyError): + dpath.get(response_json, "persons/bill0") + with pytest.raises(KeyError): + dpath.get(response_json, "persons/bob0") + with pytest.raises(KeyError): + dpath.get(response_json, "households/b&b") def test_axes_group_targeting_individuals(test_client) -> None: @@ -395,9 +405,6 @@ def test_axes_group_targeting_individuals(test_client) -> None: bill_salary_1 = dpath.get(response_json, "persons/bill0/salary/2025-03") bill_salary_2 = dpath.get(response_json, "persons/bill2/salary/2025-03") bill_salary_3 = dpath.get(response_json, "persons/bill4/salary/2025-03") - bob_salary_1 = dpath.get(response_json, "persons/bob1/salary/2025-03") - bob_salary_2 = dpath.get(response_json, "persons/bob3/salary/2025-03") - bob_salary_3 = dpath.get(response_json, "persons/bob5/salary/2025-03") # Assert assert response.status_code == client.OK @@ -413,9 +420,12 @@ def test_axes_group_targeting_individuals(test_client) -> None: assert bill_salary_1 == 0 assert bill_salary_2 == 5000 assert bill_salary_3 == 10000 - assert bob_salary_1 == 0 - assert bob_salary_2 == 0 - assert bob_salary_3 == 0 + with pytest.raises(KeyError): + dpath.get(response_json, "persons/bill") + with pytest.raises(KeyError): + dpath.get(response_json, "persons/bob1") + with pytest.raises(KeyError): + dpath.get(response_json, "households/b&b") def test_enums_sending_identifier(test_client) -> None: From e1741de1fcdc6ac01013a63b91e967610c7db672 Mon Sep 17 00:00:00 2001 From: Mauko Quiroga Date: Tue, 18 Mar 2025 00:26:49 +0100 Subject: [PATCH 9/9] chore: version bump --- CHANGELOG.md | 7 +++++++ setup.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9005b6fc5..93b615a37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 43.4.0 [#1327](https://github.com/openfisca/openfisca-core/pull/1327) + +#### New features + +- Introduce parallel axes expansion to the Web API + - Allows for calculating variables based on a range of depending values + ### 43.3.5 [#1325](https://github.com/openfisca/openfisca-core/pull/1325) #### Documentation diff --git a/setup.py b/setup.py index cbd33a591..7c81973cc 100644 --- a/setup.py +++ b/setup.py @@ -70,7 +70,7 @@ setup( name="OpenFisca-Core", - version="43.3.5", + version="43.4.0", author="OpenFisca Team", author_email="contact@openfisca.org", classifiers=[