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,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"])