easy-eo 0.3.1__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. {easy_eo-0.3.1 → easy_eo-0.4.0}/PKG-INFO +4 -2
  2. {easy_eo-0.3.1 → easy_eo-0.4.0}/README.md +3 -1
  3. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/__init__.py +4 -2
  4. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/io/__init__.py +6 -2
  5. easy_eo-0.4.0/eeo/io/_archive.py +392 -0
  6. easy_eo-0.4.0/eeo/io/_bands.py +417 -0
  7. easy_eo-0.4.0/eeo/io/_landsat.py +439 -0
  8. easy_eo-0.4.0/eeo/io/_sentinel2.py +458 -0
  9. easy_eo-0.4.0/eeo/io/_stacking.py +242 -0
  10. easy_eo-0.4.0/eeo/io/products.py +413 -0
  11. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/io/stac.py +7 -140
  12. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/io/xarray.py +3 -1
  13. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/preprocessing/resample.py +13 -1
  14. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/viz/plot.py +15 -1
  15. {easy_eo-0.3.1 → easy_eo-0.4.0}/pyproject.toml +7 -1
  16. {easy_eo-0.3.1 → easy_eo-0.4.0}/.gitignore +0 -0
  17. {easy_eo-0.3.1 → easy_eo-0.4.0}/LICENSE +0 -0
  18. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/_optional.py +0 -0
  19. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/_show_versions.py +0 -0
  20. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/analysis/__init__.py +0 -0
  21. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/analysis/indices.py +0 -0
  22. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/analysis/stats.py +0 -0
  23. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/common.py +0 -0
  24. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/core/__init__.py +0 -0
  25. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/core/adapters/__init__.py +0 -0
  26. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/core/adapters/base.py +0 -0
  27. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/core/adapters/numpy.py +0 -0
  28. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/core/adapters/rasterio.py +0 -0
  29. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/core/core.py +0 -0
  30. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/core/core.pyi +0 -0
  31. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/core/decorators.py +0 -0
  32. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/core/exceptions.py +0 -0
  33. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/core/loader.py +0 -0
  34. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/core/plugins.py +0 -0
  35. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/core/types.py +0 -0
  36. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/datasets/__init__.py +0 -0
  37. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/datasets/_cache.py +0 -0
  38. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/datasets/_registry.py +0 -0
  39. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/datasets/_samples.py +0 -0
  40. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/ops/__init__.py +0 -0
  41. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/ops/algebra.py +0 -0
  42. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/ops/merge.py +0 -0
  43. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/preprocessing/__init__.py +0 -0
  44. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/preprocessing/clip.py +0 -0
  45. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/preprocessing/normalize.py +0 -0
  46. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/preprocessing/reproject.py +0 -0
  47. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/py.typed +0 -0
  48. {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/viz/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: easy-eo
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: A lightweight Earth Observation utilities library for chainable raster operations
5
5
  Project-URL: Homepage, https://github.com/tommy-burns/easy-eo
6
6
  Project-URL: Repository, https://github.com/tommy-burns/easy-eo
@@ -71,7 +71,7 @@ Description-Content-Type: text/markdown
71
71
  | **Install** | [![PyPI][pypi-badge]][pypi] [![conda-forge][conda-badge]][conda] [![Python versions][pyversions-badge]][pypi] [![Platforms][platforms-badge]][ci] |
72
72
  | **Build & quality** | [![CI][ci-badge]][ci] [![Latest deps][latest-deps-badge]][latest-deps] [![Coverage][codecov-badge]][codecov] [![Ruff][ruff-badge]][ruff] [![Checked with mypy][mypy-badge]][mypy] |
73
73
  | **Security** | [![CodeQL][codeql-badge]][codeql] [![OpenSSF Scorecard][scorecard-badge]][scorecard] |
74
- | **Project** | [![Documentation][docs-badge]][docs] [![License: MIT][license-badge]][license] |
74
+ | **Project** | [![Documentation][docs-badge]][docs] [![License: MIT][license-badge]][license] [![DOI][doi-badge]][doi] |
75
75
 
76
76
  [pypi]: https://pypi.org/project/easy-eo/
77
77
  [pypi-badge]: https://img.shields.io/pypi/v/easy-eo.svg
@@ -97,6 +97,8 @@ Description-Content-Type: text/markdown
97
97
  [docs-badge]: https://readthedocs.org/projects/easy-eo/badge/?version=latest
98
98
  [license]: https://github.com/Tommy-Burns/easy-eo/blob/main/LICENSE
99
99
  [license-badge]: https://img.shields.io/github/license/Tommy-Burns/easy-eo
100
+ [doi]: https://doi.org/10.5281/zenodo.21967655
101
+ [doi-badge]: https://zenodo.org/badge/DOI/10.5281/zenodo.21967655.svg
100
102
 
101
103
 
102
104
  Easy-EO is a lightweight, extensible Python library for raster-based Earth
@@ -9,7 +9,7 @@
9
9
  | **Install** | [![PyPI][pypi-badge]][pypi] [![conda-forge][conda-badge]][conda] [![Python versions][pyversions-badge]][pypi] [![Platforms][platforms-badge]][ci] |
10
10
  | **Build & quality** | [![CI][ci-badge]][ci] [![Latest deps][latest-deps-badge]][latest-deps] [![Coverage][codecov-badge]][codecov] [![Ruff][ruff-badge]][ruff] [![Checked with mypy][mypy-badge]][mypy] |
11
11
  | **Security** | [![CodeQL][codeql-badge]][codeql] [![OpenSSF Scorecard][scorecard-badge]][scorecard] |
12
- | **Project** | [![Documentation][docs-badge]][docs] [![License: MIT][license-badge]][license] |
12
+ | **Project** | [![Documentation][docs-badge]][docs] [![License: MIT][license-badge]][license] [![DOI][doi-badge]][doi] |
13
13
 
14
14
  [pypi]: https://pypi.org/project/easy-eo/
15
15
  [pypi-badge]: https://img.shields.io/pypi/v/easy-eo.svg
@@ -35,6 +35,8 @@
35
35
  [docs-badge]: https://readthedocs.org/projects/easy-eo/badge/?version=latest
36
36
  [license]: https://github.com/Tommy-Burns/easy-eo/blob/main/LICENSE
37
37
  [license-badge]: https://img.shields.io/github/license/Tommy-Burns/easy-eo
38
+ [doi]: https://doi.org/10.5281/zenodo.21967655
39
+ [doi-badge]: https://zenodo.org/badge/DOI/10.5281/zenodo.21967655.svg
38
40
 
39
41
 
40
42
  Easy-EO is a lightweight, extensible Python library for raster-based Earth
@@ -18,7 +18,7 @@ from .core import (
18
18
  load_raster,
19
19
  )
20
20
  from .core.adapters import *
21
- from .io import from_xarray, stac_search
21
+ from .io import from_xarray, load_landsat, load_sentinel2, stac_search
22
22
  from .ops import *
23
23
  from .preprocessing import *
24
24
  from .viz import *
@@ -28,6 +28,8 @@ __all__ = [
28
28
  "load_raster",
29
29
  "load_array",
30
30
  "stac_search",
31
+ "load_landsat",
32
+ "load_sentinel2",
31
33
  "from_xarray",
32
34
  "show_versions",
33
35
  "EEOError",
@@ -38,4 +40,4 @@ __all__ = [
38
40
  "MissingDependencyError",
39
41
  ]
40
42
 
41
- __version__ = "0.3.1"
43
+ __version__ = "0.4.0"
@@ -1,15 +1,19 @@
1
1
  """Data access and exchange beyond the local filesystem.
2
2
 
3
- Holds STAC catalog access (:mod:`eeo.io.stac`, the ``stac`` extra) and xarray
4
- interop (:mod:`eeo.io.xarray`, the ``xarray`` extra). Importing this package
3
+ Holds STAC catalog access (:mod:`eeo.io.stac`, the ``stac`` extra), loaders for
4
+ products downloaded to disk (:mod:`eeo.io.products`), and xarray interop
5
+ (:mod:`eeo.io.xarray`, the ``xarray`` extra). Importing this package
5
6
  pulls in no optional dependency; an extra is only required when one of its
6
7
  features actually runs.
7
8
  """
8
9
 
10
+ from .products import load_landsat, load_sentinel2
9
11
  from .stac import PLANETARY_COMPUTER_STAC_URL, STACItem, STACSearchResult, stac_search
10
12
  from .xarray import from_xarray
11
13
 
12
14
  __all__ = [
15
+ "load_landsat",
16
+ "load_sentinel2",
13
17
  "stac_search",
14
18
  "STACItem",
15
19
  "STACSearchResult",
@@ -0,0 +1,392 @@
1
+ """Where a product's files live, so the readers do not have to care.
2
+
3
+ A downloaded scene arrives as a directory, a Copernicus ``.zip``, or a USGS
4
+ ``.tar``, and identifying it means the same four questions in every case: does
5
+ this file exist, which files match this pattern, what does this metadata file
6
+ say, and what do I hand rasterio to read this image? :class:`ProductSource`
7
+ answers those four and nothing else, so :mod:`eeo.io._sentinel2` and
8
+ :mod:`eeo.io._landsat` can be written once against a product rather than once
9
+ per container it might be shipped in.
10
+
11
+ Paths inside a source are relative :class:`~pathlib.PurePosixPath` values, and
12
+ purely nominal — an archive member has no filesystem path to be relative to.
13
+ Only :meth:`ProductSource.href` produces something openable, which keeps the
14
+ one place that has to know about ``/vsizip/`` down to a single line.
15
+
16
+ The read half of the loader never needed this: rasterio opens a URL as happily
17
+ as a path. It is discovery that is filesystem-bound, so it is discovery this
18
+ covers. A remote product is the same shape of problem as an archive — the
19
+ product is not a plain directory — and would arrive as one more implementation
20
+ here rather than as changes to the readers.
21
+
22
+ Notes
23
+ -----
24
+ **A compressed tar is refused rather than read.** The three containers here all
25
+ allow a single member to be addressed on its own: a directory by definition, a
26
+ zip through its central directory, an uncompressed tar through its member
27
+ headers. Wrapping a tar in gzip removes that. The compressed stream has no
28
+ index, so reaching any member means decompressing everything before it, and a
29
+ windowed read of one band decompresses the entire archive ahead of it — every
30
+ time, for every band. The cost is not a constant factor, so accepting it
31
+ silently would make the loader mysteriously slow rather than honestly refuse.
32
+
33
+ Zip is not free either, only bounded: a deflated member is decompressed from
34
+ its own start, not from the start of the archive. That is one image at worst,
35
+ and it is the format Copernicus actually ships, so it is supported with the
36
+ cost noted rather than refused.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import functools
42
+ import re
43
+ import tarfile
44
+ import zipfile
45
+ from abc import ABC, abstractmethod
46
+ from pathlib import Path, PurePosixPath
47
+ from typing import NamedTuple
48
+
49
+ from eeo.core.exceptions import ValidationError
50
+ from eeo.core.types import StrPath
51
+
52
+ #: Final suffixes marking a compressed archive, which is refused. ``.tgz`` and
53
+ #: friends are the one-piece spellings of the same thing.
54
+ _COMPRESSED_SUFFIXES = (
55
+ ".gz",
56
+ ".bz2",
57
+ ".xz",
58
+ ".z",
59
+ ".zst",
60
+ ".tgz",
61
+ ".tbz",
62
+ ".tbz2",
63
+ ".txz",
64
+ )
65
+
66
+ #: Archive suffixes, mapped to the GDAL virtual filesystem that reads them.
67
+ _ARCHIVE_VSI = {".zip": "vsizip", ".tar": "vsitar"}
68
+
69
+
70
+ @functools.lru_cache(maxsize=64)
71
+ def _anchored(pattern: str) -> re.Pattern[str]:
72
+ """Compile a glob pattern the way :meth:`pathlib.Path.glob` reads it.
73
+
74
+ Anchored at the root and with ``*`` stopping at a separator, so an archive
75
+ answers a pattern exactly as the equivalent directory would. Matching
76
+ archive members with :meth:`PurePosixPath.match` instead would match from
77
+ the right, and quietly find things a directory source would not.
78
+ """
79
+ parts = []
80
+ for char in pattern:
81
+ if char == "*":
82
+ parts.append("[^/]*")
83
+ elif char == "?":
84
+ parts.append("[^/]")
85
+ else:
86
+ parts.append(re.escape(char))
87
+ return re.compile("".join(parts))
88
+
89
+
90
+ class ProductSource(ABC):
91
+ """The files of one product, wherever they physically live.
92
+
93
+ Attributes
94
+ ----------
95
+ name : str
96
+ A name for the product, used where a message or a provenance record
97
+ needs to identify it.
98
+ location : Path
99
+ What the caller actually passed, for error messages.
100
+ named_entry : PurePosixPath or None
101
+ The file the caller named, when they named one rather than a container.
102
+ A reader honours it instead of searching, so pointing at a specific
103
+ metadata file selects that file and not a sibling it prefers.
104
+ """
105
+
106
+ name: str
107
+ location: Path
108
+ named_entry: PurePosixPath | None
109
+
110
+ @abstractmethod
111
+ def exists(self, relative: PurePosixPath | str) -> bool:
112
+ """Report whether the source holds a file at a relative path.
113
+
114
+ Parameters
115
+ ----------
116
+ relative : PurePosixPath or str
117
+ Path within the source.
118
+
119
+ Returns
120
+ -------
121
+ bool
122
+ True if a file sits there. Directories do not count.
123
+ """
124
+
125
+ @abstractmethod
126
+ def glob(self, pattern: str) -> list[PurePosixPath]:
127
+ """Find the files matching a glob pattern.
128
+
129
+ Parameters
130
+ ----------
131
+ pattern : str
132
+ Glob pattern, read as :meth:`pathlib.Path.glob` reads it: anchored
133
+ at the source root, with ``*`` stopping at a separator.
134
+
135
+ Returns
136
+ -------
137
+ list of PurePosixPath
138
+ Matching files, relative to the source, sorted.
139
+ """
140
+
141
+ @abstractmethod
142
+ def read_bytes(self, relative: PurePosixPath | str) -> bytes:
143
+ """Read one file whole. For metadata, never for imagery.
144
+
145
+ Parameters
146
+ ----------
147
+ relative : PurePosixPath or str
148
+ Path within the source.
149
+
150
+ Returns
151
+ -------
152
+ bytes
153
+ The file's contents.
154
+ """
155
+
156
+ @abstractmethod
157
+ def href(self, relative: PurePosixPath | str) -> str:
158
+ """Return what rasterio should open to read this file.
159
+
160
+ Parameters
161
+ ----------
162
+ relative : PurePosixPath or str
163
+ Path within the source.
164
+
165
+ Returns
166
+ -------
167
+ str
168
+ A path or virtual filesystem URL rasterio can open.
169
+ """
170
+
171
+
172
+ class DirectorySource(ProductSource):
173
+ """A product unpacked into a directory.
174
+
175
+ Parameters
176
+ ----------
177
+ root : Path
178
+ The directory paths are resolved against.
179
+ named_entry : PurePosixPath or None, default None
180
+ The file the caller named, when they named one rather than the
181
+ directory. A reader honours it instead of searching.
182
+ """
183
+
184
+ def __init__(self, root: Path, named_entry: PurePosixPath | None = None) -> None:
185
+ self.root = root
186
+ self.name = root.name
187
+ self.named_entry = named_entry
188
+ self.location = root if named_entry is None else root / named_entry
189
+
190
+ def exists(self, relative: PurePosixPath | str) -> bool:
191
+ return (self.root / str(relative)).is_file()
192
+
193
+ def glob(self, pattern: str) -> list[PurePosixPath]:
194
+ return sorted(
195
+ PurePosixPath(match.relative_to(self.root).as_posix())
196
+ for match in self.root.glob(pattern)
197
+ if match.is_file()
198
+ )
199
+
200
+ def read_bytes(self, relative: PurePosixPath | str) -> bytes:
201
+ try:
202
+ return (self.root / str(relative)).read_bytes()
203
+ except OSError as err:
204
+ raise ValidationError(f"could not read {relative} under {self.root}: {err}") from err
205
+
206
+ def href(self, relative: PurePosixPath | str) -> str:
207
+ return str(self.root / str(relative))
208
+
209
+
210
+ class ArchiveSource(ProductSource):
211
+ """A product still inside the zip or tar it was downloaded as.
212
+
213
+ The member list is read once and kept; the archive itself is reopened for
214
+ each metadata read, of which there are one or two. Imagery is never read
215
+ through here — :meth:`href` hands GDAL a virtual filesystem path and GDAL
216
+ addresses the member directly.
217
+
218
+ Parameters
219
+ ----------
220
+ archive : Path
221
+ The ``.zip`` or ``.tar`` file.
222
+ suffix : str
223
+ Which of the two it is, lowercased, as the key into the virtual
224
+ filesystem table.
225
+ entries : tuple of str
226
+ The archive's file members, listed once at construction.
227
+ """
228
+
229
+ def __init__(self, archive: Path, suffix: str, entries: tuple[str, ...]) -> None:
230
+ self.archive = archive
231
+ self.suffix = suffix
232
+ self.name = archive.name
233
+ self.location = archive
234
+ self.named_entry = None
235
+ self._entries = entries
236
+ self._set = frozenset(entries)
237
+
238
+ def exists(self, relative: PurePosixPath | str) -> bool:
239
+ return str(relative) in self._set
240
+
241
+ def glob(self, pattern: str) -> list[PurePosixPath]:
242
+ matches = _anchored(pattern)
243
+ return sorted(PurePosixPath(e) for e in self._entries if matches.fullmatch(e))
244
+
245
+ def read_bytes(self, relative: PurePosixPath | str) -> bytes:
246
+ try:
247
+ if self.suffix == ".zip":
248
+ with zipfile.ZipFile(self.archive) as archive:
249
+ return archive.read(str(relative))
250
+ with tarfile.open(self.archive) as tar:
251
+ member = tar.extractfile(str(relative))
252
+ if member is None:
253
+ raise ValidationError(f"{relative} is not a file in {self.archive.name}")
254
+ return member.read()
255
+ except (OSError, KeyError, zipfile.BadZipFile, tarfile.TarError) as err:
256
+ raise ValidationError(
257
+ f"could not read {relative} from {self.archive.name}: {err}"
258
+ ) from err
259
+
260
+ def href(self, relative: PurePosixPath | str) -> str:
261
+ # GDAL addresses an archive member as /vsizip/<archive path>/<member>.
262
+ # An absolute archive path leaves a doubled slash after the prefix,
263
+ # which is the documented spelling rather than an accident.
264
+ return f"/{_ARCHIVE_VSI[self.suffix]}/{self.archive.as_posix()}/{relative}"
265
+
266
+
267
+ def _archive_entries(archive: Path, suffix: str) -> tuple[str, ...]:
268
+ """List an archive's file members, without extracting anything."""
269
+ try:
270
+ if suffix == ".zip":
271
+ with zipfile.ZipFile(archive) as zipped:
272
+ return tuple(info.filename for info in zipped.infolist() if not info.is_dir())
273
+ with tarfile.open(archive) as tarred:
274
+ return tuple(member.name for member in tarred.getmembers() if member.isfile())
275
+ except (OSError, zipfile.BadZipFile, tarfile.TarError) as err:
276
+ raise ValidationError(f"{archive.name} is not a readable archive: {err}") from err
277
+
278
+
279
+ class Located(NamedTuple):
280
+ """A product's metadata file, and the source it was actually found in.
281
+
282
+ The source is returned alongside the path because finding a product can
283
+ change where the rest of it will be read from: pointing at a folder that
284
+ holds an archive resolves to that archive, and every later read has to go
285
+ there rather than to the folder.
286
+ """
287
+
288
+ source: ProductSource
289
+ path: PurePosixPath
290
+
291
+
292
+ def nested_archive(source: ProductSource, mission: str) -> ProductSource | None:
293
+ """Open the one archive sitting inside a directory, if there is exactly one.
294
+
295
+ A download that has not been unpacked still lives in a folder, and pointing
296
+ at the folder is what people do. One archive there is unambiguous; several
297
+ are refused the same way several products are.
298
+
299
+ Parameters
300
+ ----------
301
+ source : ProductSource
302
+ Where the caller pointed. Only a directory source can hold an archive.
303
+ mission : str
304
+ How to name the mission in any error, e.g. ``"Sentinel-2"``.
305
+
306
+ Returns
307
+ -------
308
+ ProductSource or None
309
+ A source for the archive, or None when there is nothing to resolve to:
310
+ the source is not a directory, the caller named a specific file, or the
311
+ directory holds no archive at all. In each of those the caller's own
312
+ search stands.
313
+
314
+ Raises
315
+ ------
316
+ ValidationError
317
+ If the directory holds more than one archive.
318
+ """
319
+ if not isinstance(source, DirectorySource) or source.named_entry is not None:
320
+ return None
321
+
322
+ archives = sorted(
323
+ entry
324
+ for entry in source.root.iterdir()
325
+ if entry.is_file() and entry.suffix.lower() in _ARCHIVE_VSI
326
+ )
327
+ if not archives:
328
+ return None
329
+ if len(archives) > 1:
330
+ raise ValidationError(
331
+ f"{str(source.root)!r} holds {len(archives)} archives, so which one to "
332
+ f"load cannot be told from the path alone: "
333
+ f"{', '.join(archive.name for archive in archives)}. "
334
+ f"Name the one you mean."
335
+ )
336
+ return open_product(archives[0], mission)
337
+
338
+
339
+ def open_product(path: StrPath, mission: str) -> ProductSource:
340
+ """Open whatever the caller pointed at as a source of product files.
341
+
342
+ Parameters
343
+ ----------
344
+ path : str or pathlib.Path
345
+ A product directory, a directory holding one, a ``.zip`` or ``.tar``
346
+ holding one, or a single file inside a product directory.
347
+ mission : str
348
+ How to name the mission when the path turns out to hold nothing, e.g.
349
+ ``"Sentinel-2"``.
350
+
351
+ Returns
352
+ -------
353
+ ProductSource
354
+ A directory-backed source, or an archive-backed one.
355
+
356
+ Raises
357
+ ------
358
+ ValidationError
359
+ If the path does not exist, if the archive cannot be read, or if it is
360
+ a compressed archive.
361
+
362
+ Notes
363
+ -----
364
+ A compressed archive is refused with instructions rather than read. See the
365
+ module docstring for why the cost is not a constant factor.
366
+
367
+ Examples
368
+ --------
369
+ >>> open_product("S2B_MSIL2A_20240830T100559.SAFE", "Sentinel-2") # doctest: +SKIP
370
+ <eeo.io._archive.DirectorySource object at ...>
371
+ """
372
+ candidate = Path(path)
373
+ if not candidate.exists():
374
+ raise ValidationError(f"no such {mission} product: {str(path)!r}")
375
+ if candidate.is_dir():
376
+ return DirectorySource(candidate)
377
+
378
+ suffix = candidate.suffix.lower()
379
+ if suffix in _COMPRESSED_SUFFIXES:
380
+ stem = candidate.name[: -len(candidate.suffix)]
381
+ raise ValidationError(
382
+ f"{candidate.name!r} is a compressed archive, which cannot be read in "
383
+ f"place: it holds no index, so reading one band would decompress every "
384
+ f"byte before it, once per band. Extract it first — "
385
+ f"`tar -xf {candidate.name}` — and pass the {stem!r} directory."
386
+ )
387
+ if suffix in _ARCHIVE_VSI:
388
+ return ArchiveSource(candidate, suffix, _archive_entries(candidate, suffix))
389
+
390
+ # A plain file: the caller named one file of a product, so read the
391
+ # directory around it and remember which file they meant.
392
+ return DirectorySource(candidate.parent, named_entry=PurePosixPath(candidate.name))