earthcatalog 0.2.0__py3-none-any.whl
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.
- earthcatalog/__init__.py +164 -0
- earthcatalog/async_http_client.py +1006 -0
- earthcatalog/config.py +97 -0
- earthcatalog/engines/__init__.py +308 -0
- earthcatalog/engines/rustac_engine.py +142 -0
- earthcatalog/engines/stac_geoparquet_engine.py +126 -0
- earthcatalog/exceptions.py +471 -0
- earthcatalog/grid_systems.py +1114 -0
- earthcatalog/ingestion_pipeline.py +2281 -0
- earthcatalog/input_readers.py +603 -0
- earthcatalog/job_tracking.py +485 -0
- earthcatalog/pipeline.py +606 -0
- earthcatalog/schema_generator.py +911 -0
- earthcatalog/spatial_resolver.py +1207 -0
- earthcatalog/stac_hooks.py +754 -0
- earthcatalog/statistics.py +677 -0
- earthcatalog/storage_backends.py +548 -0
- earthcatalog/tests/__init__.py +1 -0
- earthcatalog/tests/conftest.py +76 -0
- earthcatalog/tests/test_all_grids.py +793 -0
- earthcatalog/tests/test_async_http.py +700 -0
- earthcatalog/tests/test_cli_and_storage.py +230 -0
- earthcatalog/tests/test_config.py +245 -0
- earthcatalog/tests/test_dask_integration.py +580 -0
- earthcatalog/tests/test_e2e_synthetic.py +1624 -0
- earthcatalog/tests/test_engines.py +272 -0
- earthcatalog/tests/test_exceptions.py +346 -0
- earthcatalog/tests/test_file_structure.py +245 -0
- earthcatalog/tests/test_input_readers.py +666 -0
- earthcatalog/tests/test_integration.py +200 -0
- earthcatalog/tests/test_integration_async.py +283 -0
- earthcatalog/tests/test_job_tracking.py +603 -0
- earthcatalog/tests/test_multi_file_input.py +336 -0
- earthcatalog/tests/test_passthrough_hook.py +196 -0
- earthcatalog/tests/test_pipeline.py +684 -0
- earthcatalog/tests/test_pipeline_components.py +665 -0
- earthcatalog/tests/test_schema_generator.py +506 -0
- earthcatalog/tests/test_spatial_resolver.py +413 -0
- earthcatalog/tests/test_stac_hooks.py +776 -0
- earthcatalog/tests/test_statistics.py +477 -0
- earthcatalog/tests/test_storage_backends.py +236 -0
- earthcatalog/tests/test_validation.py +435 -0
- earthcatalog/tests/test_workers.py +653 -0
- earthcatalog/validation.py +921 -0
- earthcatalog/workers.py +682 -0
- earthcatalog-0.2.0.dist-info/METADATA +333 -0
- earthcatalog-0.2.0.dist-info/RECORD +50 -0
- earthcatalog-0.2.0.dist-info/WHEEL +5 -0
- earthcatalog-0.2.0.dist-info/entry_points.txt +3 -0
- earthcatalog-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Tests for STAC engine abstraction layer.
|
|
2
|
+
|
|
3
|
+
Tests both rustac and stac-geoparquet engines to ensure consistent behavior.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import geopandas as gpd
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from earthcatalog.engines import (
|
|
13
|
+
STACEngine,
|
|
14
|
+
get_engine,
|
|
15
|
+
)
|
|
16
|
+
from earthcatalog.storage_backends import LocalStorage
|
|
17
|
+
|
|
18
|
+
# Sample STAC items for testing
|
|
19
|
+
SAMPLE_ITEMS = [
|
|
20
|
+
{
|
|
21
|
+
"type": "Feature",
|
|
22
|
+
"id": "test-item-1",
|
|
23
|
+
"stac_version": "1.0.0",
|
|
24
|
+
"geometry": {"type": "Point", "coordinates": [-122.4, 37.8]},
|
|
25
|
+
"bbox": [-122.5, 37.7, -122.3, 37.9],
|
|
26
|
+
"properties": {"datetime": "2024-01-01T00:00:00Z"},
|
|
27
|
+
"links": [{"rel": "self", "href": "https://example.com/item1.json"}],
|
|
28
|
+
"assets": {"data": {"href": "https://example.com/data1.tif"}},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"type": "Feature",
|
|
32
|
+
"id": "test-item-2",
|
|
33
|
+
"stac_version": "1.0.0",
|
|
34
|
+
"geometry": {
|
|
35
|
+
"type": "Polygon",
|
|
36
|
+
"coordinates": [[[-122.0, 37.0], [-121.0, 37.0], [-121.0, 38.0], [-122.0, 38.0], [-122.0, 37.0]]],
|
|
37
|
+
},
|
|
38
|
+
"bbox": [-122.0, 37.0, -121.0, 38.0],
|
|
39
|
+
"properties": {"datetime": "2024-06-15T12:00:00Z"},
|
|
40
|
+
"links": [{"rel": "self", "href": "https://example.com/item2.json"}],
|
|
41
|
+
"assets": {"data": {"href": "https://example.com/data2.tif"}},
|
|
42
|
+
},
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.fixture(params=["rustac", "stac-geoparquet"])
|
|
47
|
+
def engine(request) -> STACEngine:
|
|
48
|
+
"""Parameterized fixture that yields both engine types."""
|
|
49
|
+
return get_engine(request.param)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TestEngineFactory:
|
|
53
|
+
"""Tests for the engine factory function."""
|
|
54
|
+
|
|
55
|
+
def test_get_rustac_engine(self):
|
|
56
|
+
"""Test getting rustac engine explicitly."""
|
|
57
|
+
engine = get_engine("rustac")
|
|
58
|
+
assert engine.name == "rustac"
|
|
59
|
+
|
|
60
|
+
def test_get_stac_geoparquet_engine(self):
|
|
61
|
+
"""Test getting stac-geoparquet engine explicitly."""
|
|
62
|
+
engine = get_engine("stac-geoparquet")
|
|
63
|
+
assert engine.name == "stac-geoparquet"
|
|
64
|
+
|
|
65
|
+
def test_get_auto_engine(self):
|
|
66
|
+
"""Test auto engine selection (should prefer rustac)."""
|
|
67
|
+
engine = get_engine("auto")
|
|
68
|
+
# Should return one of the available engines
|
|
69
|
+
assert engine.name in ("rustac", "stac-geoparquet")
|
|
70
|
+
|
|
71
|
+
def test_invalid_engine_type(self):
|
|
72
|
+
"""Test that invalid engine type raises ValueError."""
|
|
73
|
+
with pytest.raises(ValueError, match="Unknown engine type"):
|
|
74
|
+
get_engine("invalid-engine") # type: ignore
|
|
75
|
+
|
|
76
|
+
def test_engine_is_stac_engine(self):
|
|
77
|
+
"""Test that returned engines implement STACEngine."""
|
|
78
|
+
engine = get_engine("rustac")
|
|
79
|
+
assert isinstance(engine, STACEngine)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TestItemsToGeoDataFrame:
|
|
83
|
+
"""Tests for converting STAC items to GeoDataFrame."""
|
|
84
|
+
|
|
85
|
+
def test_single_item_conversion(self, engine: STACEngine):
|
|
86
|
+
"""Test converting a single STAC item."""
|
|
87
|
+
gdf = engine.items_to_geodataframe([SAMPLE_ITEMS[0]])
|
|
88
|
+
|
|
89
|
+
assert isinstance(gdf, gpd.GeoDataFrame)
|
|
90
|
+
assert len(gdf) == 1
|
|
91
|
+
assert "geometry" in gdf.columns
|
|
92
|
+
assert gdf.iloc[0]["id"] == "test-item-1"
|
|
93
|
+
|
|
94
|
+
def test_multiple_items_conversion(self, engine: STACEngine):
|
|
95
|
+
"""Test converting multiple STAC items."""
|
|
96
|
+
gdf = engine.items_to_geodataframe(SAMPLE_ITEMS)
|
|
97
|
+
|
|
98
|
+
assert isinstance(gdf, gpd.GeoDataFrame)
|
|
99
|
+
assert len(gdf) == 2
|
|
100
|
+
assert set(gdf["id"].tolist()) == {"test-item-1", "test-item-2"}
|
|
101
|
+
|
|
102
|
+
def test_empty_items_list(self, engine: STACEngine):
|
|
103
|
+
"""Test converting empty list returns empty GeoDataFrame."""
|
|
104
|
+
gdf = engine.items_to_geodataframe([])
|
|
105
|
+
|
|
106
|
+
assert isinstance(gdf, gpd.GeoDataFrame)
|
|
107
|
+
assert len(gdf) == 0
|
|
108
|
+
|
|
109
|
+
def test_geometry_preserved(self, engine: STACEngine):
|
|
110
|
+
"""Test that geometry is correctly preserved."""
|
|
111
|
+
gdf = engine.items_to_geodataframe([SAMPLE_ITEMS[0]])
|
|
112
|
+
|
|
113
|
+
geom = gdf.iloc[0].geometry
|
|
114
|
+
assert geom is not None
|
|
115
|
+
assert geom.geom_type == "Point"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class TestGeoDataFrameToItems:
|
|
119
|
+
"""Tests for converting GeoDataFrame back to STAC items."""
|
|
120
|
+
|
|
121
|
+
def test_roundtrip_conversion(self, engine: STACEngine):
|
|
122
|
+
"""Test that items survive a roundtrip conversion."""
|
|
123
|
+
# Convert to GeoDataFrame
|
|
124
|
+
gdf = engine.items_to_geodataframe(SAMPLE_ITEMS)
|
|
125
|
+
|
|
126
|
+
# Convert back to items
|
|
127
|
+
items = engine.geodataframe_to_items(gdf)
|
|
128
|
+
|
|
129
|
+
assert len(items) == 2
|
|
130
|
+
item_ids = {item["id"] for item in items}
|
|
131
|
+
assert item_ids == {"test-item-1", "test-item-2"}
|
|
132
|
+
|
|
133
|
+
def test_empty_geodataframe(self, engine: STACEngine):
|
|
134
|
+
"""Test converting empty GeoDataFrame returns empty list."""
|
|
135
|
+
gdf = gpd.GeoDataFrame()
|
|
136
|
+
items = engine.geodataframe_to_items(gdf)
|
|
137
|
+
|
|
138
|
+
assert items == []
|
|
139
|
+
|
|
140
|
+
def test_geometry_preserved_in_roundtrip(self, engine: STACEngine):
|
|
141
|
+
"""Test that geometry is preserved in roundtrip."""
|
|
142
|
+
original = [SAMPLE_ITEMS[0]]
|
|
143
|
+
gdf = engine.items_to_geodataframe(original)
|
|
144
|
+
items = engine.geodataframe_to_items(gdf)
|
|
145
|
+
|
|
146
|
+
assert len(items) == 1
|
|
147
|
+
item = items[0]
|
|
148
|
+
assert "geometry" in item
|
|
149
|
+
assert item["geometry"]["type"] == "Point"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class TestGeoParquetIO:
|
|
153
|
+
"""Tests for GeoParquet read/write operations."""
|
|
154
|
+
|
|
155
|
+
def test_write_and_read_geoparquet(self, engine: STACEngine):
|
|
156
|
+
"""Test writing and reading GeoParquet files."""
|
|
157
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
158
|
+
path = f"{tmpdir}/test.parquet"
|
|
159
|
+
storage = LocalStorage(tmpdir)
|
|
160
|
+
|
|
161
|
+
# Convert items to GeoDataFrame
|
|
162
|
+
gdf = engine.items_to_geodataframe(SAMPLE_ITEMS)
|
|
163
|
+
|
|
164
|
+
# Write to GeoParquet
|
|
165
|
+
engine.write_geoparquet_sync(gdf, path, storage)
|
|
166
|
+
|
|
167
|
+
# Verify file was created
|
|
168
|
+
assert Path(path).exists()
|
|
169
|
+
|
|
170
|
+
# Read back
|
|
171
|
+
gdf_read = engine.read_geoparquet_sync(path, storage)
|
|
172
|
+
|
|
173
|
+
# Verify data
|
|
174
|
+
assert isinstance(gdf_read, gpd.GeoDataFrame)
|
|
175
|
+
assert len(gdf_read) == 2
|
|
176
|
+
|
|
177
|
+
def test_write_empty_geodataframe(self, engine: STACEngine):
|
|
178
|
+
"""Test that writing empty GeoDataFrame doesn't create file."""
|
|
179
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
180
|
+
path = f"{tmpdir}/empty.parquet"
|
|
181
|
+
storage = LocalStorage(tmpdir)
|
|
182
|
+
|
|
183
|
+
gdf = gpd.GeoDataFrame()
|
|
184
|
+
engine.write_geoparquet_sync(gdf, path, storage)
|
|
185
|
+
|
|
186
|
+
# Empty GeoDataFrame should not create a file
|
|
187
|
+
assert not Path(path).exists()
|
|
188
|
+
|
|
189
|
+
def test_read_nonexistent_file(self, engine: STACEngine):
|
|
190
|
+
"""Test reading nonexistent file raises appropriate error."""
|
|
191
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
192
|
+
path = f"{tmpdir}/nonexistent.parquet"
|
|
193
|
+
storage = LocalStorage(tmpdir)
|
|
194
|
+
|
|
195
|
+
with pytest.raises((FileNotFoundError, IOError)):
|
|
196
|
+
engine.read_geoparquet_sync(path, storage)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class TestEngineConsistency:
|
|
200
|
+
"""Tests to verify both engines produce consistent results."""
|
|
201
|
+
|
|
202
|
+
def test_geodataframe_columns_present(self):
|
|
203
|
+
"""Test that both engines include essential columns."""
|
|
204
|
+
rustac_engine = get_engine("rustac")
|
|
205
|
+
sgp_engine = get_engine("stac-geoparquet")
|
|
206
|
+
|
|
207
|
+
gdf_rustac = rustac_engine.items_to_geodataframe(SAMPLE_ITEMS)
|
|
208
|
+
gdf_sgp = sgp_engine.items_to_geodataframe(SAMPLE_ITEMS)
|
|
209
|
+
|
|
210
|
+
# Both should have geometry column
|
|
211
|
+
assert "geometry" in gdf_rustac.columns
|
|
212
|
+
assert "geometry" in gdf_sgp.columns
|
|
213
|
+
|
|
214
|
+
# Both should have id column
|
|
215
|
+
assert "id" in gdf_rustac.columns
|
|
216
|
+
assert "id" in gdf_sgp.columns
|
|
217
|
+
|
|
218
|
+
def test_item_ids_preserved(self):
|
|
219
|
+
"""Test that both engines preserve item IDs."""
|
|
220
|
+
rustac_engine = get_engine("rustac")
|
|
221
|
+
sgp_engine = get_engine("stac-geoparquet")
|
|
222
|
+
|
|
223
|
+
gdf_rustac = rustac_engine.items_to_geodataframe(SAMPLE_ITEMS)
|
|
224
|
+
gdf_sgp = sgp_engine.items_to_geodataframe(SAMPLE_ITEMS)
|
|
225
|
+
|
|
226
|
+
ids_rustac = set(gdf_rustac["id"].tolist())
|
|
227
|
+
ids_sgp = set(gdf_sgp["id"].tolist())
|
|
228
|
+
|
|
229
|
+
expected_ids = {"test-item-1", "test-item-2"}
|
|
230
|
+
assert ids_rustac == expected_ids
|
|
231
|
+
assert ids_sgp == expected_ids
|
|
232
|
+
|
|
233
|
+
def test_roundtrip_preserves_ids(self):
|
|
234
|
+
"""Test that roundtrip conversion preserves IDs in both engines."""
|
|
235
|
+
rustac_engine = get_engine("rustac")
|
|
236
|
+
sgp_engine = get_engine("stac-geoparquet")
|
|
237
|
+
|
|
238
|
+
for engine in [rustac_engine, sgp_engine]:
|
|
239
|
+
gdf = engine.items_to_geodataframe(SAMPLE_ITEMS)
|
|
240
|
+
items = engine.geodataframe_to_items(gdf)
|
|
241
|
+
|
|
242
|
+
item_ids = {item["id"] for item in items}
|
|
243
|
+
assert item_ids == {"test-item-1", "test-item-2"}, f"Failed for {engine.name}"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class TestEnginePerformance:
|
|
247
|
+
"""Basic performance sanity tests."""
|
|
248
|
+
|
|
249
|
+
def test_handles_larger_datasets(self, engine: STACEngine):
|
|
250
|
+
"""Test that engines can handle larger datasets."""
|
|
251
|
+
# Create 100 items
|
|
252
|
+
items = []
|
|
253
|
+
for i in range(100):
|
|
254
|
+
item = {
|
|
255
|
+
"type": "Feature",
|
|
256
|
+
"id": f"test-item-{i}",
|
|
257
|
+
"stac_version": "1.0.0",
|
|
258
|
+
"geometry": {"type": "Point", "coordinates": [-122.0 + (i * 0.01), 37.0 + (i * 0.01)]},
|
|
259
|
+
"bbox": [-122.1, 36.9, -121.9, 37.1],
|
|
260
|
+
"properties": {"datetime": "2024-01-01T00:00:00Z"},
|
|
261
|
+
"links": [],
|
|
262
|
+
"assets": {},
|
|
263
|
+
}
|
|
264
|
+
items.append(item)
|
|
265
|
+
|
|
266
|
+
# Convert to GeoDataFrame
|
|
267
|
+
gdf = engine.items_to_geodataframe(items)
|
|
268
|
+
assert len(gdf) == 100
|
|
269
|
+
|
|
270
|
+
# Convert back
|
|
271
|
+
converted = engine.geodataframe_to_items(gdf)
|
|
272
|
+
assert len(converted) == 100
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Tests for the exceptions module."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from earthcatalog.exceptions import (
|
|
6
|
+
ConfigurationError,
|
|
7
|
+
ConsolidationError,
|
|
8
|
+
DownloadError,
|
|
9
|
+
EarthCatalogError,
|
|
10
|
+
IngestionError,
|
|
11
|
+
InvalidGridConfigError,
|
|
12
|
+
InvalidStorageConfigError,
|
|
13
|
+
ItemValidationError,
|
|
14
|
+
QueryError,
|
|
15
|
+
SpatialResolverError,
|
|
16
|
+
StorageConnectionError,
|
|
17
|
+
StorageError,
|
|
18
|
+
StorageReadError,
|
|
19
|
+
StorageWriteError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestEarthCatalogError:
|
|
24
|
+
"""Tests for base EarthCatalogError class."""
|
|
25
|
+
|
|
26
|
+
def test_basic_initialization(self):
|
|
27
|
+
"""Test basic exception initialization."""
|
|
28
|
+
exc = EarthCatalogError("Something went wrong")
|
|
29
|
+
assert str(exc) == "Something went wrong"
|
|
30
|
+
assert exc.message == "Something went wrong"
|
|
31
|
+
assert exc.details == {}
|
|
32
|
+
|
|
33
|
+
def test_initialization_with_details(self):
|
|
34
|
+
"""Test exception with details dict."""
|
|
35
|
+
details = {"key": "value", "count": 42}
|
|
36
|
+
exc = EarthCatalogError("Error occurred", details=details)
|
|
37
|
+
assert exc.details == details
|
|
38
|
+
assert exc.details["key"] == "value"
|
|
39
|
+
|
|
40
|
+
def test_repr(self):
|
|
41
|
+
"""Test __repr__ method."""
|
|
42
|
+
exc = EarthCatalogError("Test error")
|
|
43
|
+
assert repr(exc) == "EarthCatalogError('Test error')"
|
|
44
|
+
|
|
45
|
+
def test_bool(self):
|
|
46
|
+
"""Test __bool__ method - exceptions are always truthy."""
|
|
47
|
+
exc = EarthCatalogError("Error")
|
|
48
|
+
assert bool(exc) is True
|
|
49
|
+
|
|
50
|
+
def test_is_exception(self):
|
|
51
|
+
"""Test that it can be raised and caught."""
|
|
52
|
+
with pytest.raises(EarthCatalogError):
|
|
53
|
+
raise EarthCatalogError("Test")
|
|
54
|
+
|
|
55
|
+
def test_catch_as_base_exception(self):
|
|
56
|
+
"""Test that exception inherits from Exception."""
|
|
57
|
+
exc = EarthCatalogError("Test")
|
|
58
|
+
assert isinstance(exc, Exception)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestConfigurationError:
|
|
62
|
+
"""Tests for ConfigurationError and subclasses."""
|
|
63
|
+
|
|
64
|
+
def test_configuration_error_inheritance(self):
|
|
65
|
+
"""Test ConfigurationError inherits from EarthCatalogError."""
|
|
66
|
+
exc = ConfigurationError("Invalid config")
|
|
67
|
+
assert isinstance(exc, EarthCatalogError)
|
|
68
|
+
|
|
69
|
+
def test_invalid_grid_config_error(self):
|
|
70
|
+
"""Test InvalidGridConfigError with all attributes."""
|
|
71
|
+
exc = InvalidGridConfigError(
|
|
72
|
+
"H3 resolution must be 0-15",
|
|
73
|
+
grid_system="h3",
|
|
74
|
+
resolution=20,
|
|
75
|
+
)
|
|
76
|
+
assert exc.grid_system == "h3"
|
|
77
|
+
assert exc.resolution == 20
|
|
78
|
+
assert exc.details["grid_system"] == "h3"
|
|
79
|
+
assert exc.details["resolution"] == 20
|
|
80
|
+
assert isinstance(exc, ConfigurationError)
|
|
81
|
+
|
|
82
|
+
def test_invalid_grid_config_error_with_extra_kwargs(self):
|
|
83
|
+
"""Test InvalidGridConfigError with additional kwargs."""
|
|
84
|
+
exc = InvalidGridConfigError(
|
|
85
|
+
"Error",
|
|
86
|
+
grid_system="h3",
|
|
87
|
+
resolution=6,
|
|
88
|
+
extra_info="some value",
|
|
89
|
+
)
|
|
90
|
+
assert exc.details["extra_info"] == "some value"
|
|
91
|
+
|
|
92
|
+
def test_invalid_storage_config_error(self):
|
|
93
|
+
"""Test InvalidStorageConfigError with all attributes."""
|
|
94
|
+
exc = InvalidStorageConfigError(
|
|
95
|
+
"Bucket does not exist",
|
|
96
|
+
backend="s3",
|
|
97
|
+
path="s3://nonexistent/bucket",
|
|
98
|
+
)
|
|
99
|
+
assert exc.backend == "s3"
|
|
100
|
+
assert exc.path == "s3://nonexistent/bucket"
|
|
101
|
+
assert isinstance(exc, ConfigurationError)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TestIngestionError:
|
|
105
|
+
"""Tests for IngestionError and subclasses."""
|
|
106
|
+
|
|
107
|
+
def test_ingestion_error_inheritance(self):
|
|
108
|
+
"""Test IngestionError inherits from EarthCatalogError."""
|
|
109
|
+
exc = IngestionError("Ingestion failed")
|
|
110
|
+
assert isinstance(exc, EarthCatalogError)
|
|
111
|
+
|
|
112
|
+
def test_download_error_basic(self):
|
|
113
|
+
"""Test DownloadError with minimal attributes."""
|
|
114
|
+
exc = DownloadError("Connection failed")
|
|
115
|
+
assert exc.url is None
|
|
116
|
+
assert exc.status_code is None
|
|
117
|
+
assert exc.retry_count == 0
|
|
118
|
+
assert isinstance(exc, IngestionError)
|
|
119
|
+
|
|
120
|
+
def test_download_error_full(self):
|
|
121
|
+
"""Test DownloadError with all attributes."""
|
|
122
|
+
exc = DownloadError(
|
|
123
|
+
"Request timed out",
|
|
124
|
+
url="https://api.example.com/item.json",
|
|
125
|
+
status_code=504,
|
|
126
|
+
retry_count=3,
|
|
127
|
+
error_type="timeout",
|
|
128
|
+
)
|
|
129
|
+
assert exc.url == "https://api.example.com/item.json"
|
|
130
|
+
assert exc.status_code == 504
|
|
131
|
+
assert exc.retry_count == 3
|
|
132
|
+
assert exc.error_type == "timeout"
|
|
133
|
+
assert exc.details["url"] == "https://api.example.com/item.json"
|
|
134
|
+
|
|
135
|
+
def test_item_validation_error(self):
|
|
136
|
+
"""Test ItemValidationError with all attributes."""
|
|
137
|
+
exc = ItemValidationError(
|
|
138
|
+
"Geometry is self-intersecting",
|
|
139
|
+
item_id="ITEM_123",
|
|
140
|
+
issue_code="INVALID_GEOMETRY",
|
|
141
|
+
)
|
|
142
|
+
assert exc.item_id == "ITEM_123"
|
|
143
|
+
assert exc.issue_code == "INVALID_GEOMETRY"
|
|
144
|
+
assert isinstance(exc, IngestionError)
|
|
145
|
+
|
|
146
|
+
def test_consolidation_error(self):
|
|
147
|
+
"""Test ConsolidationError with all attributes."""
|
|
148
|
+
exc = ConsolidationError(
|
|
149
|
+
"Memory limit exceeded",
|
|
150
|
+
partition_key="h3=abc123/year=2024/month=01",
|
|
151
|
+
shard_count=50,
|
|
152
|
+
)
|
|
153
|
+
assert exc.partition_key == "h3=abc123/year=2024/month=01"
|
|
154
|
+
assert exc.shard_count == 50
|
|
155
|
+
assert isinstance(exc, IngestionError)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class TestStorageError:
|
|
159
|
+
"""Tests for StorageError and subclasses."""
|
|
160
|
+
|
|
161
|
+
def test_storage_error_inheritance(self):
|
|
162
|
+
"""Test StorageError inherits from EarthCatalogError."""
|
|
163
|
+
exc = StorageError("Storage failed")
|
|
164
|
+
assert isinstance(exc, EarthCatalogError)
|
|
165
|
+
|
|
166
|
+
def test_storage_error_with_path(self):
|
|
167
|
+
"""Test StorageError with path attribute."""
|
|
168
|
+
exc = StorageError("Cannot access path", path="/data/catalog")
|
|
169
|
+
assert exc.path == "/data/catalog"
|
|
170
|
+
assert exc.details["path"] == "/data/catalog"
|
|
171
|
+
|
|
172
|
+
def test_storage_connection_error(self):
|
|
173
|
+
"""Test StorageConnectionError."""
|
|
174
|
+
exc = StorageConnectionError(
|
|
175
|
+
"Access denied",
|
|
176
|
+
path="s3://bucket/catalog",
|
|
177
|
+
)
|
|
178
|
+
assert exc.path == "s3://bucket/catalog"
|
|
179
|
+
assert isinstance(exc, StorageError)
|
|
180
|
+
|
|
181
|
+
def test_storage_write_error(self):
|
|
182
|
+
"""Test StorageWriteError with bytes_written."""
|
|
183
|
+
exc = StorageWriteError(
|
|
184
|
+
"Disk full",
|
|
185
|
+
path="/catalog/partition.parquet",
|
|
186
|
+
bytes_written=1024000,
|
|
187
|
+
)
|
|
188
|
+
assert exc.path == "/catalog/partition.parquet"
|
|
189
|
+
assert exc.bytes_written == 1024000
|
|
190
|
+
assert exc.details["bytes_written"] == 1024000
|
|
191
|
+
assert isinstance(exc, StorageError)
|
|
192
|
+
|
|
193
|
+
def test_storage_read_error(self):
|
|
194
|
+
"""Test StorageReadError."""
|
|
195
|
+
exc = StorageReadError(
|
|
196
|
+
"File not found",
|
|
197
|
+
path="s3://bucket/missing.parquet",
|
|
198
|
+
)
|
|
199
|
+
assert exc.path == "s3://bucket/missing.parquet"
|
|
200
|
+
assert isinstance(exc, StorageError)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class TestQueryError:
|
|
204
|
+
"""Tests for QueryError and subclasses."""
|
|
205
|
+
|
|
206
|
+
def test_query_error_inheritance(self):
|
|
207
|
+
"""Test QueryError inherits from EarthCatalogError."""
|
|
208
|
+
exc = QueryError("Query failed")
|
|
209
|
+
assert isinstance(exc, EarthCatalogError)
|
|
210
|
+
|
|
211
|
+
def test_spatial_resolver_error(self):
|
|
212
|
+
"""Test SpatialResolverError with geometry_type."""
|
|
213
|
+
exc = SpatialResolverError(
|
|
214
|
+
"Cannot resolve partitions",
|
|
215
|
+
geometry_type="Polygon",
|
|
216
|
+
)
|
|
217
|
+
assert exc.geometry_type == "Polygon"
|
|
218
|
+
assert exc.details["geometry_type"] == "Polygon"
|
|
219
|
+
assert isinstance(exc, QueryError)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class TestExceptionHierarchy:
|
|
223
|
+
"""Tests for the exception hierarchy."""
|
|
224
|
+
|
|
225
|
+
def test_all_inherit_from_base(self):
|
|
226
|
+
"""Test all exceptions inherit from EarthCatalogError."""
|
|
227
|
+
exceptions = [
|
|
228
|
+
ConfigurationError("test"),
|
|
229
|
+
InvalidGridConfigError("test"),
|
|
230
|
+
InvalidStorageConfigError("test"),
|
|
231
|
+
IngestionError("test"),
|
|
232
|
+
DownloadError("test"),
|
|
233
|
+
ItemValidationError("test"),
|
|
234
|
+
ConsolidationError("test"),
|
|
235
|
+
StorageError("test"),
|
|
236
|
+
StorageConnectionError("test"),
|
|
237
|
+
StorageWriteError("test"),
|
|
238
|
+
StorageReadError("test"),
|
|
239
|
+
QueryError("test"),
|
|
240
|
+
SpatialResolverError("test"),
|
|
241
|
+
]
|
|
242
|
+
for exc in exceptions:
|
|
243
|
+
assert isinstance(exc, EarthCatalogError), f"{type(exc).__name__} should inherit from EarthCatalogError"
|
|
244
|
+
|
|
245
|
+
def test_catch_all_with_base(self):
|
|
246
|
+
"""Test that all exceptions can be caught with base class."""
|
|
247
|
+
exceptions_to_test = [
|
|
248
|
+
DownloadError("test"),
|
|
249
|
+
ConsolidationError("test"),
|
|
250
|
+
StorageWriteError("test"),
|
|
251
|
+
SpatialResolverError("test"),
|
|
252
|
+
]
|
|
253
|
+
|
|
254
|
+
for exc in exceptions_to_test:
|
|
255
|
+
try:
|
|
256
|
+
raise exc
|
|
257
|
+
except EarthCatalogError as caught:
|
|
258
|
+
assert caught is exc
|
|
259
|
+
|
|
260
|
+
@pytest.mark.parametrize(
|
|
261
|
+
"exception_class,parent_class",
|
|
262
|
+
[
|
|
263
|
+
# Configuration errors
|
|
264
|
+
(InvalidGridConfigError, ConfigurationError),
|
|
265
|
+
(InvalidStorageConfigError, ConfigurationError),
|
|
266
|
+
# Ingestion errors
|
|
267
|
+
(DownloadError, IngestionError),
|
|
268
|
+
(ItemValidationError, IngestionError),
|
|
269
|
+
(ConsolidationError, IngestionError),
|
|
270
|
+
# Storage errors
|
|
271
|
+
(StorageConnectionError, StorageError),
|
|
272
|
+
(StorageWriteError, StorageError),
|
|
273
|
+
(StorageReadError, StorageError),
|
|
274
|
+
# Query errors
|
|
275
|
+
(SpatialResolverError, QueryError),
|
|
276
|
+
],
|
|
277
|
+
ids=[
|
|
278
|
+
"InvalidGridConfigError->ConfigurationError",
|
|
279
|
+
"InvalidStorageConfigError->ConfigurationError",
|
|
280
|
+
"DownloadError->IngestionError",
|
|
281
|
+
"ItemValidationError->IngestionError",
|
|
282
|
+
"ConsolidationError->IngestionError",
|
|
283
|
+
"StorageConnectionError->StorageError",
|
|
284
|
+
"StorageWriteError->StorageError",
|
|
285
|
+
"StorageReadError->StorageError",
|
|
286
|
+
"SpatialResolverError->QueryError",
|
|
287
|
+
],
|
|
288
|
+
)
|
|
289
|
+
def test_inheritance_chains(self, exception_class, parent_class):
|
|
290
|
+
"""Test specific inheritance chains."""
|
|
291
|
+
exc = exception_class("test")
|
|
292
|
+
assert isinstance(exc, parent_class), f"{exception_class.__name__} should inherit from {parent_class.__name__}"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class TestExceptionUsage:
|
|
296
|
+
"""Test realistic usage patterns."""
|
|
297
|
+
|
|
298
|
+
def test_exception_in_try_except(self):
|
|
299
|
+
"""Test exception usage in try/except."""
|
|
300
|
+
|
|
301
|
+
def download_item(url: str) -> dict:
|
|
302
|
+
if "invalid" in url:
|
|
303
|
+
raise DownloadError(
|
|
304
|
+
f"Failed to download {url}",
|
|
305
|
+
url=url,
|
|
306
|
+
status_code=404,
|
|
307
|
+
error_type="not_found",
|
|
308
|
+
)
|
|
309
|
+
return {"id": "item"}
|
|
310
|
+
|
|
311
|
+
# Should not raise
|
|
312
|
+
result = download_item("https://valid.com/item.json")
|
|
313
|
+
assert result == {"id": "item"}
|
|
314
|
+
|
|
315
|
+
# Should raise
|
|
316
|
+
with pytest.raises(DownloadError) as exc_info:
|
|
317
|
+
download_item("https://invalid.com/item.json")
|
|
318
|
+
|
|
319
|
+
assert exc_info.value.status_code == 404
|
|
320
|
+
assert exc_info.value.error_type == "not_found"
|
|
321
|
+
|
|
322
|
+
def test_exception_chaining(self):
|
|
323
|
+
"""Test exception chaining with __cause__."""
|
|
324
|
+
try:
|
|
325
|
+
try:
|
|
326
|
+
raise ValueError("Original error")
|
|
327
|
+
except ValueError as e:
|
|
328
|
+
raise DownloadError("Download failed") from e
|
|
329
|
+
except DownloadError as exc:
|
|
330
|
+
assert exc.__cause__ is not None
|
|
331
|
+
assert isinstance(exc.__cause__, ValueError)
|
|
332
|
+
|
|
333
|
+
def test_exception_details_for_logging(self):
|
|
334
|
+
"""Test that exception details are useful for logging."""
|
|
335
|
+
exc = ConsolidationError(
|
|
336
|
+
"Failed to merge shards",
|
|
337
|
+
partition_key="h3=abc/year=2024",
|
|
338
|
+
shard_count=10,
|
|
339
|
+
memory_used_mb=1024,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# Details should be suitable for structured logging
|
|
343
|
+
log_context = exc.details
|
|
344
|
+
assert "partition_key" in log_context
|
|
345
|
+
assert "shard_count" in log_context
|
|
346
|
+
assert "memory_used_mb" in log_context
|