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.
Files changed (50) hide show
  1. earthcatalog/__init__.py +164 -0
  2. earthcatalog/async_http_client.py +1006 -0
  3. earthcatalog/config.py +97 -0
  4. earthcatalog/engines/__init__.py +308 -0
  5. earthcatalog/engines/rustac_engine.py +142 -0
  6. earthcatalog/engines/stac_geoparquet_engine.py +126 -0
  7. earthcatalog/exceptions.py +471 -0
  8. earthcatalog/grid_systems.py +1114 -0
  9. earthcatalog/ingestion_pipeline.py +2281 -0
  10. earthcatalog/input_readers.py +603 -0
  11. earthcatalog/job_tracking.py +485 -0
  12. earthcatalog/pipeline.py +606 -0
  13. earthcatalog/schema_generator.py +911 -0
  14. earthcatalog/spatial_resolver.py +1207 -0
  15. earthcatalog/stac_hooks.py +754 -0
  16. earthcatalog/statistics.py +677 -0
  17. earthcatalog/storage_backends.py +548 -0
  18. earthcatalog/tests/__init__.py +1 -0
  19. earthcatalog/tests/conftest.py +76 -0
  20. earthcatalog/tests/test_all_grids.py +793 -0
  21. earthcatalog/tests/test_async_http.py +700 -0
  22. earthcatalog/tests/test_cli_and_storage.py +230 -0
  23. earthcatalog/tests/test_config.py +245 -0
  24. earthcatalog/tests/test_dask_integration.py +580 -0
  25. earthcatalog/tests/test_e2e_synthetic.py +1624 -0
  26. earthcatalog/tests/test_engines.py +272 -0
  27. earthcatalog/tests/test_exceptions.py +346 -0
  28. earthcatalog/tests/test_file_structure.py +245 -0
  29. earthcatalog/tests/test_input_readers.py +666 -0
  30. earthcatalog/tests/test_integration.py +200 -0
  31. earthcatalog/tests/test_integration_async.py +283 -0
  32. earthcatalog/tests/test_job_tracking.py +603 -0
  33. earthcatalog/tests/test_multi_file_input.py +336 -0
  34. earthcatalog/tests/test_passthrough_hook.py +196 -0
  35. earthcatalog/tests/test_pipeline.py +684 -0
  36. earthcatalog/tests/test_pipeline_components.py +665 -0
  37. earthcatalog/tests/test_schema_generator.py +506 -0
  38. earthcatalog/tests/test_spatial_resolver.py +413 -0
  39. earthcatalog/tests/test_stac_hooks.py +776 -0
  40. earthcatalog/tests/test_statistics.py +477 -0
  41. earthcatalog/tests/test_storage_backends.py +236 -0
  42. earthcatalog/tests/test_validation.py +435 -0
  43. earthcatalog/tests/test_workers.py +653 -0
  44. earthcatalog/validation.py +921 -0
  45. earthcatalog/workers.py +682 -0
  46. earthcatalog-0.2.0.dist-info/METADATA +333 -0
  47. earthcatalog-0.2.0.dist-info/RECORD +50 -0
  48. earthcatalog-0.2.0.dist-info/WHEEL +5 -0
  49. earthcatalog-0.2.0.dist-info/entry_points.txt +3 -0
  50. 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