Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/unittests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ jobs:
# Basic configurations to run on
os: ["ubuntu-latest"]
python-version:
- "3.8"
- "3.9"
- "3.10"
- "3.11"
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
2 changes: 1 addition & 1 deletion Jenkinsfile
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
12 changes: 4 additions & 8 deletions openeo/extra/spectral_indices/spectral_indices.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import functools
import importlib.resources
import json
import re
from typing import Dict, List, Optional, Set
Expand All @@ -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]:
Expand All @@ -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:
Expand All @@ -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"))

Expand All @@ -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
Expand Down
21 changes: 8 additions & 13 deletions openeo/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
):
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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
):
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down
3 changes: 1 addition & 2 deletions openeo/udf/debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
26 changes: 6 additions & 20 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
[build-system]
# Setuptools 75 is the latest version that works with Python 3.8.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing this comment eliminates all the traces of us wanting to bump setuptools to a more recent version from the source code. You should probably make a note somewhere else so the bumping doesn't get lost.

@soxofaan soxofaan Sep 22, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

indeed, I was hesitant about this

the problem is that keeping the comment as is (i.e. the "Python 3.8" part) makes no sense when dropping Python 3.8.
Updating the comment to align with the new minimum Python requirement (Python 3.9) would also require to bump actual setuptools constraint (to >=82 I think, but there is no urgent reason as far as I know to also drag this in here.

So the easiest solution is just removing the comment (as it will lose relevance).
Note that this not removes all traces, the original comment will still be discoverable when doing a git blame on the requires = ["setuptools>=75"] line.

But again, no strong opinion here. I'm also fine with bumping the setuptools constraint to >=82 if you think that makes more sense

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it makes sense to limit the scope as much as possible so we can get rid of the EOL versions of Python as quickly as possible. I guess this is the reminder to bump it "soon" after this PR is merged.

(The Ceph August updates tightening the checks on signed S3 URIs makes me keen on getting a new release of this package that doesn't run HEAD on them to make our open-EO usable at all, so I don't want to block your next release on a bunch of nice to haves here.)

requires = ["setuptools>=75"]
build-backend = "setuptools.build_meta"

Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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]
Expand Down
4 changes: 0 additions & 4 deletions tests/extra/job_management/test_job_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 24 additions & 70 deletions tests/rest/datacube/test_datacube100.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand Down
Loading
Loading