From c92eefd887df29ebf2e013df48758612fe6c92c7 Mon Sep 17 00:00:00 2001 From: SongshGeo Date: Tue, 7 Apr 2026 16:38:12 +0200 Subject: [PATCH] fix(compatibility): :bug: Introduce mesa-geo compatibility handling and regression tests This commit adds a new module `mesa_raster_compat.py` to manage compatibility between different versions of `mesa` and `mesa-geo`, addressing initialization differences in raster layers. It also updates the `PatchModule` to safely handle transformations and cell synchronization. Additionally, a new CI job is introduced to test compatibility across minimum and latest versions of these dependencies, along with regression tests to ensure stability in raster initialization. Documentation is updated to reflect these changes and clarify dependency management. --- .github/workflows/tests.yml | 37 +++++++++++++++++++++ abses/space/mesa_raster_compat.py | 40 ++++++++++++++++++++++ abses/space/patch.py | 30 +++++++++++++++-- docs/home/dependencies.md | 26 ++++++++++----- docs/home/dependencies.zh.md | 22 +++++++++---- pyproject.toml | 3 ++ tests/api/test_patch_mesa_compat.py | 51 +++++++++++++++++++++++++++++ 7 files changed, 190 insertions(+), 19 deletions(-) create mode 100644 abses/space/mesa_raster_compat.py create mode 100644 tests/api/test_patch_mesa_compat.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1f192918..a36f9edb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -81,3 +81,40 @@ jobs: env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + deps-compat: + name: Mesa / mesa-geo compatibility (${{ matrix.variant }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - variant: min-supported + install_cmd: 'uv pip install "mesa==3.1.0" "mesa-geo==0.9.1"' + - variant: latest-upstream + install_cmd: "uv pip install -U mesa mesa-geo" + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install project dependencies + run: uv sync --dev + + - name: Override mesa / mesa-geo for this matrix leg + run: ${{ matrix.install_cmd }} + + - name: Show installed mesa versions + run: | + uv run python -c "import importlib.metadata as m; print('mesa', m.version('mesa')); print('mesa-geo', m.version('mesa-geo'))" + + - name: Run spatial / raster regression tests + run: | + uv run pytest tests/api/test_patch_mesa_compat.py tests/api/test_nature.py tests/foundation/test_basic_functionality.py -v --tb=short + diff --git a/abses/space/mesa_raster_compat.py b/abses/space/mesa_raster_compat.py new file mode 100644 index 00000000..5d6f2b37 --- /dev/null +++ b/abses/space/mesa_raster_compat.py @@ -0,0 +1,40 @@ +"""Compatibility helpers for mesa-geo raster APIs across versions. + +Mesa-geo's ``RasterLayer`` initialization and ``_update_transform`` behavior +varies between releases. ABSESpy uses ``PatchModule`` with ``_cells`` and a +``cached_property`` for ``cells``; upstream probes like ``getattr(self, +"cells", None)`` during early construction can recurse through ``__getattr__``. +This module centralizes safe, capability-based handling. +""" + +from __future__ import annotations + +from typing import Any + + +def raster_base_update_transform(instance: Any) -> None: + """Apply ``RasterBase._update_transform`` without ``RasterLayer`` side effects. + + Args: + instance: A ``RasterLayer`` subclass instance (e.g. ``PatchModule``). + """ + from mesa_geo.raster_layers import RasterBase + + RasterBase._update_transform(instance) + + +def maybe_sync_cell_xy(instance: Any) -> None: + """Call ``_sync_cell_xy`` when the upstream class provides it and cells exist. + + Older mesa-geo releases do not define ``_sync_cell_xy``. Newer releases + sync cell centers after transform updates when cell storage is ready. + + Args: + instance: A ``RasterLayer`` subclass instance (e.g. ``PatchModule``). + """ + if instance.__dict__.get("_cells") is None: + return + sync = getattr(type(instance), "_sync_cell_xy", None) + if sync is None: + return + sync(instance) diff --git a/abses/space/patch.py b/abses/space/patch.py index cd2cbc99..4640ab8d 100644 --- a/abses/space/patch.py +++ b/abses/space/patch.py @@ -42,6 +42,10 @@ from abses.core.base import BaseModule from abses.core.primitives import DEFAULT_CRS from abses.space.cells import PatchCell +from abses.space.mesa_raster_compat import ( + maybe_sync_cell_xy, + raster_base_update_transform, +) from abses.utils.errors import ABSESpyError from abses.utils.func import get_buffer, set_null_values from abses.utils.random import ListRandom @@ -285,6 +289,19 @@ def __init__( if apply_raster and xda is not None and attr_name is not None: self.apply_raster(xda.to_numpy(), attr_name=attr_name) + def _update_transform(self) -> None: + """Recompute affine transform and optionally sync cell centers. + + Mesa-geo may call this from ``RasterBase.__init__`` before cells exist. + Upstream ``RasterLayer._update_transform`` can probe ``cells`` via + ``getattr``, which is unsafe for ``PatchModule`` during early init. + We always apply ``RasterBase`` math first, then sync only when + ``_cells`` is populated (and when the installed mesa-geo provides + ``_sync_cell_xy``). + """ + raster_base_update_transform(self) + maybe_sync_cell_xy(self) + def _initialize_cells( self, model: MainModelProtocol, @@ -552,13 +569,20 @@ def __getattr__(self, name: str) -> Any: >>> # Save plot to file >>> grid.elevation.plot(save_path='elevation.png', show=False) """ - # Check if it's a raster attribute - if name in self.cell_properties: + # Avoid ``self.cell_properties`` while ``cell_cls`` is not set yet + # (e.g. during ``RasterBase.__init__``), which would recurse here. + try: + cell_cls = object.__getattribute__(self, "cell_cls") + except AttributeError as exc: + raise AttributeError( + f"'{self.__class__.__name__}' object has no attribute '{name}'" + ) from exc + + if name in cell_cls.__attribute_properties__(): from abses.viz import PlotableAttribute return PlotableAttribute(module=self, attr_name=name) - # Raise AttributeError if not found raise AttributeError( f"'{self.__class__.__name__}' object has no attribute '{name}'" ) diff --git a/docs/home/dependencies.md b/docs/home/dependencies.md index 12e00cc9..366bd335 100644 --- a/docs/home/dependencies.md +++ b/docs/home/dependencies.md @@ -3,16 +3,24 @@ | Package | Version | Purpose | |---------------|-------------------|-------------------------------------------------------| -| python | ">=3.9,<3.12" | Core programming language used for development | +| python | ">=3.11,<3.14" | Core programming language used for development | | netcdf4 | ">=1.6" | To read and write NetCDF and HDF5 files | -| hydra-core | "~1.3" | For managing application configurations | -| mesa-geo | ">=0.6" | To create spatially explicit agent-based models | -| xarray | "~2024" | To work with labelled multi-dimensional arrays | -| fiona | ">1.8" | For reading and writing vector data (shapefiles, etc) | -| loguru | "~0.7" | For better logging | -| rioxarray | ">=0.13" | Operating raster data and xarray | -| pendulum | "~2" | For time control | -| geopandas | "~0" | For shapefile geo-data operating | +| hydra-core | ">=1.3,<1.4" | For managing application configurations | +| mesa | ">=3.1.0" | Agent-based scheduling and core utilities | +| mesa-geo | ">=0.9.1" | Spatially explicit layers and raster support | +| xarray | ">=2023" | To work with labelled multi-dimensional arrays | +| fiona | ">1.8" | For reading and writing vector data (shapefiles, etc) | +| rioxarray | ">=0.13" | Operating raster data and xarray | +| pendulum | ">=3.0.0" | For time control | +| geopandas | ">=0,<1" | For shapefile geo-data operating | + +### Mesa / mesa-geo compatibility + +`ABSESpy` declares **lower bounds only** for `mesa` and `mesa-geo` in `pyproject.toml` so downstream projects can upgrade within their own constraints. Raster initialization differences across `mesa-geo` releases are handled in code (e.g. `abses/space/mesa_raster_compat.py` with `PatchModule`). + +CI runs a **dependency compatibility** job on Ubuntu that reinstalls the minimum supported `mesa` / `mesa-geo` pair and the latest published releases, then runs spatial regression tests. This does not guarantee every future `mesa-geo` major release will work without changes, but it catches regressions early. + +If you upgrade `mesa` / `mesa-geo` and see errors during `PatchModule` / raster setup, check the installed versions (`pip show mesa mesa-geo` or `uv pip list`) and open an issue with the traceback. !!! Warning diff --git a/docs/home/dependencies.zh.md b/docs/home/dependencies.zh.md index 7017c075..3a79b2a6 100644 --- a/docs/home/dependencies.zh.md +++ b/docs/home/dependencies.zh.md @@ -3,16 +3,24 @@ | 包 | 版本 | 用途 | |---------------|-------------------|----------------------------------------| -| python | ">=3.9,<3.12" | 开发使用的核心编程语言 | +| python | ">=3.11,<3.14" | 开发使用的核心编程语言 | | netcdf4 | ">=1.6" | 读写 NetCDF 和 HDF5 文件 | -| hydra-core | "~1.3" | 管理应用程序配置 | -| mesa-geo | ">=0.6" | 创建空间显式的基于智能体的模型 | -| xarray | "~2024" | 处理标记的多维数组 | +| hydra-core | ">=1.3,<1.4" | 管理应用程序配置 | +| mesa | ">=3.1.0" | 智能体调度与核心工具 | +| mesa-geo | ">=0.9.1" | 空间显式图层与栅格支持 | +| xarray | ">=2023" | 处理标记的多维数组 | | fiona | ">1.8" | 读写矢量数据(shapefiles 等) | -| loguru | "~0.7" | 更好的日志记录 | | rioxarray | ">=0.13" | 操作栅格数据和 xarray | -| pendulum | "~2" | 时间控制 | -| geopandas | "~0" | shapefile 地理数据操作 | +| pendulum | ">=3.0.0" | 时间控制 | +| geopandas | ">=0,<1" | shapefile 地理数据操作 | + +### Mesa / mesa-geo 兼容性说明 + +`ABSESpy` 在 `pyproject.toml` 中对 `mesa` 与 `mesa-geo` 仅声明**下界**,以便下游项目按需升级。不同 `mesa-geo` 版本在栅格初始化顺序上的差异在代码中处理(例如 `abses/space/mesa_raster_compat.py` 与 `PatchModule`)。 + +CI 在 Ubuntu 上运行**依赖兼容**任务:分别安装声明的最低版本与当前 PyPI 最新版本,再跑空间相关回归测试。这不保证未来任意大版本 `mesa-geo` 都无需改动,但能尽早发现破坏性变更。 + +若升级 `mesa` / `mesa-geo` 后在 `PatchModule` 或栅格初始化阶段报错,请记录 `pip show mesa mesa-geo`(或 `uv pip list`)中的版本并附上完整 traceback 提 issue。 !!! Warning "警告" diff --git a/pyproject.toml b/pyproject.toml index 88989737..5e4b786a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,9 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", ] +# Direct dependency bounds are intentionally loose for mesa / mesa-geo. +# Raster initialization differences across mesa-geo releases are handled in +# code (see abses/space/mesa_raster_compat.py) and exercised in CI. dependencies = [ "netcdf4>=1.6", "hydra-core>=1.3,<1.4", diff --git a/tests/api/test_patch_mesa_compat.py b/tests/api/test_patch_mesa_compat.py new file mode 100644 index 00000000..9f31060e --- /dev/null +++ b/tests/api/test_patch_mesa_compat.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +# -*-coding:utf-8 -*- +"""Regression tests for PatchModule vs mesa-geo raster initialization order.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from abses.core.model import MainModel +from abses.space.mesa_raster_compat import maybe_sync_cell_xy +from abses.space.patch import PatchModule + + +def test_create_patch_module_no_recursion(model: MainModel) -> None: + """Creating a grid must complete without RecursionError (mesa-geo 0.9.3+).""" + module = model.nature.create_module(shape=(4, 4), resolution=1.0) + assert isinstance(module, PatchModule) + assert module.array_cells.shape == (4, 4) + assert len(module.cells) == 4 + + +def test_update_transform_after_init_is_safe(model: MainModel) -> None: + """Transform updates after cells exist must remain safe.""" + module = model.nature.create_module(shape=(2, 3), resolution=1.0) + module._update_transform() + module._update_transform() + assert module.transform is not None + + +def test_maybe_sync_cell_xy_skips_without_cells() -> None: + """Helper must not fail when ``_cells`` is absent or sync is undefined.""" + + class _NoSync: + pass + + layer = _NoSync() + maybe_sync_cell_xy(layer) + + layer.__dict__["_cells"] = None + maybe_sync_cell_xy(layer) + + +@pytest.mark.parametrize("shape", [(1, 1), (5, 7)]) +def test_create_module_various_shapes(model: MainModel, shape: tuple[int, int]) -> None: + """Smoke test multiple raster dimensions.""" + module = model.nature.create_module(shape=shape, resolution=1.0) + assert module.width == shape[1] + assert module.height == shape[0] + module.apply_raster(np.ones(module.shape3d), attr_name="ones") + assert module.get_raster("ones").sum() == shape[0] * shape[1]