From 03e6ffc7df1843d5f5a9c1a414a684fe951c3f50 Mon Sep 17 00:00:00 2001 From: Alessandro Amici 2 Date: Mon, 9 Nov 2026 13:02:48 +0100 Subject: [PATCH 1/3] Join the paths inside a product with posixpath fsspec paths and the manifest hrefs always use "/", but os.path joins with "\" on Windows, breaking the zip member names and the get_fs_path results. Co-Authored-By: Claude Opus 5.5 --- docs/whats-new.md | 5 +++++ tests/test_20_sentinel1.py | 4 ++-- tests/test_30_sentinel1_fsspec.py | 2 +- xarray_sentinel/eopf_product.py | 8 ++++---- xarray_sentinel/esa_safe.py | 5 +++-- xarray_sentinel/sentinel1.py | 12 ++++++------ 6 files changed, 21 insertions(+), 15 deletions(-) diff --git a/docs/whats-new.md b/docs/whats-new.md index cdc8aca..2646ae9 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -25,6 +25,11 @@ Fewer required dependencies: opening the products needs neither `scipy` nor `zar `xarray-sentinel` command: the commands and their arguments are the same, the help and the errors are plain text. +### Bug fixes + +- Join the paths inside a product with `/` on Windows too: opening zipped products + failed with a `FileNotFoundError`, as did products on other non local file systems. + ## v0.9.7 (2026-11-04) The first release with the EOPF DataTree, in beta, the `xarray-sentinel` command line diff --git a/tests/test_20_sentinel1.py b/tests/test_20_sentinel1.py index 1862650..c0f88ca 100644 --- a/tests/test_20_sentinel1.py +++ b/tests/test_20_sentinel1.py @@ -78,11 +78,11 @@ def test_get_fs_path() -> None: fs, path = sentinel1.get_fs_path(str(SLC_IW)) - assert path == str((SLC_IW / "manifest.safe").absolute()) + assert path == (SLC_IW / "manifest.safe").absolute().as_posix() fs, path = sentinel1.get_fs_path(SLC_IW, fs) - assert path == str(SLC_IW / "manifest.safe") + assert path == f"{SLC_IW}/manifest.safe" with pytest.raises(TypeError): sentinel1.get_fs_path("*", fs=fs, storage_options={}) diff --git a/tests/test_30_sentinel1_fsspec.py b/tests/test_30_sentinel1_fsspec.py index 102f9e2..87a8e2f 100644 --- a/tests/test_30_sentinel1_fsspec.py +++ b/tests/test_30_sentinel1_fsspec.py @@ -55,7 +55,7 @@ def test_get_fs_path() -> None: fs, path = sentinel1.get_fs_path(SLC_IW) assert isinstance(fs, fsspec.AbstractFileSystem) - assert path == str(SLC_IW / "manifest.safe") + assert path == (SLC_IW / "manifest.safe").as_posix() fs2, path2 = sentinel1.get_fs_path(path, fs=fs) diff --git a/xarray_sentinel/eopf_product.py b/xarray_sentinel/eopf_product.py index 701a27f..186e218 100644 --- a/xarray_sentinel/eopf_product.py +++ b/xarray_sentinel/eopf_product.py @@ -1,6 +1,6 @@ import datetime import io -import os +import posixpath import re import warnings from collections.abc import Callable @@ -178,10 +178,10 @@ def open_manifest( ) -> tuple[fsspec.AbstractFileSystem, str, io.BytesIO, str]: """Return the filesystem, the product path, the manifest and the product name.""" fs, manifest_path = sentinel1.get_fs_path(product_urlpath, fs, storage_options) - product_path = os.path.dirname(manifest_path) + product_path = posixpath.dirname(manifest_path) manifest = io.BytesIO(fs.cat_file(manifest_path)) common_attrs, _ = esa_safe.parse_manifest_sentinel1(manifest) - safe_name, _ = os.path.splitext(os.path.basename(product_path)) + safe_name, _ = posixpath.splitext(posixpath.basename(product_path)) product_name = make_product_name(safe_name, common_attrs) return fs, product_path, manifest, product_name @@ -246,7 +246,7 @@ def open_datatree( swath, _, pol = group.partition("/") swath_pols.setdefault(swath, []).append(pol) - safe_name = os.path.basename(product_path) + safe_name = posixpath.basename(product_path) processing_history = eopf_metadata.build_processing_history(manifest, safe_name) # NOTE: as the entry CPM 3.0 adds on conversion, but drops on the S01 product nodes processing_history["Level-1 Product"].append( diff --git a/xarray_sentinel/esa_safe.py b/xarray_sentinel/esa_safe.py index 323dac2..57b4e71 100644 --- a/xarray_sentinel/esa_safe.py +++ b/xarray_sentinel/esa_safe.py @@ -1,6 +1,7 @@ import functools import importlib.resources import os +import posixpath import re from collections.abc import Mapping from typing import IO, Any @@ -121,7 +122,7 @@ def findall( def parse_annotation_filename(name: str) -> tuple[str, str, str, str]: match = re.match( - r"([a-z-]*)s1[abcd]-([^-]*)-[^-]*-([^-]*)-([\dt]*)-", os.path.basename(name) + r"([a-z-]*)s1[abcd]-([^-]*)-[^-]*-([^-]*)-([\dt]*)-", posixpath.basename(name) ) if match is None: raise ValueError(f"cannot parse name {name!r}") @@ -192,7 +193,7 @@ def parse_manifest_sentinel1( if location_tag is not None: file_href = location_tag.attrib["href"] try: - description = parse_annotation_filename(os.path.basename(file_href)) + description = parse_annotation_filename(posixpath.basename(file_href)) except ValueError: continue file_type = file_tag.attrib["repID"] diff --git a/xarray_sentinel/sentinel1.py b/xarray_sentinel/sentinel1.py index 066a825..9815656 100644 --- a/xarray_sentinel/sentinel1.py +++ b/xarray_sentinel/sentinel1.py @@ -12,7 +12,7 @@ from __future__ import annotations -import os +import posixpath import warnings from collections.abc import Sequence from typing import Any, TypeVar @@ -54,7 +54,7 @@ def get_fs_path( path = str(urlpath_or_path) if fs.isdir(path): - path = os.path.join(path, "manifest.safe") + path = posixpath.join(path, "manifest.safe") return fs, path @@ -512,7 +512,7 @@ def find_available_groups( groups: dict[str, list[str]] = {} for path, (type, _, swath, polarization, _) in product_files.items(): swath_pol_group = f"{swath}/{polarization}".upper() - abspath = os.path.join(product_path, os.path.normpath(path)) + abspath = posixpath.join(product_path, posixpath.normpath(path)) if check_files_exist: if not fs.exists(abspath): continue @@ -992,8 +992,8 @@ def do_override_product_files( overridden_product_files = {} for path, description in product_files.items(): type, prefix, swath, polarization, date = description - ext = os.path.splitext(path)[1] - dirname = os.path.dirname(path) + ext = posixpath.splitext(path)[1] + dirname = posixpath.dirname(path) overridden_path = template.format(**locals()) overridden_product_files[overridden_path] = description return overridden_product_files @@ -1015,7 +1015,7 @@ def open_sentinel1_dataset( warnings.warn("'drop_variables' is currently ignored") fs, manifest_path = get_fs_path(product_urlpath, fs, storage_options) - product_path = os.path.dirname(manifest_path) + product_path = posixpath.dirname(manifest_path) with fs.open(manifest_path) as file: common_attrs, product_files = esa_safe.parse_manifest_sentinel1(file) From 24242a74d543727275eeb7f897dfa9fd94c93f76 Mon Sep 17 00:00:00 2001 From: Alessandro Amici 2 Date: Mon, 9 Nov 2026 13:02:49 +0100 Subject: [PATCH 2/3] Skip guessing the engine of a directory on Windows in the tests xarray<=2026.09.0 raises PermissionError opening a directory to read its magic number on Windows, fixed upstream in pydata/xarray#11802. Co-Authored-By: Claude Opus 5.5 --- tests/test_30_xarray_backends.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/tests/test_30_xarray_backends.py b/tests/test_30_xarray_backends.py index d2c5443..3ea01ba 100644 --- a/tests/test_30_xarray_backends.py +++ b/tests/test_30_xarray_backends.py @@ -1,4 +1,5 @@ import pathlib +import sys import pytest import xarray as xr @@ -62,9 +63,12 @@ def test_open_dataset_root() -> None: assert attr_name in res.attrs assert res.attrs[attr_name] == COMMON_ATTRIBUTES[attr_name] - res = xr.open_dataset(product_path) + # xarray<=2026.09.0 can't guess the engine of a directory on Windows, + # fixed in https://github.com/pydata/xarray/pull/11802 + if sys.platform != "win32": + res = xr.open_dataset(product_path) - assert isinstance(res, xr.Dataset) + assert isinstance(res, xr.Dataset) product_path = product_path / "manifest.safe" @@ -262,9 +266,12 @@ def test_open_datatree() -> None: assert "stac_discovery" in res[f"/{node}"].attrs assert res.attrs["other_metadata"]["eopf_category"] == "eocontainer" - res = xr.open_datatree(product_path, check_files_exist=True) + # xarray<=2026.09.0 can't guess the engine of a directory on Windows, + # fixed in https://github.com/pydata/xarray/pull/11802 + if sys.platform != "win32": + res = xr.open_datatree(product_path, check_files_exist=True) - assert isinstance(res, xr.DataTree) + assert isinstance(res, xr.DataTree) def test_open_groups() -> None: @@ -281,6 +288,9 @@ def test_open_groups() -> None: assert isinstance(res[f"/{node}/measurements"], xr.Dataset) assert "polarization" in res[f"/{node}/measurements"].sizes - res = xr.open_groups(product_path, check_files_exist=True) + # xarray<=2026.09.0 can't guess the engine of a directory on Windows, + # fixed in https://github.com/pydata/xarray/pull/11802 + if sys.platform != "win32": + res = xr.open_groups(product_path, check_files_exist=True) - assert isinstance(res, dict) + assert isinstance(res, dict) From 7316b4c7fc7b8f8b4da0aead5e147de84fea0998 Mon Sep 17 00:00:00 2001 From: Alessandro Amici 2 Date: Mon, 9 Nov 2026 13:25:11 +0100 Subject: [PATCH 3/3] Open a non existing file instead of "" in the backend test xarray resolves "" to the current directory, and on Windows opening a directory to guess the engine raises PermissionError (pydata/xarray#11802). Co-Authored-By: Claude Opus 5.5 --- tests/test_30_xarray_backends.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_30_xarray_backends.py b/tests/test_30_xarray_backends.py index 3ea01ba..5148960 100644 --- a/tests/test_30_xarray_backends.py +++ b/tests/test_30_xarray_backends.py @@ -81,7 +81,7 @@ def test_open_dataset_root() -> None: assert isinstance(res, xr.Dataset) with pytest.raises((ValueError, FileNotFoundError)): - xr.open_dataset("") + xr.open_dataset("non-existing-file") def test_open_dataset_wv() -> None: