diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index 699b51b8a..b7af96404 100644 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -20,7 +20,6 @@ jobs: # Basic configurations to run on os: ["ubuntu-latest"] python-version: - - "3.8" - "3.9" - "3.10" - "3.11" diff --git a/CHANGELOG.md b/CHANGELOG.md index e387fd522..90cfde9d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed +- Require at least Python 3.9 (drop support for Python 3.8, [#717](https://github.com/Open-EO/openeo-python-client/issues/717)) + ### Fixed - `metadata_from_stac()` now keeps declared STAC `cube:dimensions` as the dimension source of truth and handles STAC 1.1 common `bands` metadata without requiring the datacube extension ([#743](https://github.com/Open-EO/openeo-python-client/issues/743), [#867](https://github.com/Open-EO/openeo-python-client/pull/867)). diff --git a/Jenkinsfile b/Jenkinsfile index 0d23a1900..9a645c5c6 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -12,7 +12,7 @@ pythonPipeline { package_name = 'openeo' wipeout_workspace = true - python_version = ["3.8"] + python_version = ["3.11"] extras_require = 'tests' upload_dev_wheels = false wheel_repo = 'python-openeo' diff --git a/openeo/extra/spectral_indices/spectral_indices.py b/openeo/extra/spectral_indices/spectral_indices.py index e5e58a879..94548e562 100644 --- a/openeo/extra/spectral_indices/spectral_indices.py +++ b/openeo/extra/spectral_indices/spectral_indices.py @@ -1,4 +1,5 @@ import functools +import importlib.resources import json import re from typing import Dict, List, Optional, Set @@ -8,11 +9,6 @@ from openeo.processes import ProcessBuilder, array_create, array_modify from openeo.rest.datacube import DataCube -try: - import importlib_resources -except ImportError: - import importlib.resources as importlib_resources - @functools.lru_cache(maxsize=1) def load_indices() -> Dict[str, dict]: @@ -26,7 +22,7 @@ def load_indices() -> Dict[str, dict]: # and provide an alternative mechanism to work with custom indices "resources/extra-indices-dict.json", ]: - resource = importlib_resources.files("openeo.extra.spectral_indices") / path + resource = importlib.resources.files("openeo.extra.spectral_indices") / path data = json.loads(resource.read_text(encoding="utf8")) overwrites = set(specs.keys()).intersection(data["SpectralIndices"].keys()) if overwrites: @@ -41,7 +37,7 @@ def load_constants() -> Dict[str, float]: """Load constants defined by Awesome Spectral Indices.""" # TODO: encapsulate all this json loading in a single Awesome Spectral Indices registry class? resource = ( - importlib_resources.files("openeo.extra.spectral_indices") / "resources/awesome-spectral-indices/constants.json" + importlib.resources.files("openeo.extra.spectral_indices") / "resources/awesome-spectral-indices/constants.json" ) data = json.loads(resource.read_text(encoding="utf8")) @@ -53,7 +49,7 @@ def _load_bands() -> Dict[str, dict]: """Load band name mapping defined by Awesome Spectral Indices.""" # TODO: encapsulate all this json loading in a single Awesome Spectral Indices registry class? resource = ( - importlib_resources.files("openeo.extra.spectral_indices") / "resources/awesome-spectral-indices/bands.json" + importlib.resources.files("openeo.extra.spectral_indices") / "resources/awesome-spectral-indices/bands.json" ) data = json.loads(resource.read_text(encoding="utf8")) return data diff --git a/openeo/metadata.py b/openeo/metadata.py index 8dc355dba..21c249f57 100644 --- a/openeo/metadata.py +++ b/openeo/metadata.py @@ -693,9 +693,6 @@ def metadata_from_stac(url: str) -> CubeMetadata: parser = _StacMetadataParser() return parser.metadata_from_stac_object(stac_object) -# Sniff for PySTAC extension API since version 1.9.0 (which is not available below Python 3.9) -# TODO: remove this once support for Python 3.7 and 3.8 is dropped -_PYSTAC_1_9_EXTENSION_INTERFACE = hasattr(pystac.Item, "ext") # Sniff for PySTAC support for Collection.item_assets (in STAC core since 1.1) # (supported since PySTAC 1.12.0, which requires Python>=3.10) @@ -845,8 +842,7 @@ def _parse_declared_dimensions(self, stac_object: pystac.STACObject, bands: _Ban Parse dimensions declared through cube:dimensions. """ if ( - _PYSTAC_1_9_EXTENSION_INTERFACE - and getattr(stac_object, "ext", None) is not None + getattr(stac_object, "ext", None) is not None and stac_object.ext.has("cube") and hasattr(stac_object.ext, "cube") ): @@ -960,7 +956,7 @@ def bands_from_stac_catalog(self, catalog: pystac.Catalog, *, on_empty: str = _O summaries = catalog.extra_fields.get("summaries", {}) self._warn(f"bands_from_stac_catalog with {summaries.keys()=} (which is non-standard)") if "eo:bands" in summaries: - if _PYSTAC_1_9_EXTENSION_INTERFACE and not catalog.ext.has("eo"): + if not catalog.ext.has("eo"): self._warn_undeclared_metadata(field="eo:bands", ext="eo") return _BandList(self._band_from_eo_bands_metadata(b) for b in summaries["eo:bands"]) elif "bands" in summaries: @@ -985,7 +981,7 @@ def bands_from_stac_collection( self._log(f"bands_from_stac_collection with {collection.summaries.lists.keys()=}") # Look for band metadata in collection summaries if "eo:bands" in collection.summaries.lists: - if _PYSTAC_1_9_EXTENSION_INTERFACE and not collection.ext.has("eo"): + if not collection.ext.has("eo"): self._warn_undeclared_metadata(field="eo:bands", ext="eo") return _BandList(self._band_from_eo_bands_metadata(b) for b in collection.summaries.lists["eo:bands"]) elif "bands" in collection.summaries.lists: @@ -998,8 +994,7 @@ def bands_from_stac_collection( elif _PYSTAC_1_12_ITEM_ASSETS and collection.item_assets: return self._bands_from_item_assets(collection.item_assets) elif ( - _PYSTAC_1_9_EXTENSION_INTERFACE - and collection.ext.has("item_assets") + collection.ext.has("item_assets") and collection.extra_fields.get("item-assets") and collection.ext.item_assets ): @@ -1068,10 +1063,10 @@ def bands_from_stac_asset(self, asset: pystac.Asset, *, on_empty: str = _ON_EMPT """ # TODO: "eo:bands" vs "bands" priority based on STAC and EO extension version information # TODO: filter on asset roles? - if _PYSTAC_1_9_EXTENSION_INTERFACE and asset.owner and asset.ext.has("eo") and asset.ext.eo.bands is not None: + if asset.owner and asset.ext.has("eo") and asset.ext.eo.bands is not None: return _BandList(self._band_from_eo_bands_metadata(b) for b in asset.ext.eo.bands) elif "eo:bands" in asset.extra_fields: - if _PYSTAC_1_9_EXTENSION_INTERFACE and asset.owner and not asset.ext.has("eo"): + if asset.owner and not asset.ext.has("eo"): self._warn_undeclared_metadata(field="eo:bands", ext="eo") return _BandList(self._band_from_eo_bands_metadata(b) for b in asset.extra_fields["eo:bands"]) elif "bands" in asset.extra_fields: @@ -1095,7 +1090,7 @@ def _bands_from_item_asset_definition( """ if isinstance(asset, pystac.extensions.item_assets.AssetDefinition): if "eo:bands" in asset.properties: - if _PYSTAC_1_9_EXTENSION_INTERFACE and asset.owner and not asset.ext.has("eo"): + if asset.owner and not asset.ext.has("eo"): self._warn_undeclared_metadata(field="eo:bands", ext="eo") return _BandList(self._band_from_eo_bands_metadata(b) for b in asset.properties["eo:bands"]) elif "bands" in asset.properties: @@ -1104,7 +1099,7 @@ def _bands_from_item_asset_definition( if "bands" in asset.properties: return _BandList(self._band_from_common_bands_metadata(b) for b in asset.properties["bands"]) elif "eo:bands" in asset.properties: - if _PYSTAC_1_9_EXTENSION_INTERFACE and asset.owner and not asset.ext.has("eo"): + if asset.owner and not asset.ext.has("eo"): self._warn_undeclared_metadata(field="eo:bands", ext="eo") return _BandList(self._band_from_eo_bands_metadata(b) for b in asset.properties["eo:bands"]) else: diff --git a/openeo/udf/debug.py b/openeo/udf/debug.py index 3cb408494..80a361137 100644 --- a/openeo/udf/debug.py +++ b/openeo/udf/debug.py @@ -26,5 +26,4 @@ def inspect(data=None, message: str = "", code: str = "User", level: str = "info .. seealso:: :ref:`udf_logging_with_inspect` """ extra = {"data": data, "code": code} - kwargs = {"stacklevel": 2} if sys.version_info >= (3, 8) else {} - _user_log.log(level=logging.getLevelName(level.upper()), msg=message, extra=extra, **kwargs) + _user_log.log(level=logging.getLevelName(level.upper()), msg=message, extra=extra, stacklevel=2) diff --git a/pyproject.toml b/pyproject.toml index be80f0f3d..5b8804105 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,4 @@ [build-system] -# Setuptools 75 is the latest version that works with Python 3.8. requires = ["setuptools>=75"] build-backend = "setuptools.build_meta" @@ -9,14 +8,13 @@ dynamic = ["version"] description = "Client API for openEO" readme = "README.md" license = { text = "Apache-2.0" } -requires-python = ">=3.8" +requires-python = ">=3.9" authors = [ { name = "Jeroen Dries", email = "jeroen.dries@vito.be" }, ] classifiers = [ "Development Status :: 5 - Production/Stable", "Operating System :: OS Independent", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -26,16 +24,11 @@ classifiers = [ ] dependencies = [ "deprecated>=1.2.12", - # TODO #717 Simplify geopandas constraints when Python 3.8 support is dropped - "geopandas", # Best-effort geopandas dependency for Python 3.8 - "geopandas>=0.14; python_version>='3.9'", - "importlib_resources; python_version<'3.9'", + "geopandas>=0.14", "numpy>=1.17.0", "oschmod>=0.3.12; sys_platform == \"win32\"", "pandas>0.20.0,<3.0.0", # TODO pandas 3 compatibility https://github.com/Open-EO/openeo-python-client/issues/856 - # TODO #578: pystac 1.5.0 is highest version available for lowest Python - # version we still support (3.7). - "pystac>=1.5.0", + "pystac>=1.9.0", "requests>=2.26.0", "shapely>=1.6.4", "urllib3>=1.9.0", @@ -49,20 +42,17 @@ artifacts = [ ] dev = [ "boto3", - "boto3~=1.37.38; python_version<'3.9'", # Pin to speed up slow dependency resolution on Python 3.8. "botocore", - "botocore~=1.37.38; python_version<'3.9'", # Pin to speed up slow dependency resolution on Python 3.8. "dirty_equals>=0.8.0", "flake8>=5.0.0", "httpretty>=1.1.4", "mock", "moto>=5.0.0", - "moto~=5.0.28; python_version<'3.9'", # Pin to speed up slow dependency resolution on Python 3.8. "myst-parser", "netCDF4>=1.7.0", "pyarrow>=10.0.1", "pydata_sphinx_theme", - "pyproj>=3.2.0", # Pyproj is an optional, best-effort runtime dependency + "pyproj>=3.3.0", # Pyproj is an optional, best-effort runtime dependency "pystac-client>=0.7.5", "pytest>=4.5.0", "python-dateutil>=2.7.0", @@ -100,19 +90,15 @@ oschmod = [ ] tests = [ "boto3", - "boto3~=1.37.38; python_version<'3.9'", # Pin to speed up slow dependency resolution on Python 3.8. "botocore", - "botocore~=1.37.38; python_version<'3.9'", # Pin to speed up slow dependency resolution on Python 3.8. "dirty_equals>=0.8.0", "flake8>=5.0.0", "httpretty>=1.1.4", "mock", "moto>=5.0.0", - # Some pins to speed up slow dependency resolution in Python 3.8 venvs - "moto~=5.0.28; python_version<'3.9'", # Pin to speed up slow dependency resolution on Python 3.8. "netCDF4>=1.7.0", "pyarrow>=10.0.1", # For Parquet read/write support in pandas - "pyproj>=3.2.0", + "pyproj>=3.3.0", "pystac-client>=0.7.5", "pytest>=4.5.0", "python-dateutil>=2.7.0", @@ -140,7 +126,7 @@ version = {attr = "openeo._version.__version__"} include = ["openeo*"] [tool.uv] -# Version constraints for optionals aren't solvable with older Python versions. +# Version constraints for some optional dependencies aren't solvable with older Python versions. environments = ["python_version >= '3.10'"] [tool.black] diff --git a/tests/extra/job_management/test_job_db.py b/tests/extra/job_management/test_job_db.py index efd1a09e0..87668fcde 100644 --- a/tests/extra/job_management/test_job_db.py +++ b/tests/extra/job_management/test_job_db.py @@ -268,10 +268,6 @@ def test_initialize_from_df_on_exists_skip(self, tmp_path): ) assert set(db.read()["some_number"]) == {1, 2, 3} - @pytest.mark.skipif( - ComparableVersion(geopandas.__version__) < "0.14", - reason="This issue has no workaround with geopandas < 0.14 (highest available version on Python 3.8 is 0.13.2)", - ) def test_read_with_crs_column(self, tmp_path): """ Having a column named "crs" can cause obscure error messages when creating a GeoPandas dataframe diff --git a/tests/rest/datacube/test_datacube100.py b/tests/rest/datacube/test_datacube100.py index 5120931e6..1f4606ef1 100644 --- a/tests/rest/datacube/test_datacube100.py +++ b/tests/rest/datacube/test_datacube100.py @@ -193,17 +193,6 @@ } -def _get_normalizable_crs_inputs(): - """ - Dynamic (proj version based) generation of supported CRS inputs (to normalize). - :return: - """ - yield "EPSG:32631" - yield 32631 - yield "32631" - yield "+proj=utm +zone=31 +datum=WGS84 +units=m +no_defs" # is also EPSG:32631, in proj format - yield WKT2_FOR_EPSG23631 - def _get_leaf_node(cube: DataCube) -> dict: """Get leaf node (node with result=True), supporting old and new style of graph building.""" @@ -561,7 +550,18 @@ def test_aggregate_spatial_types(con100: Connection, polygon, expected_geometrie } -@pytest.mark.parametrize("crs", _get_normalizable_crs_inputs()) +@pytest.mark.parametrize( + "crs", + [ + "EPSG:32631", + 32631, + "32631", + "+proj=utm +zone=31 +datum=WGS84 +units=m +no_defs", # is also EPSG:32631, in proj format + WKT2_FOR_EPSG23631, + PROJJSON_FOR_EPSG23631, + json.dumps(PROJJSON_FOR_EPSG23631), + ], +) def test_aggregate_spatial_with_crs(con100: Connection, recwarn, crs: str): img = con100.load_collection("S2") polygon = shapely.geometry.box(0, 0, 1, 1) @@ -588,36 +588,6 @@ def test_aggregate_spatial_with_crs(con100: Connection, recwarn, crs: str): } -@pytest.mark.skipif( - pyproj.__version__ < ComparableVersion("3.3.0"), - reason="PROJJSON format support requires pyproj 3.3.0 or higher", -) -@pytest.mark.parametrize("crs", [PROJJSON_FOR_EPSG23631, json.dumps(PROJJSON_FOR_EPSG23631)]) -def test_aggregate_spatial_with_crs_as_projjson(con100: Connection, recwarn, crs): - """Separate test coverage for PROJJSON, so we can skip it for Python versions below 3.8""" - img = con100.load_collection("S2") - polygon = shapely.geometry.box(0, 0, 1, 1) - masked = img.aggregate_spatial(geometries=polygon, reducer="mean", crs=crs) - warnings = [str(w.message) for w in recwarn] - assert f"Geometry with non-Lon-Lat CRS {crs!r} is only supported by specific back-ends." in warnings - assert sorted(masked.flat_graph().keys()) == ["aggregatespatial1", "loadcollection1"] - assert masked.flat_graph()["aggregatespatial1"] == { - "process_id": "aggregate_spatial", - "arguments": { - "data": {"from_node": "loadcollection1"}, - "geometries": { - "type": "Polygon", - "coordinates": (((1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0), (1.0, 0.0)),), - "crs": {"properties": {"name": "EPSG:32631"}, "type": "name"}, - }, - "reducer": { - "process_graph": { - "mean1": {"process_id": "mean", "arguments": {"data": {"from_parameter": "data"}}, "result": True} - } - }, - }, - "result": True, - } @pytest.mark.parametrize( @@ -906,7 +876,18 @@ def test_mask_polygon_types(con100: Connection, polygon, expected_mask): } -@pytest.mark.parametrize("crs", _get_normalizable_crs_inputs()) +@pytest.mark.parametrize( + "crs", + [ + "EPSG:32631", + 32631, + "32631", + "+proj=utm +zone=31 +datum=WGS84 +units=m +no_defs", # is also EPSG:32631, in proj format + WKT2_FOR_EPSG23631, + PROJJSON_FOR_EPSG23631, + json.dumps(PROJJSON_FOR_EPSG23631), + ], +) def test_mask_polygon_with_crs(con100: Connection, recwarn, crs: str): img = con100.load_collection("S2") polygon = shapely.geometry.box(0, 0, 1, 1) @@ -929,33 +910,6 @@ def test_mask_polygon_with_crs(con100: Connection, recwarn, crs: str): } -@pytest.mark.skipif( - pyproj.__version__ < ComparableVersion("3.3.0"), - reason="PROJJSON format support requires pyproj 3.3.0 or higher", -) -@pytest.mark.parametrize("crs", [PROJJSON_FOR_EPSG23631, json.dumps(PROJJSON_FOR_EPSG23631)]) -def test_mask_polygon_with_crs_as_projjson(con100: Connection, recwarn, crs): - """Separate test coverage for PROJJSON, so we can skip it for Python versions below 3.8""" - img = con100.load_collection("S2") - polygon = shapely.geometry.box(0, 0, 1, 1) - masked = img.mask_polygon(mask=polygon, srs=crs) - warnings = [str(w.message) for w in recwarn] - assert f"Geometry with non-Lon-Lat CRS {crs!r} is only supported by specific back-ends." in warnings - assert sorted(masked.flat_graph().keys()) == ["loadcollection1", "maskpolygon1"] - assert masked.flat_graph()["maskpolygon1"] == { - "process_id": "mask_polygon", - "arguments": { - "data": {"from_node": "loadcollection1"}, - "mask": { - "type": "Polygon", - "coordinates": (((1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0), (1.0, 0.0)),), - # All listed test inputs for crs should be converted to "EPSG:32631" - "crs": {"type": "name", "properties": {"name": "EPSG:32631"}}, - }, - }, - "result": True, - } - def test_mask_polygon_parameter(con100: Connection): img = con100.load_collection("S2") diff --git a/tests/rest/test_connection.py b/tests/rest/test_connection.py index 2e1bb10be..bc479c079 100644 --- a/tests/rest/test_connection.py +++ b/tests/rest/test_connection.py @@ -23,7 +23,6 @@ from openeo.api.process import Parameter from openeo.internal.graph_building import FlatGraphableMixin, PGNode from openeo.metadata import ( - _PYSTAC_1_9_EXTENSION_INTERFACE, Band, BandDimension, CubeMetadata, @@ -3372,10 +3371,6 @@ def test_load_stac_reduce_temporal(self, con120, build_stac_ref, temporal_dim): }, } - @pytest.mark.skipif( - not _PYSTAC_1_9_EXTENSION_INTERFACE, - reason="No backport of implementation/test below PySTAC 1.9 extension interface", - ) @pytest.mark.parametrize( ["collection_extent", "dim_extent"], [ diff --git a/tests/test_metadata.py b/tests/test_metadata.py index f7bad8d98..2f1bd1466 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -10,7 +10,6 @@ from openeo.api.process import Parameter from openeo.metadata import ( - _PYSTAC_1_9_EXTENSION_INTERFACE, Band, BandDimension, CollectionMetadata, @@ -1183,7 +1182,6 @@ def test_metadata_from_stac_stac_1_1_common_bands_without_datacube_extension(tmp assert metadata.band_dimension.bands[1].wavelength_um == 0.842 -@pytest.mark.skipif(not _PYSTAC_1_9_EXTENSION_INTERFACE, reason="Requires PySTAC 1.9+ extension interface") @pytest.mark.parametrize( ["eo_extension_is_declared", "expected_warnings"], [ @@ -1224,10 +1222,6 @@ def test_metadata_from_stac_collection_bands_from_item_assets( assert caplog.messages == expected_warnings -@pytest.mark.skipif( - not _PYSTAC_1_9_EXTENSION_INTERFACE, - reason="No backport of implementation/test below PySTAC 1.9 extension interface", -) @pytest.mark.parametrize( ["stac_dict", "expected"], [ @@ -1688,9 +1682,7 @@ def test_band_from_common_bands_metadata_emtpy(self): def test_bands_from_stac_catalog(self, data, expected, expected_warnings, caplog): catalog = pystac.Catalog.from_dict(data) assert _StacMetadataParser().bands_from_stac_catalog(catalog=catalog) == expected - - if _PYSTAC_1_9_EXTENSION_INTERFACE: - assert caplog.messages == expected_warnings + assert caplog.messages == expected_warnings @pytest.mark.parametrize( ["data", "expected", "expected_warnings"], @@ -1776,9 +1768,7 @@ def test_bands_from_stac_catalog(self, data, expected, expected_warnings, caplog def test_bands_from_stac_collection(self, data, expected, caplog, expected_warnings): collection = pystac.Collection.from_dict(data) assert _StacMetadataParser().bands_from_stac_collection(collection=collection) == expected - - if _PYSTAC_1_9_EXTENSION_INTERFACE: - assert caplog.messages == expected_warnings + assert caplog.messages == expected_warnings @pytest.mark.parametrize( ["entities", "kwargs", "expected", "expected_warnings"], @@ -2371,9 +2361,7 @@ def test_bands_from_stac_collection_with_item_assets( ): collection = pystac.Collection.from_dict(stac_data) assert _StacMetadataParser().bands_from_stac_collection(collection).band_names() == expected_bands - - if _PYSTAC_1_9_EXTENSION_INTERFACE: - assert caplog.messages == expected_warnings + assert caplog.messages == expected_warnings def test_bands_from_stac_collection_with_item_assets_extension_but_no_item_assets(self, caplog): """ diff --git a/tests/udf/test_debug.py b/tests/udf/test_debug.py index be902e7f7..20bbb0c53 100644 --- a/tests/udf/test_debug.py +++ b/tests/udf/test_debug.py @@ -19,7 +19,6 @@ def test_inspect_basic(caplog): assert record.__dict__["code"] == "User" -@pytest.mark.skipif(sys.version_info < (3, 8), reason="Requires python 3.8 or higher (logging `stacklevel`)") def test_inspect_filename(caplog): caplog.set_level("INFO") inspect(data=[1, 2, 3], message="hello")