diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 630905e..47db3ab 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -18,15 +18,15 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.13" - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@v8.1.0 with: enable-cache: true cache-dependency-glob: "**/pyproject.toml" @@ -43,12 +43,12 @@ jobs: touch _build/html/.nojekyll - name: Setup Pages - uses: actions/configure-pages@v4 + uses: actions/configure-pages@v6 with: enablement: true - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: ./docs/_build/html @@ -61,4 +61,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 \ No newline at end of file + uses: actions/deploy-pages@v5 \ No newline at end of file diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 183042c..63ccb93 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -9,15 +9,15 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.13" - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@v8.1.0 with: enable-cache: true cache-dependency-glob: "**/pyproject.toml" @@ -38,15 +38,15 @@ jobs: python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@v8.1.0 with: enable-cache: true cache-dependency-glob: "**/pyproject.toml" @@ -63,15 +63,15 @@ jobs: name: Build Documentation runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.13" - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@v8.1.0 with: enable-cache: true cache-dependency-glob: "**/pyproject.toml" @@ -88,15 +88,15 @@ jobs: name: YAML Policy Tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.13" - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@v8.1.0 with: enable-cache: true cache-dependency-glob: "**/pyproject.toml" @@ -113,7 +113,7 @@ jobs: name: Check changelog fragment runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Check for changelog fragment run: | FRAGMENTS=$(find changelog.d -type f ! -name '.gitkeep' | wc -l) diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index a68eea9..fcc197d 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -9,15 +9,15 @@ jobs: name: Lint, Test and Build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.13" - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@v8.1.0 with: enable-cache: true cache-dependency-glob: "**/pyproject.toml" @@ -50,13 +50,13 @@ jobs: needs: lint-test-build steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.13" @@ -81,15 +81,15 @@ jobs: needs: lint-test-build steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.13" - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@v8.1.0 with: enable-cache: true cache-dependency-glob: "**/pyproject.toml" @@ -117,10 +117,12 @@ jobs: # if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') # # steps: - # - uses: actions/checkout@v4 + # - uses: actions/checkout@v6 # # - name: Set up Python - # uses: actions/setup-python@v5 + # uses: actions/setup-python@v6 + with: + python-version: "3.12" # with: # python-version: "3.13" # diff --git a/policyengine_au/tests/conftest.py b/policyengine_au/tests/conftest.py index 85d8884..444cbbe 100644 --- a/policyengine_au/tests/conftest.py +++ b/policyengine_au/tests/conftest.py @@ -6,11 +6,31 @@ import pytest import yaml -from pathlib import Path +import re +import numpy as np from policyengine_au import AustralianTaxBenefitSystem from policyengine_core.simulations import Simulation +PERIOD_PATTERN = re.compile(r"^\d{4}(-\d{2}(-\d{2})?)?$") + + +def is_period_key(key): + return str(key) == "ETERNITY" or bool(PERIOD_PATTERN.match(str(key))) + + +def normalize_input_values(value, period): + if isinstance(value, dict): + if value and all(is_period_key(key) for key in value): + return value + return { + key: normalize_input_values(child, period) for key, child in value.items() + } + if isinstance(value, list): + return value + return {period: value} + + def pytest_collect_file(parent, path): """Custom collector for YAML test files.""" if path.ext == ".yaml" and path.basename.startswith("test_"): @@ -44,34 +64,54 @@ def runtest(self): # Build situation from input situation = self.spec.get("input", {}) period = str(self.spec.get("period", "2024")) + situation = normalize_input_values(situation, period) # Create simulation simulation = Simulation(tax_benefit_system=system, situation=situation) # Check outputs expected_outputs = self.spec.get("output", {}) - for variable_name, expected_value in expected_outputs.items(): - if isinstance(expected_value, dict): - # Handle per-entity outputs - for entity_name, entity_expected in expected_value.items(): - calculated = simulation.calculate(variable_name, period) - # Would need entity index mapping here - pass - else: - # Simple case: single value - calculated = simulation.calculate(variable_name, period) - if hasattr(calculated, "__len__"): - calculated = calculated[0] - - # Allow small tolerance for floating point comparisons - if isinstance(expected_value, (int, float)): - assert abs(calculated - expected_value) < 0.01, ( - f"{variable_name}: expected {expected_value}, got {calculated}" - ) - else: - assert calculated == expected_value, ( - f"{variable_name}: expected {expected_value}, got {calculated}" + person_ids = list(situation.get("people", {}).keys()) + + for output_key, expected_value in expected_outputs.items(): + if output_key in system.variables: + self.assert_variable(simulation, output_key, period, expected_value) + elif isinstance(expected_value, dict) and output_key in person_ids: + for variable_name, entity_expected in expected_value.items(): + person_index = person_ids.index(output_key) + self.assert_variable( + simulation, + variable_name, + period, + entity_expected, + index=person_index, ) + else: + raise AssertionError(f"Unknown output target: {output_key}") + + def assert_variable( + self, simulation, variable_name, period, expected_value, index=0 + ): + calculated = simulation.calculate(variable_name, period) + if isinstance(calculated, np.ndarray): + calculated = calculated[index] + elif hasattr(calculated, "__len__") and not isinstance( + calculated, (str, bytes) + ): + calculated = calculated[index] + + if isinstance(expected_value, bool): + assert bool(calculated) is expected_value, ( + f"{variable_name}: expected {expected_value}, got {calculated}" + ) + elif isinstance(expected_value, (int, float)): + assert abs(calculated - expected_value) < 0.01, ( + f"{variable_name}: expected {expected_value}, got {calculated}" + ) + else: + assert calculated == expected_value, ( + f"{variable_name}: expected {expected_value}, got {calculated}" + ) def reportinfo(self): """Report test location.""" diff --git a/policyengine_au/tests/policy/baseline/test_income_tax.yaml b/policyengine_au/tests/policy/baseline/test_income_tax.yaml index ad78986..3909cdd 100644 --- a/policyengine_au/tests/policy/baseline/test_income_tax.yaml +++ b/policyengine_au/tests/policy/baseline/test_income_tax.yaml @@ -53,7 +53,7 @@ employment_income: 120_000 output: taxable_income: 120_000 - income_tax: 28_467 # Progressive tax calculation + income_tax: 29_467 # Progressive tax calculation medicare_levy: 2_400 # 2% of $120,000 - name: High income earner with $200,000 income @@ -69,7 +69,7 @@ medicare_levy: 4_000 # 2% of $200,000 - name: Stage 3 tax cuts example - $150,000 income - period: 2024-07-01 + period: 2025 input: people: person_1: @@ -77,5 +77,5 @@ employment_income: 150_000 output: taxable_income: 150_000 - income_tax: 39_567 # With Stage 3 tax cuts - medicare_levy: 3_000 # 2% of $150,000 \ No newline at end of file + income_tax: 37_642 # With Stage 3 tax cuts + medicare_levy: 3_000 # 2% of $150,000 diff --git a/policyengine_au/variables/gov/dss/age_pension/age_pension_eligible.py b/policyengine_au/variables/gov/dss/age_pension/age_pension_eligible.py index 77812b8..d05cf43 100644 --- a/policyengine_au/variables/gov/dss/age_pension/age_pension_eligible.py +++ b/policyengine_au/variables/gov/dss/age_pension/age_pension_eligible.py @@ -16,7 +16,7 @@ def formula(person, period, parameters): p_eligibility = parameters(period).gov.dss.age_pension.eligibility # Current age threshold is 67 - age_threshold = p_eligibility.age_threshold.current + age_threshold = p_eligibility.age_threshold # Check age eligibility age_eligible = age >= age_threshold diff --git a/policyengine_au/variables/gov/states/payroll_tax.py b/policyengine_au/variables/gov/states/payroll_tax.py index c1f9a8a..5231057 100644 --- a/policyengine_au/variables/gov/states/payroll_tax.py +++ b/policyengine_au/variables/gov/states/payroll_tax.py @@ -1,5 +1,7 @@ from policyengine_core.model_api import * from policyengine_au.entities import * +from policyengine_au.variables.input.demographics.state import StateCode +import numpy as np class state_payroll_tax(Variable): @@ -30,7 +32,7 @@ def formula(household, period, parameters): } # Calculate tax for each state and select the appropriate one - tax = zeros(household.count) + tax = np.zeros(household.count) for state_code, tax_variable in state_tax_map.items(): is_state = state == state_code state_tax = household(tax_variable, period)