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.
- {easy_eo-0.3.1 → easy_eo-0.4.0}/PKG-INFO +4 -2
- {easy_eo-0.3.1 → easy_eo-0.4.0}/README.md +3 -1
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/__init__.py +4 -2
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/io/__init__.py +6 -2
- easy_eo-0.4.0/eeo/io/_archive.py +392 -0
- easy_eo-0.4.0/eeo/io/_bands.py +417 -0
- easy_eo-0.4.0/eeo/io/_landsat.py +439 -0
- easy_eo-0.4.0/eeo/io/_sentinel2.py +458 -0
- easy_eo-0.4.0/eeo/io/_stacking.py +242 -0
- easy_eo-0.4.0/eeo/io/products.py +413 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/io/stac.py +7 -140
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/io/xarray.py +3 -1
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/preprocessing/resample.py +13 -1
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/viz/plot.py +15 -1
- {easy_eo-0.3.1 → easy_eo-0.4.0}/pyproject.toml +7 -1
- {easy_eo-0.3.1 → easy_eo-0.4.0}/.gitignore +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/LICENSE +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/_optional.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/_show_versions.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/analysis/__init__.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/analysis/indices.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/analysis/stats.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/common.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/core/__init__.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/core/adapters/__init__.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/core/adapters/base.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/core/adapters/numpy.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/core/adapters/rasterio.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/core/core.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/core/core.pyi +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/core/decorators.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/core/exceptions.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/core/loader.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/core/plugins.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/core/types.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/datasets/__init__.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/datasets/_cache.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/datasets/_registry.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/datasets/_samples.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/ops/__init__.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/ops/algebra.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/ops/merge.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/preprocessing/__init__.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/preprocessing/clip.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/preprocessing/normalize.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/preprocessing/reproject.py +0 -0
- {easy_eo-0.3.1 → easy_eo-0.4.0}/eeo/py.typed +0 -0
- {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
|
+
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.
|
|
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)
|
|
4
|
-
|
|
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))
|