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,200 @@
|
|
|
1
|
+
"""Integration tests for end-to-end catalog generation and spatial resolution."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from shapely.geometry import box
|
|
9
|
+
|
|
10
|
+
from earthcatalog.spatial_resolver import spatial_resolver
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestIntegration:
|
|
14
|
+
"""Integration tests for schema generation and spatial resolution workflow."""
|
|
15
|
+
|
|
16
|
+
def setup_method(self):
|
|
17
|
+
"""Set up test fixtures."""
|
|
18
|
+
self.temp_dir = tempfile.mkdtemp()
|
|
19
|
+
self.catalog_path = Path(self.temp_dir) / "test_catalog"
|
|
20
|
+
self.catalog_path.mkdir(exist_ok=True)
|
|
21
|
+
|
|
22
|
+
def test_schema_generation_and_spatial_resolution(self):
|
|
23
|
+
"""Test schema generation functionality and spatial resolution."""
|
|
24
|
+
|
|
25
|
+
# Create a mock schema file (as would be generated by the pipeline)
|
|
26
|
+
mock_schema = {
|
|
27
|
+
"spatial_partitioning": {
|
|
28
|
+
"grid_system": "h3",
|
|
29
|
+
"resolution": 6,
|
|
30
|
+
"coordinate_system": "WGS84 (EPSG:4326)",
|
|
31
|
+
"cell_area_km2_avg": 36.13,
|
|
32
|
+
"cell_edge_length_km_avg": 3.23,
|
|
33
|
+
},
|
|
34
|
+
"global_partitioning": {
|
|
35
|
+
"enabled": True,
|
|
36
|
+
"threshold": 2,
|
|
37
|
+
"description": "Items that span more than 2 spatial cells are stored in global partition",
|
|
38
|
+
},
|
|
39
|
+
"temporal_partitioning": {"scheme": "year-month", "levels": ["year", "month"]},
|
|
40
|
+
"partition_structure": {
|
|
41
|
+
"spatial_partitions": ["861d907dfffffff", "861d90757ffffff"],
|
|
42
|
+
"temporal_partitions": ["2024", "2024-01", "2024-02"],
|
|
43
|
+
"global_partition": True,
|
|
44
|
+
"total_partitions": 12,
|
|
45
|
+
},
|
|
46
|
+
"usage_examples": {
|
|
47
|
+
"query_pattern": "SELECT * FROM read_parquet('{catalog_path}/spatial_partition={{partition_id}}/temporal_partition=2024-*/data.parquet')",
|
|
48
|
+
"description": "Replace {{partition_id}} with resolved spatial partition IDs for efficient querying",
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Save mock schema file
|
|
53
|
+
schema_file = self.catalog_path / "catalog_schema.json"
|
|
54
|
+
with open(schema_file, "w") as f:
|
|
55
|
+
json.dump(mock_schema, f, indent=2)
|
|
56
|
+
|
|
57
|
+
# Test spatial resolution with mock schema
|
|
58
|
+
resolver = spatial_resolver(str(schema_file), str(self.catalog_path))
|
|
59
|
+
|
|
60
|
+
# Verify resolver initialization
|
|
61
|
+
assert resolver.grid_system == "h3"
|
|
62
|
+
assert resolver.spatial_config["resolution"] == 6
|
|
63
|
+
assert resolver.global_enabled is True
|
|
64
|
+
assert resolver.global_threshold == 2
|
|
65
|
+
|
|
66
|
+
# Test resolution for San Francisco area
|
|
67
|
+
sf_area = box(-122.5, 37.7, -122.0, 38.0)
|
|
68
|
+
sf_partitions = resolver.resolve_partitions(sf_area, overlap=True)
|
|
69
|
+
|
|
70
|
+
assert len(sf_partitions) > 0, "Should resolve at least one partition for SF area"
|
|
71
|
+
|
|
72
|
+
# Test query path generation (creates mock partition directories for testing)
|
|
73
|
+
for partition_id in sf_partitions[:2]: # Only test a couple to avoid creating too many dirs
|
|
74
|
+
if partition_id != "global": # Skip global partition for this test
|
|
75
|
+
partition_dir = self.catalog_path / partition_id
|
|
76
|
+
partition_dir.mkdir(exist_ok=True)
|
|
77
|
+
|
|
78
|
+
query_paths = resolver.generate_query_paths(sf_partitions, "2024-*")
|
|
79
|
+
assert isinstance(query_paths, list), "Should return query path list"
|
|
80
|
+
if len(query_paths) > 0: # Only check content if paths exist
|
|
81
|
+
assert any("2024-*" in path for path in query_paths), "Should contain temporal filter"
|
|
82
|
+
|
|
83
|
+
# Test resolution with manual global override
|
|
84
|
+
small_area = box(-122.1, 37.8, -122.05, 37.85) # Very small area in SF
|
|
85
|
+
small_partitions_no_global = resolver.resolve_partitions(small_area, include_global=False)
|
|
86
|
+
small_partitions_with_global = resolver.resolve_partitions(small_area, include_global=True)
|
|
87
|
+
|
|
88
|
+
assert "global" not in small_partitions_no_global, "Manual override should exclude global"
|
|
89
|
+
assert "global" in small_partitions_with_global, "Manual override should include global"
|
|
90
|
+
|
|
91
|
+
def test_geojson_schema_and_resolution(self):
|
|
92
|
+
"""Test schema generation and resolution with custom GeoJSON grid system."""
|
|
93
|
+
|
|
94
|
+
# Create custom GeoJSON tiles structure (as would be in a schema)
|
|
95
|
+
geojson_tiles = {
|
|
96
|
+
"type": "FeatureCollection",
|
|
97
|
+
"features": [
|
|
98
|
+
{
|
|
99
|
+
"type": "Feature",
|
|
100
|
+
"id": "tile_001",
|
|
101
|
+
"properties": {"id": "tile_001"}, # ID should be in properties for resolver
|
|
102
|
+
"geometry": {
|
|
103
|
+
"type": "Polygon",
|
|
104
|
+
"coordinates": [
|
|
105
|
+
[[-122.5, 37.7], [-122.0, 37.7], [-122.0, 38.0], [-122.5, 38.0], [-122.5, 37.7]]
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"type": "Feature",
|
|
111
|
+
"id": "tile_002",
|
|
112
|
+
"properties": {"id": "tile_002"}, # ID should be in properties for resolver
|
|
113
|
+
"geometry": {
|
|
114
|
+
"type": "Polygon",
|
|
115
|
+
"coordinates": [
|
|
116
|
+
[[-121.0, 37.0], [-120.5, 37.0], [-120.5, 37.5], [-121.0, 37.5], [-121.0, 37.0]]
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# Save GeoJSON tiles to a file (as would happen in real usage)
|
|
124
|
+
geojson_file = self.catalog_path / "custom_tiles.geojson"
|
|
125
|
+
with open(geojson_file, "w") as f:
|
|
126
|
+
json.dump(geojson_tiles, f)
|
|
127
|
+
|
|
128
|
+
# Create mock schema with GeoJSON grid (matching schema_generator format)
|
|
129
|
+
geojson_schema = {
|
|
130
|
+
"spatial_partitioning": {
|
|
131
|
+
"grid_system": "geojson",
|
|
132
|
+
"geojson_source": str(geojson_file),
|
|
133
|
+
"custom_tiles": {
|
|
134
|
+
"total_tiles": 2,
|
|
135
|
+
"tile_ids": ["tile_001", "tile_002"],
|
|
136
|
+
"source_file": str(geojson_file),
|
|
137
|
+
},
|
|
138
|
+
"coordinate_system": "WGS84 (EPSG:4326)",
|
|
139
|
+
},
|
|
140
|
+
"global_partitioning": {"enabled": False},
|
|
141
|
+
"temporal_partitioning": {"scheme": "year-month", "levels": ["year", "month"]},
|
|
142
|
+
"partition_structure": {
|
|
143
|
+
"spatial_partitions": ["tile_001", "tile_002"],
|
|
144
|
+
"temporal_partitions": ["2024", "2024-01"],
|
|
145
|
+
"global_partition": False,
|
|
146
|
+
"total_partitions": 4,
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
# Save schema file
|
|
151
|
+
schema_file = self.catalog_path / "geojson_schema.json"
|
|
152
|
+
with open(schema_file, "w") as f:
|
|
153
|
+
json.dump(geojson_schema, f, indent=2)
|
|
154
|
+
|
|
155
|
+
# Test spatial resolution
|
|
156
|
+
resolver = spatial_resolver(str(schema_file), str(self.catalog_path))
|
|
157
|
+
|
|
158
|
+
# Verify GeoJSON resolver initialization
|
|
159
|
+
assert resolver.grid_system == "geojson"
|
|
160
|
+
assert resolver.global_enabled is False
|
|
161
|
+
|
|
162
|
+
# Query area that overlaps with tile_001
|
|
163
|
+
query_area = box(-122.4, 37.75, -122.1, 37.95)
|
|
164
|
+
partitions = resolver.resolve_partitions(query_area)
|
|
165
|
+
|
|
166
|
+
assert "tile_001" in partitions, "Should resolve tile_001 for the query area"
|
|
167
|
+
assert "global" not in partitions, "Global partition should be disabled"
|
|
168
|
+
|
|
169
|
+
# Test query path generation for GeoJSON tiles (create mock directory)
|
|
170
|
+
tile_dir = self.catalog_path / "tile_001"
|
|
171
|
+
tile_dir.mkdir(exist_ok=True)
|
|
172
|
+
|
|
173
|
+
query_paths = resolver.generate_query_paths(["tile_001"], "2024-*")
|
|
174
|
+
assert isinstance(query_paths, list), "Should return list of query paths"
|
|
175
|
+
if len(query_paths) > 0:
|
|
176
|
+
assert any("tile_001" in path for path in query_paths), "Query path should include resolved tile"
|
|
177
|
+
|
|
178
|
+
def test_schema_validation_and_error_handling(self):
|
|
179
|
+
"""Test schema validation and error handling in integration scenarios."""
|
|
180
|
+
|
|
181
|
+
# Test with invalid schema
|
|
182
|
+
invalid_schema = {"spatial_partitioning": {"grid_system": "invalid_grid"}} # Invalid grid system
|
|
183
|
+
|
|
184
|
+
schema_file = self.catalog_path / "invalid_schema.json"
|
|
185
|
+
with open(schema_file, "w") as f:
|
|
186
|
+
json.dump(invalid_schema, f)
|
|
187
|
+
|
|
188
|
+
# Should raise error for invalid grid system
|
|
189
|
+
with pytest.raises(ValueError, match="Unsupported grid system"):
|
|
190
|
+
resolver = spatial_resolver(str(schema_file), str(self.catalog_path))
|
|
191
|
+
resolver.resolve_partitions(box(-122.5, 37.7, -122.0, 38.0))
|
|
192
|
+
|
|
193
|
+
# Test with missing schema file
|
|
194
|
+
with pytest.raises(FileNotFoundError):
|
|
195
|
+
spatial_resolver("/nonexistent/schema.json", str(self.catalog_path))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
# Allow running tests directly
|
|
200
|
+
pytest.main([__file__])
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Integration test for EarthCatalog async HTTP with mocked STAC catalog endpoints.
|
|
3
|
+
|
|
4
|
+
This test validates that the async HTTP implementation works correctly with
|
|
5
|
+
simulated STAC catalog services, ensuring compatibility with real-world data patterns.
|
|
6
|
+
Optimized for fast execution while maintaining comprehensive coverage.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
# mypy: ignore-errors
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import tempfile
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import pandas as pd
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
# Import async HTTP testing utilities
|
|
20
|
+
try:
|
|
21
|
+
import aiohttp
|
|
22
|
+
from aioresponses import aioresponses
|
|
23
|
+
|
|
24
|
+
HAS_ASYNC_TEST_SUPPORT = True
|
|
25
|
+
except ImportError:
|
|
26
|
+
HAS_ASYNC_TEST_SUPPORT = False
|
|
27
|
+
aioresponses = None # type: ignore
|
|
28
|
+
aiohttp = None # type: ignore
|
|
29
|
+
|
|
30
|
+
# Configure test logging
|
|
31
|
+
logging.basicConfig(level=logging.INFO)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture
|
|
35
|
+
def mock_stac_urls() -> list[str]:
|
|
36
|
+
"""Provide a set of STAC catalog URLs for integration testing."""
|
|
37
|
+
return [
|
|
38
|
+
# Microsoft Planetary Computer - Sentinel-2 L2A
|
|
39
|
+
"https://planetarycomputer.microsoft.com/api/stac/v1/collections/sentinel-2-l2a/items?limit=5&bbox=-122.5,37.7,-122.3,37.9",
|
|
40
|
+
# AWS Earth Search - Landsat Collection 2 L2
|
|
41
|
+
"https://earth-search.aws.element84.com/v1/collections/landsat-c2l2-sr/items?limit=5&bbox=-122.5,37.7,-122.3,37.9",
|
|
42
|
+
# ESA Copernicus Data Space - Sentinel-1
|
|
43
|
+
"https://catalogue.dataspace.copernicus.eu/stac/collections/SENTINEL-1/items?limit=5&bbox=-122.5,37.7,-122.3,37.9",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.fixture
|
|
48
|
+
def temp_test_dir():
|
|
49
|
+
"""Create temporary directory for test files."""
|
|
50
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
51
|
+
yield Path(temp_dir)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.fixture
|
|
55
|
+
def sample_stac_response():
|
|
56
|
+
"""Sample STAC FeatureCollection response for mocking."""
|
|
57
|
+
return {
|
|
58
|
+
"type": "FeatureCollection",
|
|
59
|
+
"links": [],
|
|
60
|
+
"features": [
|
|
61
|
+
{
|
|
62
|
+
"id": "S2A_MSIL2A_20241201T123456_N0500_R001_T10SDF_20241201T154321",
|
|
63
|
+
"type": "Feature",
|
|
64
|
+
"geometry": {"type": "Point", "coordinates": [-122.4, 37.8]},
|
|
65
|
+
"properties": {"datetime": "2024-12-01T12:34:56Z", "collection": "sentinel-2-l2a"},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"id": "LC08_L2SP_123456_20241201_20241201_02_T1",
|
|
69
|
+
"type": "Feature",
|
|
70
|
+
"geometry": {"type": "Point", "coordinates": [-122.35, 37.85]},
|
|
71
|
+
"properties": {"datetime": "2024-12-01T12:34:56Z", "collection": "landsat-c2l2-sr"},
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def create_test_input_file(urls: list[str], output_path: Path) -> None:
|
|
78
|
+
"""Create a parquet file with STAC URLs for testing."""
|
|
79
|
+
df = pd.DataFrame(
|
|
80
|
+
{
|
|
81
|
+
"url": urls,
|
|
82
|
+
"collection": [f"test_collection_{i}" for i in range(len(urls))],
|
|
83
|
+
"grid_id": [f"h{i:02d}v{i:02d}" for i in range(len(urls))],
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
df.to_parquet(output_path, index=False)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.mark.skipif(not HAS_ASYNC_TEST_SUPPORT, reason="aioresponses not available")
|
|
90
|
+
@pytest.mark.asyncio
|
|
91
|
+
async def test_async_http_client_with_mock_stac(sample_stac_response):
|
|
92
|
+
"""Test AsyncHTTPClient with mocked STAC catalog endpoints."""
|
|
93
|
+
pytest.importorskip("aiohttp", reason="aiohttp required for async HTTP client")
|
|
94
|
+
|
|
95
|
+
from earthcatalog.async_http_client import AsyncHTTPClient
|
|
96
|
+
|
|
97
|
+
# Test URLs (mocked for fast, reliable testing)
|
|
98
|
+
test_urls = [
|
|
99
|
+
"https://planetarycomputer.microsoft.com/api/stac/v1/collections/sentinel-2-l2a/items?limit=2&bbox=-122.5,37.7,-122.3,37.9",
|
|
100
|
+
"https://earth-search.aws.element84.com/v1/collections/landsat-c2l2-sr/items?limit=2&bbox=-122.5,37.7,-122.3,37.9",
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
with aioresponses() as mock_responses:
|
|
104
|
+
# Mock all URLs with successful STAC responses
|
|
105
|
+
for url in test_urls:
|
|
106
|
+
mock_responses.get(url, payload=sample_stac_response)
|
|
107
|
+
|
|
108
|
+
async with AsyncHTTPClient(concurrent_requests=2, request_timeout=5) as client:
|
|
109
|
+
# Use download_batch method which returns RequestResult objects
|
|
110
|
+
results = await client.download_batch(test_urls)
|
|
111
|
+
|
|
112
|
+
# Validate results
|
|
113
|
+
assert len(results) == len(test_urls), "Should receive result for each URL"
|
|
114
|
+
|
|
115
|
+
successful_results = [r for r in results if r.success and r.data]
|
|
116
|
+
assert len(successful_results) == len(test_urls), "All requests should succeed with mocked responses"
|
|
117
|
+
|
|
118
|
+
for i, result in enumerate(successful_results):
|
|
119
|
+
assert result.data is not None, f"Result {i} should have data"
|
|
120
|
+
|
|
121
|
+
# Validate STAC response structure (data is already parsed)
|
|
122
|
+
data = result.data
|
|
123
|
+
assert "type" in data, "STAC response should have 'type' field"
|
|
124
|
+
assert "features" in data, "STAC response should have 'features' field"
|
|
125
|
+
assert isinstance(data["features"], list), "Features should be a list"
|
|
126
|
+
assert len(data["features"]) == 2, "Should have 2 STAC items as mocked"
|
|
127
|
+
|
|
128
|
+
logging.info(f"URL {i}: Retrieved {len(data['features'])} STAC items")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@pytest.mark.skipif(not HAS_ASYNC_TEST_SUPPORT, reason="aioresponses not available")
|
|
132
|
+
@pytest.mark.asyncio
|
|
133
|
+
async def test_batch_downloader_with_mock_stac(sample_stac_response):
|
|
134
|
+
"""Test BatchDownloader with mocked STAC catalog endpoints."""
|
|
135
|
+
pytest.importorskip("aiohttp", reason="aiohttp required for async HTTP client")
|
|
136
|
+
|
|
137
|
+
from earthcatalog.async_http_client import BatchDownloader
|
|
138
|
+
|
|
139
|
+
# Use mocked endpoints for fast testing
|
|
140
|
+
test_urls = [
|
|
141
|
+
"https://planetarycomputer.microsoft.com/api/stac/v1/collections/sentinel-2-l2a/items?limit=1&bbox=-122.4,37.8,-122.35,37.85",
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
with aioresponses() as mock_responses:
|
|
145
|
+
# Mock with STAC response
|
|
146
|
+
mock_responses.get(test_urls[0], payload=sample_stac_response)
|
|
147
|
+
|
|
148
|
+
downloader = BatchDownloader(concurrent_requests=1, batch_size=1, request_timeout=5, retry_attempts=2)
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
results = await downloader.download_all(test_urls)
|
|
152
|
+
|
|
153
|
+
# Validate results (download_all returns list of responses, not individual items)
|
|
154
|
+
assert len(results) == 1, "Should receive 1 response from mocked endpoint"
|
|
155
|
+
|
|
156
|
+
# The response is a FeatureCollection
|
|
157
|
+
response = results[0]
|
|
158
|
+
assert response is not None, "Response should not be None"
|
|
159
|
+
assert isinstance(response, dict), "Response should be dict"
|
|
160
|
+
|
|
161
|
+
# Validate STAC FeatureCollection structure
|
|
162
|
+
assert "type" in response, "Should have STAC 'type' field"
|
|
163
|
+
assert response["type"] == "FeatureCollection", "Should be a FeatureCollection"
|
|
164
|
+
assert "features" in response, "Should have 'features' field"
|
|
165
|
+
assert len(response["features"]) == 2, "Should have 2 features in the collection"
|
|
166
|
+
|
|
167
|
+
logging.info(f"Batch download: Retrieved FeatureCollection with {len(response['features'])} features")
|
|
168
|
+
|
|
169
|
+
finally:
|
|
170
|
+
await downloader.close()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_processing_config_async_defaults():
|
|
174
|
+
"""Test that ProcessingConfig has correct async defaults."""
|
|
175
|
+
from earthcatalog.ingestion_pipeline import ProcessingConfig
|
|
176
|
+
|
|
177
|
+
config = ProcessingConfig(input_file="test.parquet", output_catalog="./output", scratch_location="./scratch")
|
|
178
|
+
|
|
179
|
+
# Validate async HTTP is enabled by default
|
|
180
|
+
assert config.enable_concurrent_http is True, "Async HTTP should be enabled by default"
|
|
181
|
+
assert config.concurrent_requests == 50, "Default concurrent requests should be 50"
|
|
182
|
+
assert config.batch_size == 1000, "Default batch size should be 1000"
|
|
183
|
+
assert config.request_timeout == 30, "Default timeout should be 30 seconds"
|
|
184
|
+
assert config.retry_attempts == 3, "Default retry attempts should be 3"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@pytest.mark.asyncio
|
|
188
|
+
async def test_end_to_end_async_integration(mock_stac_urls, temp_test_dir):
|
|
189
|
+
"""End-to-end integration test with mocked STAC URLs."""
|
|
190
|
+
pytest.importorskip("aiohttp", reason="aiohttp required for async processing")
|
|
191
|
+
|
|
192
|
+
from earthcatalog.ingestion_pipeline import LocalProcessor, ProcessingConfig, STACIngestionPipeline
|
|
193
|
+
|
|
194
|
+
# Setup test files
|
|
195
|
+
input_file = temp_test_dir / "test_stac_urls.parquet"
|
|
196
|
+
output_catalog = temp_test_dir / "output_catalog"
|
|
197
|
+
scratch_location = temp_test_dir / "scratch"
|
|
198
|
+
|
|
199
|
+
# Use subset of URLs for faster testing
|
|
200
|
+
test_urls = mock_stac_urls[:2] # Only test first 2 URLs
|
|
201
|
+
create_test_input_file(test_urls, input_file)
|
|
202
|
+
|
|
203
|
+
# Configure for async processing
|
|
204
|
+
config = ProcessingConfig(
|
|
205
|
+
input_file=str(input_file),
|
|
206
|
+
output_catalog=str(output_catalog),
|
|
207
|
+
scratch_location=str(scratch_location),
|
|
208
|
+
enable_concurrent_http=True,
|
|
209
|
+
concurrent_requests=2, # Conservative for testing
|
|
210
|
+
batch_size=10,
|
|
211
|
+
request_timeout=5, # Short timeout for fast testing
|
|
212
|
+
retry_attempts=1, # Minimal retries for speed
|
|
213
|
+
grid_system="h3",
|
|
214
|
+
grid_resolution=2,
|
|
215
|
+
temporal_bin="year",
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Create processor and pipeline
|
|
219
|
+
processor = LocalProcessor(n_workers=1) # Single worker for testing
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
pipeline = STACIngestionPipeline(config, processor)
|
|
223
|
+
|
|
224
|
+
# Just test that the pipeline initializes correctly with async config
|
|
225
|
+
# Running the full pipeline with STAC catalog URLs is complex for unit testing
|
|
226
|
+
# since they return FeatureCollections, not individual STAC items
|
|
227
|
+
assert pipeline.config.enable_concurrent_http is True
|
|
228
|
+
assert pipeline.config.concurrent_requests == 2
|
|
229
|
+
assert pipeline.config.request_timeout == 5
|
|
230
|
+
|
|
231
|
+
# Test that the async HTTP client is detected
|
|
232
|
+
from earthcatalog.async_http_client import HAS_ASYNC_HTTP
|
|
233
|
+
|
|
234
|
+
if HAS_ASYNC_HTTP:
|
|
235
|
+
logging.info("End-to-end async integration: Pipeline configured correctly with async HTTP")
|
|
236
|
+
else:
|
|
237
|
+
logging.info("End-to-end async integration: Pipeline configured for sync fallback")
|
|
238
|
+
|
|
239
|
+
except (ValueError, TypeError, AttributeError, ImportError, RuntimeError) as e:
|
|
240
|
+
pytest.skip(f"Pipeline initialization failed: {e}")
|
|
241
|
+
finally:
|
|
242
|
+
processor.close()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@pytest.mark.parametrize("concurrent_requests", [1, 5, 10])
|
|
246
|
+
@pytest.mark.skipif(not HAS_ASYNC_TEST_SUPPORT, reason="aioresponses not available")
|
|
247
|
+
@pytest.mark.asyncio
|
|
248
|
+
async def test_concurrent_requests_scaling(concurrent_requests, sample_stac_response):
|
|
249
|
+
"""Test async performance scales with concurrent requests using mocked responses."""
|
|
250
|
+
pytest.importorskip("aiohttp", reason="aiohttp required for async HTTP client")
|
|
251
|
+
|
|
252
|
+
from earthcatalog.async_http_client import AsyncHTTPClient
|
|
253
|
+
|
|
254
|
+
# Use mocked responses for fast, reliable testing
|
|
255
|
+
test_urls = [f"https://example.com/stac/item_{i}.json" for i in range(5)]
|
|
256
|
+
|
|
257
|
+
start_time = time.time()
|
|
258
|
+
|
|
259
|
+
with aioresponses() as mock_responses:
|
|
260
|
+
# Mock all URLs with successful responses
|
|
261
|
+
for url in test_urls:
|
|
262
|
+
mock_responses.get(url, payload=sample_stac_response)
|
|
263
|
+
|
|
264
|
+
async with AsyncHTTPClient(concurrent_requests=concurrent_requests, request_timeout=5) as client:
|
|
265
|
+
results = await client.download_batch(test_urls)
|
|
266
|
+
|
|
267
|
+
total_time = time.time() - start_time
|
|
268
|
+
success_count = sum(1 for r in results if r.success)
|
|
269
|
+
|
|
270
|
+
# Validate performance characteristics
|
|
271
|
+
assert success_count == len(test_urls), "All mocked requests should succeed"
|
|
272
|
+
|
|
273
|
+
# With mocked responses, this should be very fast (under 1 second)
|
|
274
|
+
assert total_time < 2.0, f"Mocked requests should be fast: {total_time:.1f}s"
|
|
275
|
+
|
|
276
|
+
logging.info(
|
|
277
|
+
f"Concurrent requests {concurrent_requests}: {success_count}/{len(test_urls)} success in {total_time:.2f}s"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
if __name__ == "__main__":
|
|
282
|
+
# Run integration tests directly
|
|
283
|
+
pytest.main([__file__, "-v", "-s"])
|