From cf597b1726ba1f1dbf564c5023cbb36e6d5dad40 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 11 Mar 2026 22:33:14 +0100 Subject: [PATCH 1/4] Add Alembic merge migration for parallel branch heads Three migrations branched from 886921687770 in parallel, leaving Alembic with multiple heads. This empty merge migration unifies them into a single head so `alembic upgrade head` works again. Co-Authored-By: Claude Opus 4.6 --- ...11_db93db748457_merge_parallel_branches.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 alembic/versions/20260311_db93db748457_merge_parallel_branches.py diff --git a/alembic/versions/20260311_db93db748457_merge_parallel_branches.py b/alembic/versions/20260311_db93db748457_merge_parallel_branches.py new file mode 100644 index 0000000..e4aef86 --- /dev/null +++ b/alembic/versions/20260311_db93db748457_merge_parallel_branches.py @@ -0,0 +1,28 @@ +"""merge_parallel_branches + +Revision ID: db93db748457 +Revises: 0cbd97809414, add_variable_label, 67608331ee8a +Create Date: 2026-03-11 22:30:07.234183 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'db93db748457' +down_revision: Union[str, Sequence[str], None] = ('0cbd97809414', 'add_variable_label', '67608331ee8a') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + pass + + +def downgrade() -> None: + """Downgrade schema.""" + pass From 09b4190af583e452e08b19ad6df6571a5735e268 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 11 Mar 2026 22:41:01 +0100 Subject: [PATCH 2/4] Update package version Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 7 +++ ...7809414_add_adds_subtracts_to_variables.py | 13 ++--- ...11_db93db748457_merge_parallel_branches.py | 13 ++--- pyproject.toml | 2 +- tests/test_variable_labels.py | 54 ++++++++++++++----- uv.lock | 2 +- 6 files changed, 63 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5fcd14..0586f3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +0.3.1 (2026-03-11) + +# Fixed + +- Add Alembic merge migration to resolve multiple head revisions from parallel feature branches (#129) + + 0.3.0 (2026-03-11) # Added diff --git a/alembic/versions/20260305_0cbd97809414_add_adds_subtracts_to_variables.py b/alembic/versions/20260305_0cbd97809414_add_adds_subtracts_to_variables.py index 90b58ee..f81ad67 100644 --- a/alembic/versions/20260305_0cbd97809414_add_adds_subtracts_to_variables.py +++ b/alembic/versions/20260305_0cbd97809414_add_adds_subtracts_to_variables.py @@ -5,6 +5,7 @@ Create Date: 2026-03-05 20:26:07.571012 """ + from typing import Sequence, Union import sqlalchemy as sa @@ -12,19 +13,19 @@ from alembic import op # revision identifiers, used by Alembic. -revision: str = '0cbd97809414' -down_revision: Union[str, Sequence[str], None] = '886921687770' +revision: str = "0cbd97809414" +down_revision: Union[str, Sequence[str], None] = "886921687770" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Upgrade schema.""" - op.add_column('variables', sa.Column('adds', sa.JSON(), nullable=True)) - op.add_column('variables', sa.Column('subtracts', sa.JSON(), nullable=True)) + op.add_column("variables", sa.Column("adds", sa.JSON(), nullable=True)) + op.add_column("variables", sa.Column("subtracts", sa.JSON(), nullable=True)) def downgrade() -> None: """Downgrade schema.""" - op.drop_column('variables', 'subtracts') - op.drop_column('variables', 'adds') + op.drop_column("variables", "subtracts") + op.drop_column("variables", "adds") diff --git a/alembic/versions/20260311_db93db748457_merge_parallel_branches.py b/alembic/versions/20260311_db93db748457_merge_parallel_branches.py index e4aef86..6e6472c 100644 --- a/alembic/versions/20260311_db93db748457_merge_parallel_branches.py +++ b/alembic/versions/20260311_db93db748457_merge_parallel_branches.py @@ -5,15 +5,16 @@ Create Date: 2026-03-11 22:30:07.234183 """ -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa +from typing import Sequence, Union # revision identifiers, used by Alembic. -revision: str = 'db93db748457' -down_revision: Union[str, Sequence[str], None] = ('0cbd97809414', 'add_variable_label', '67608331ee8a') +revision: str = "db93db748457" +down_revision: Union[str, Sequence[str], None] = ( + "0cbd97809414", + "add_variable_label", + "67608331ee8a", +) branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None diff --git a/pyproject.toml b/pyproject.toml index efa6635..c4075ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-api-v2" -version = "0.3.0" +version = "0.3.1" description = "FastAPI service for PolicyEngine microsimulations" readme = "README.md" requires-python = ">=3.13" diff --git a/tests/test_variable_labels.py b/tests/test_variable_labels.py index a99382b..3517abf 100644 --- a/tests/test_variable_labels.py +++ b/tests/test_variable_labels.py @@ -1,14 +1,10 @@ """Tests for variable label field across all variable endpoints.""" -import pytest from test_fixtures.fixtures_variables import ( # noqa: F811 create_variable, - uk_model_version, - us_model_version, ) - # --------------------------------------------------------------------------- # GET /variables - label in list responses # --------------------------------------------------------------------------- @@ -18,7 +14,10 @@ class TestListVariablesLabel: """Tests that label is returned when listing variables.""" def test_label_returned_in_response( - self, client, session, us_model_version # noqa: F811 + self, + client, + session, + us_model_version, # noqa: F811 ): """Variable with a label should include it in the list response.""" create_variable( @@ -35,7 +34,10 @@ def test_label_returned_in_response( assert data[0]["label"] == "Employment income" def test_null_label_returned_when_absent( - self, client, session, us_model_version # noqa: F811 + self, + client, + session, + us_model_version, # noqa: F811 ): """Variable without a label should return null.""" create_variable( @@ -52,7 +54,10 @@ def test_null_label_returned_when_absent( assert data[0]["label"] is None def test_empty_label_returned( - self, client, session, us_model_version # noqa: F811 + self, + client, + session, + us_model_version, # noqa: F811 ): """Variable with an empty string label should return it as-is.""" create_variable( @@ -76,7 +81,10 @@ class TestSearchVariablesByLabel: """Tests that the search parameter matches against labels.""" def test_search_matches_label( - self, client, session, us_model_version # noqa: F811 + self, + client, + session, + us_model_version, # noqa: F811 ): """Searching for a term in the label should return the variable.""" create_variable( @@ -105,7 +113,10 @@ def test_search_matches_label( assert data[0]["name"] == "employment_income" def test_search_label_case_insensitive( - self, client, session, us_model_version # noqa: F811 + self, + client, + session, + us_model_version, # noqa: F811 ): """Label search should be case-insensitive.""" create_variable( @@ -126,7 +137,10 @@ def test_search_label_case_insensitive( assert len(response.json()) == 1 def test_search_partial_label_match( - self, client, session, us_model_version # noqa: F811 + self, + client, + session, + us_model_version, # noqa: F811 ): """Partial label matches should be returned.""" create_variable( @@ -156,7 +170,10 @@ class TestGetVariableLabel: """Tests that label is returned when fetching a single variable.""" def test_label_in_get_response( - self, client, session, us_model_version # noqa: F811 + self, + client, + session, + us_model_version, # noqa: F811 ): """GET /variables/{id} should include the label field.""" var = create_variable( @@ -171,7 +188,10 @@ def test_label_in_get_response( assert response.json()["label"] == "Employment income" def test_null_label_in_get_response( - self, client, session, us_model_version # noqa: F811 + self, + client, + session, + us_model_version, # noqa: F811 ): """GET /variables/{id} should return null for missing label.""" var = create_variable( @@ -195,7 +215,10 @@ class TestVariablesByNameLabel: """Tests that label is included in by-name lookup responses.""" def test_label_in_by_name_response( - self, client, session, us_model_version # noqa: F811 + self, + client, + session, + us_model_version, # noqa: F811 ): """POST /variables/by-name should include the label field.""" create_variable( @@ -215,7 +238,10 @@ def test_label_in_by_name_response( assert data[0]["label"] == "Employment income" def test_mixed_labels_in_by_name_response( - self, client, session, us_model_version # noqa: F811 + self, + client, + session, + us_model_version, # noqa: F811 ): """Variables with and without labels should both be returned correctly.""" create_variable( diff --git a/uv.lock b/uv.lock index 6a7770a..7381a88 100644 --- a/uv.lock +++ b/uv.lock @@ -1849,7 +1849,7 @@ wheels = [ [[package]] name = "policyengine-api-v2" -version = "0.2.3" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "alembic" }, From eebd105d531b2504463a883a9885fbefcd45b181 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 11 Mar 2026 22:59:27 +0100 Subject: [PATCH 3/4] Include add_filter_strategy in merge migration A 4th parallel head (add_filter_strategy) was merged to main after the original fix branch was created. Updated the merge migration to consolidate all 4 heads. Also applies ruff formatting fixes to newly added files from main. Co-Authored-By: Claude Opus 4.6 --- ...11_db93db748457_merge_parallel_branches.py | 3 +- src/policyengine_api/api/analysis.py | 24 +++++++++---- tests/test_analysis.py | 4 ++- tests/test_strategy_reconstruction.py | 35 ++++++++++++++----- tests/test_variable_labels.py | 1 - uv.lock | 2 +- 6 files changed, 51 insertions(+), 18 deletions(-) diff --git a/alembic/versions/20260311_db93db748457_merge_parallel_branches.py b/alembic/versions/20260311_db93db748457_merge_parallel_branches.py index 6e6472c..391967c 100644 --- a/alembic/versions/20260311_db93db748457_merge_parallel_branches.py +++ b/alembic/versions/20260311_db93db748457_merge_parallel_branches.py @@ -1,7 +1,7 @@ """merge_parallel_branches Revision ID: db93db748457 -Revises: 0cbd97809414, add_variable_label, 67608331ee8a +Revises: 0cbd97809414, add_variable_label, 67608331ee8a, add_filter_strategy Create Date: 2026-03-11 22:30:07.234183 """ @@ -14,6 +14,7 @@ "0cbd97809414", "add_variable_label", "67608331ee8a", + "add_filter_strategy", ) branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None diff --git a/src/policyengine_api/api/analysis.py b/src/policyengine_api/api/analysis.py index 9caedcf..d2e5864 100644 --- a/src/policyengine_api/api/analysis.py +++ b/src/policyengine_api/api/analysis.py @@ -857,7 +857,9 @@ def build_dynamic(dynamic_id): # Reconstruct scoping strategy from DB columns (if applicable) from policyengine_api.utils.strategy_reconstruction import reconstruct_strategy - baseline_region = session.get(Region, baseline_sim.region_id) if baseline_sim.region_id else None + baseline_region = ( + session.get(Region, baseline_sim.region_id) if baseline_sim.region_id else None + ) baseline_strategy = reconstruct_strategy( filter_strategy=baseline_sim.filter_strategy, filter_field=baseline_sim.filter_field, @@ -865,7 +867,9 @@ def build_dynamic(dynamic_id): region_type=baseline_region.region_type.value if baseline_region else None, ) - reform_region = session.get(Region, reform_sim.region_id) if reform_sim.region_id else None + reform_region = ( + session.get(Region, reform_sim.region_id) if reform_sim.region_id else None + ) reform_strategy = reconstruct_strategy( filter_strategy=reform_sim.filter_strategy, filter_field=reform_sim.filter_field, @@ -1038,7 +1042,9 @@ def build_dynamic(dynamic_id): # Reconstruct scoping strategy from DB columns (if applicable) from policyengine_api.utils.strategy_reconstruction import reconstruct_strategy - baseline_region = session.get(Region, baseline_sim.region_id) if baseline_sim.region_id else None + baseline_region = ( + session.get(Region, baseline_sim.region_id) if baseline_sim.region_id else None + ) baseline_strategy = reconstruct_strategy( filter_strategy=baseline_sim.filter_strategy, filter_field=baseline_sim.filter_field, @@ -1046,7 +1052,9 @@ def build_dynamic(dynamic_id): region_type=baseline_region.region_type.value if baseline_region else None, ) - reform_region = session.get(Region, reform_sim.region_id) if reform_sim.region_id else None + reform_region = ( + session.get(Region, reform_sim.region_id) if reform_sim.region_id else None + ) reform_strategy = reconstruct_strategy( filter_strategy=reform_sim.filter_strategy, filter_field=reform_sim.filter_field, @@ -1249,7 +1257,9 @@ def economic_impact( # Extract filter parameters from region (if present) filter_field = region.filter_field if region and region.requires_filter else None filter_value = region.filter_value if region and region.requires_filter else None - filter_strategy = region.filter_strategy if region and region.requires_filter else None + filter_strategy = ( + region.filter_strategy if region and region.requires_filter else None + ) # Get model version model_version = _get_model_version(request.tax_benefit_model_name, session) @@ -1446,7 +1456,9 @@ def economy_custom( region_obj.filter_value if region_obj and region_obj.requires_filter else None ) filter_strategy = ( - region_obj.filter_strategy if region_obj and region_obj.requires_filter else None + region_obj.filter_strategy + if region_obj and region_obj.requires_filter + else None ) model_version = _get_model_version(request.tax_benefit_model_name, session) diff --git a/tests/test_analysis.py b/tests/test_analysis.py index c977996..abd7489 100644 --- a/tests/test_analysis.py +++ b/tests/test_analysis.py @@ -981,7 +981,9 @@ def test__given_constituency_region__then_region_has_weight_replacement_strategy # Then assert resolved_region is not None - assert resolved_region.filter_strategy == FILTER_STRATEGIES["WEIGHT_REPLACEMENT"] + assert ( + resolved_region.filter_strategy == FILTER_STRATEGIES["WEIGHT_REPLACEMENT"] + ) def test__given_national_region__then_filter_strategy_is_none( self, session: Session diff --git a/tests/test_strategy_reconstruction.py b/tests/test_strategy_reconstruction.py index 44379f0..e21ee21 100644 --- a/tests/test_strategy_reconstruction.py +++ b/tests/test_strategy_reconstruction.py @@ -25,7 +25,6 @@ REGION_TYPES, ) - # --------------------------------------------------------------------------- # Mock strategy classes (match the real constructor signatures) # --------------------------------------------------------------------------- @@ -277,9 +276,18 @@ def test__given_constituency_weight_replacement__then_gcs_config_matches(self): ) # Then - assert result.weight_matrix_bucket == EXPECTED_CONSTITUENCY_CONFIG["weight_matrix_bucket"] - assert result.weight_matrix_key == EXPECTED_CONSTITUENCY_CONFIG["weight_matrix_key"] - assert result.lookup_csv_bucket == EXPECTED_CONSTITUENCY_CONFIG["lookup_csv_bucket"] + assert ( + result.weight_matrix_bucket + == EXPECTED_CONSTITUENCY_CONFIG["weight_matrix_bucket"] + ) + assert ( + result.weight_matrix_key + == EXPECTED_CONSTITUENCY_CONFIG["weight_matrix_key"] + ) + assert ( + result.lookup_csv_bucket + == EXPECTED_CONSTITUENCY_CONFIG["lookup_csv_bucket"] + ) assert result.lookup_csv_key == EXPECTED_CONSTITUENCY_CONFIG["lookup_csv_key"] def test__given_local_authority_weight_replacement__then_returns_weight_replacement_instance( @@ -306,10 +314,21 @@ def test__given_local_authority_weight_replacement__then_gcs_config_matches(self ) # Then - assert result.weight_matrix_bucket == EXPECTED_LOCAL_AUTHORITY_CONFIG["weight_matrix_bucket"] - assert result.weight_matrix_key == EXPECTED_LOCAL_AUTHORITY_CONFIG["weight_matrix_key"] - assert result.lookup_csv_bucket == EXPECTED_LOCAL_AUTHORITY_CONFIG["lookup_csv_bucket"] - assert result.lookup_csv_key == EXPECTED_LOCAL_AUTHORITY_CONFIG["lookup_csv_key"] + assert ( + result.weight_matrix_bucket + == EXPECTED_LOCAL_AUTHORITY_CONFIG["weight_matrix_bucket"] + ) + assert ( + result.weight_matrix_key + == EXPECTED_LOCAL_AUTHORITY_CONFIG["weight_matrix_key"] + ) + assert ( + result.lookup_csv_bucket + == EXPECTED_LOCAL_AUTHORITY_CONFIG["lookup_csv_bucket"] + ) + assert ( + result.lookup_csv_key == EXPECTED_LOCAL_AUTHORITY_CONFIG["lookup_csv_key"] + ) # --------------------------------------------------------------------------- diff --git a/tests/test_variable_labels.py b/tests/test_variable_labels.py index 3517abf..f2d3c6a 100644 --- a/tests/test_variable_labels.py +++ b/tests/test_variable_labels.py @@ -1,6 +1,5 @@ """Tests for variable label field across all variable endpoints.""" - from test_fixtures.fixtures_variables import ( # noqa: F811 create_variable, ) diff --git a/uv.lock b/uv.lock index 7381a88..702fbe0 100644 --- a/uv.lock +++ b/uv.lock @@ -1849,7 +1849,7 @@ wheels = [ [[package]] name = "policyengine-api-v2" -version = "0.3.0" +version = "0.3.1" source = { editable = "." } dependencies = [ { name = "alembic" }, From 7757b8e2c1463a7166f10fb46bc6f61f665ded81 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 11 Mar 2026 23:09:19 +0100 Subject: [PATCH 4/4] Fix missing fixture imports in test_variable_labels The us_model_version and uk_model_version fixtures were defined in test_fixtures/fixtures_variables.py but never imported into the test module. Pytest only discovers fixtures from conftest.py or explicit imports, so all 11 tests using these fixtures failed. Co-Authored-By: Claude Opus 4.6 --- tests/test_variable_labels.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_variable_labels.py b/tests/test_variable_labels.py index f2d3c6a..4ee7705 100644 --- a/tests/test_variable_labels.py +++ b/tests/test_variable_labels.py @@ -1,8 +1,14 @@ """Tests for variable label field across all variable endpoints.""" -from test_fixtures.fixtures_variables import ( # noqa: F811 +from test_fixtures.fixtures_variables import ( create_variable, ) +from test_fixtures.fixtures_variables import ( + uk_model_version as uk_model_version, # noqa: F811 +) +from test_fixtures.fixtures_variables import ( + us_model_version as us_model_version, # noqa: F811 +) # --------------------------------------------------------------------------- # GET /variables - label in list responses