diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index b8cef9230..316052ffd 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -6,7 +6,7 @@ jobs: Lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Check formatting uses: "lgeiger/black-action@master" with: @@ -15,13 +15,13 @@ jobs: name: Check version runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 repository: ${{ github.event.pull_request.head.repo.full_name }} ref: ${{ github.event.pull_request.head.ref }} - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.12" - name: Build changelog @@ -34,21 +34,58 @@ jobs: strategy: matrix: os: [ ubuntu-latest, windows-latest ] + python-version: [ "3.12", "3.13" ] fail-fast: false runs-on: ${{ matrix.os }} steps: - name: Checkout repo uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 + - name: Enable long paths in Git (Windows Only) + if: runner.os == 'Windows' + run: git config --system core.longpaths true + - name: Enable Win32 long paths via registry (Windows Only) + if: runner.os == 'Windows' + run: reg add "HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\FileSystem" /v LongPathsEnabled /t REG_DWORD /d 1 /f + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: ${{ matrix.python-version }} - name: Install package run: make install - name: Run tests run: make test - - uses: codecov/codecov-action@v3 + - uses: codecov/codecov-action@v4 - name: Build package run: make build - name: Test documentation builds run: make documentation + SmokeTestForMultipleVersions: + name: Test Core and Country Compatibility on (${{ matrix.os }}, py${{ matrix.python-version }}) + runs-on: ${{ matrix.os }} + needs: Lint + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ['3.10', '3.11', '3.12', '3.13'] + steps: + - name: Checkout repo + uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install -core package with dev deps + # run: python -m pip install . + # Installing with dev deps to see if setuptools provides distutils (removed in Python 3.12) + run: python -m pip install ".[dev]" + - name: Install -us package from PyPI + # Skip policyengine-us installation for Python 3.13 until it's updated + if: matrix.python-version != '3.13' + run: python -m pip install policyengine-us + - name: Run smoke tests only + # Only run smoke tests if policyengine-us was installed + if: matrix.python-version != '3.13' + run: pytest -m smoke + env: + RUN_SMOKE_TESTS: "1" \ No newline at end of file diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index 9cf4a8cf5..8239a1d4e 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -9,7 +9,7 @@ jobs: (github.repository == 'PolicyEngine/policyengine-core') && (github.event.head_commit.message == 'Update PolicyEngine Core') steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Check formatting uses: "lgeiger/black-action@master" with: @@ -22,15 +22,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: ${{ github.event.pull_request.head.repo.full_name }} ref: ${{ github.event.pull_request.head.ref }} token: ${{ secrets.POLICYENGINE_GITHUB }} - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: Build changelog run: pip install yaml-changelog && make changelog - name: Preview changelog update @@ -49,16 +49,16 @@ jobs: && (github.event.head_commit.message == 'Update PolicyEngine Core') steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: Install package run: make install - name: Run tests run: make test - - uses: codecov/codecov-action@v3 + - uses: codecov/codecov-action@v4 - name: Generate documentation run: make documentation - name: Deploy documentation @@ -75,11 +75,11 @@ jobs: && (github.event.head_commit.message == 'Update PolicyEngine Core') steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" - name: Publish a git tag run: ".github/publish-git-tag.sh || true" - name: Install package diff --git a/Makefile b/Makefile index 33da57efa..adefd27a1 100644 --- a/Makefile +++ b/Makefile @@ -10,8 +10,6 @@ format: install: pip install -e ".[dev]" --config-settings editable_mode=compat - pip install policyengine-us - pip install policyengine-uk test-country-template: policyengine-core test policyengine_core/country_template/tests -c policyengine_core.country_template diff --git a/README.md b/README.md index c67bd7568..bd8fcafd0 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ This package, a fork of [OpenFisca-Core](https://github.com/OpenFisca/OpenFisca- # Prerequisites -Python 3.10 or beyond is required. +Python 3.10, 3.11, 3.12, or 3.13 is required. # Setting Up diff --git a/changelog_entry.yaml b/changelog_entry.yaml index e69de29bb..89ad20648 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -0,0 +1,5 @@ +- bump: minor + changes: + changed: + - added support for python 3.13.0 + - upgraded dependency to numpy 2.1.0 \ No newline at end of file diff --git a/policyengine_core/commons/formulas.py b/policyengine_core/commons/formulas.py index 4695cf9c6..7d316c364 100644 --- a/policyengine_core/commons/formulas.py +++ b/policyengine_core/commons/formulas.py @@ -92,6 +92,11 @@ def concat(this: ArrayLike[str], that: ArrayLike[str]) -> ArrayType[str]: array(['this1.0', 'that2.5']...) """ + if isinstance(this, tuple): + raise TypeError("First argument must not be a tuple.") + + if isinstance(that, tuple): + raise TypeError("Second argument must not be a tuple.") if isinstance(this, numpy.ndarray) and not numpy.issubdtype( this.dtype, numpy.str_ diff --git a/policyengine_core/enums/enum_array.py b/policyengine_core/enums/enum_array.py index 1bf5148b6..c518add8c 100644 --- a/policyengine_core/enums/enum_array.py +++ b/policyengine_core/enums/enum_array.py @@ -96,7 +96,8 @@ def decode_to_str(self) -> numpy.str_: """ return numpy.select( [self == item.index for item in self.possible_values], - [item.name for item in self.possible_values], + [str(item.name) for item in self.possible_values], + default="unknown", ) def __repr__(self) -> str: diff --git a/policyengine_core/parameters/parameter_node_at_instant.py b/policyengine_core/parameters/parameter_node_at_instant.py index 64e3d3567..67f4695eb 100644 --- a/policyengine_core/parameters/parameter_node_at_instant.py +++ b/policyengine_core/parameters/parameter_node_at_instant.py @@ -72,6 +72,4 @@ def __repr__(self) -> str: for name, value in self._children.items() ] ) - if sys.version_info < (3, 0): - return result return result diff --git a/policyengine_core/parameters/vectorial_parameter_node_at_instant.py b/policyengine_core/parameters/vectorial_parameter_node_at_instant.py index dbd1d14b9..0b50d928b 100644 --- a/policyengine_core/parameters/vectorial_parameter_node_at_instant.py +++ b/policyengine_core/parameters/vectorial_parameter_node_at_instant.py @@ -203,13 +203,15 @@ def __getitem__(self, key: str) -> Any: enum = type(key[0]) key = numpy.select( [key == item for item in enum], - [item.name for item in enum], + [str(item.name) for item in enum], + default="unknown", ) elif isinstance(key, EnumArray): enum = key.possible_values key = numpy.select( [key == item.index for item in enum], [item.name for item in enum], + default="unknown", ) else: key = key.astype("str") diff --git a/policyengine_core/populations/group_population.py b/policyengine_core/populations/group_population.py index e201d0f50..fd6b1bee2 100644 --- a/policyengine_core/populations/group_population.py +++ b/policyengine_core/populations/group_population.py @@ -232,7 +232,7 @@ def max(self, array: ArrayLike, role: Role = None) -> ArrayLike: return self.reduce( array, reducer=numpy.maximum, - neutral_element=-numpy.infty, + neutral_element=-numpy.inf, role=role, ) @@ -256,7 +256,7 @@ def min(self, array: ArrayLike, role: Role = None) -> ArrayLike: return self.reduce( array, reducer=numpy.minimum, - neutral_element=numpy.infty, + neutral_element=numpy.inf, role=role, ) diff --git a/policyengine_core/populations/population.py b/policyengine_core/populations/population.py index a04b8de63..a6d992cbb 100644 --- a/policyengine_core/populations/population.py +++ b/policyengine_core/populations/population.py @@ -253,7 +253,11 @@ def get_rank( # We double-argsort all lines of the matrix. # Double-argsorting gets the rank of each value once sorted # For instance, if x = [3,1,6,4,0], y = numpy.argsort(x) is [4, 1, 0, 3, 2] (because the value with index 4 is the smallest one, the value with index 1 the second smallest, etc.) and z = numpy.argsort(y) is [2, 1, 4, 3, 0], the rank of each value. - sorted_matrix = numpy.argsort(numpy.argsort(matrix)) + + # because of the infinities the first sort creates positional indices + # The second argsort converts these positions to ranks, thus fixes the broken sort issue + first_argsort = numpy.argsort(matrix, axis=1, kind="stable") + sorted_matrix = numpy.argsort(first_argsort, axis=1, kind="stable") # Build the result vector by taking for each person the value in the right line (corresponding to its household id) and the right column (corresponding to its position) result = sorted_matrix[ids, positions] diff --git a/policyengine_core/taxscales/marginal_rate_tax_scale.py b/policyengine_core/taxscales/marginal_rate_tax_scale.py index 2da91c53f..3453f0199 100644 --- a/policyengine_core/taxscales/marginal_rate_tax_scale.py +++ b/policyengine_core/taxscales/marginal_rate_tax_scale.py @@ -66,12 +66,12 @@ def calc( # # numpy.finfo(float_).eps thresholds1 = numpy.outer( - factor + numpy.finfo(numpy.float_).eps, + factor + numpy.finfo(numpy.float64).eps, numpy.array(self.thresholds + [numpy.inf]), ) if round_base_decimals is not None: - thresholds1 = numpy.round_(thresholds1, round_base_decimals) + thresholds1 = numpy.round(thresholds1, round_base_decimals) a = numpy.maximum( numpy.minimum(base1, thresholds1[:, 1:]) - thresholds1[:, :-1], 0 @@ -82,8 +82,8 @@ def calc( else: r = numpy.tile(self.rates, (len(tax_base), 1)) - b = numpy.round_(a, round_base_decimals) - return numpy.round_(r * b, round_base_decimals).sum(axis=1) + b = numpy.round(a, round_base_decimals) + return numpy.round(r * b, round_base_decimals).sum(axis=1) def combine_bracket( self, diff --git a/policyengine_core/taxscales/rate_tax_scale_like.py b/policyengine_core/taxscales/rate_tax_scale_like.py index b598f4470..79d39728b 100644 --- a/policyengine_core/taxscales/rate_tax_scale_like.py +++ b/policyengine_core/taxscales/rate_tax_scale_like.py @@ -175,12 +175,12 @@ def bracket_indices( # # numpy.finfo(float_).eps thresholds1 = numpy.outer( - +factor + numpy.finfo(numpy.float_).eps, + +factor + numpy.finfo(numpy.float64).eps, numpy.array(self.thresholds), ) if round_decimals is not None: - thresholds1 = numpy.round_(thresholds1, round_decimals) + thresholds1 = numpy.round(thresholds1, round_decimals) return (base1 - thresholds1 >= 0).sum(axis=1) - 1 diff --git a/policyengine_core/tools/simulation_dumper.py b/policyengine_core/tools/simulation_dumper.py index dd0c0ad6b..4e2f8a183 100644 --- a/policyengine_core/tools/simulation_dumper.py +++ b/policyengine_core/tools/simulation_dumper.py @@ -95,7 +95,8 @@ def _dump_entity(population, directory): else: encoded_roles = np.select( [population.members_role == role for role in flattened_roles], - [role.key for role in flattened_roles], + [str(role.key) for role in flattened_roles], + default="unknown", ) np.save(os.path.join(path, "members_role.npy"), encoded_roles) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..8da4f3d69 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ + +[pytest] +markers = + smoke: mark a test as part of the smoke suite + diff --git a/setup.py b/setup.py index 6bd526d55..b686c987e 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ general_requirements = [ "pytest>=8,<9", - "numpy~=1.26.4", + "numpy~=2.1.0", "sortedcontainers<3", "numexpr<3", "dpath<3", @@ -25,6 +25,7 @@ "pyvis>=0.3.2", "microdf_python>=1.0.0", "huggingface_hub>=0.25.1", + "standard-imghdr", ] dev_requirements = [ @@ -60,6 +61,7 @@ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Scientific/Engineering :: Information Analysis", ], description="Core microsimulation engine enabling country-specific policy models.", diff --git a/tests/core/commons/test_formulas.py b/tests/core/commons/test_formulas.py index db22fc477..ecd3e1828 100644 --- a/tests/core/commons/test_formulas.py +++ b/tests/core/commons/test_formulas.py @@ -79,3 +79,13 @@ def test_switch_when_values_are_empty(): with pytest.raises(AssertionError): assert commons.switch(conditions, value_by_condition) + + +def test_concat_tuple_inputs(): + with pytest.raises(TypeError, match="First argument must not be a tuple."): + commons.concat(("a", "b"), numpy.array(["c", "d"])) + + with pytest.raises( + TypeError, match="Second argument must not be a tuple." + ): + commons.concat(numpy.array(["a", "b"]), ("c", "d")) diff --git a/tests/core/parameters_fancy_indexing/test_fancy_indexing.py b/tests/core/parameters_fancy_indexing/test_fancy_indexing.py index d753586f0..476a4f383 100644 --- a/tests/core/parameters_fancy_indexing/test_fancy_indexing.py +++ b/tests/core/parameters_fancy_indexing/test_fancy_indexing.py @@ -114,5 +114,10 @@ class TypesZone(Enum): z1 = "Zone 1" z2 = "Zone 2" - zone = np.asarray([TypesZone.z1, TypesZone.z2, TypesZone.z2, TypesZone.z1]) + zone = np.asarray( + [ + z.name + for z in [TypesZone.z1, TypesZone.z2, TypesZone.z2, TypesZone.z1] + ] + ) assert_near(P.single.owner[zone], [100, 200, 200, 100]) diff --git a/tests/core/test_tracers.py b/tests/core/test_tracers.py index 266b79823..66410692f 100644 --- a/tests/core/test_tracers.py +++ b/tests/core/test_tracers.py @@ -403,7 +403,8 @@ def test_log_aggregate(tracer): lines = tracer.computation_log.lines(aggregate=True) assert ( - lines[0] == " A<2017, (default)> = {'avg': 1.0, 'max': 1, 'min': 1}" + lines[0].strip() + == "A<2017, (default)> = {'avg': np.float64(1.0), 'max': np.int64(1), 'min': np.int64(1)}" ) diff --git a/tests/test_us.py b/tests/smoke/test_us.py similarity index 55% rename from tests/test_us.py rename to tests/smoke/test_us.py index 8ad7f3724..65d328681 100644 --- a/tests/test_us.py +++ b/tests/smoke/test_us.py @@ -1,3 +1,12 @@ +import os +import pytest + + +@pytest.mark.smoke +@pytest.mark.skipif( + os.getenv("RUN_SMOKE_TESTS") != "1", + reason="Skip smoke tests unless explicitly enabled", +) def test_policyengine_us_microsimulation_runs(): from policyengine_us import Microsimulation