aereo 1.1.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.
@@ -0,0 +1,17 @@
1
+ """
2
+ Safe asset downloading and cleanup routines
3
+ """
4
+
5
+ from aereo.asset_downloader.core import (
6
+ cleanup_asset_safely,
7
+ download_asset_safely,
8
+ download_assets_safely,
9
+ extract_asset_safely,
10
+ )
11
+
12
+ __all__ = [
13
+ "cleanup_asset_safely",
14
+ "download_asset_safely",
15
+ "download_assets_safely",
16
+ "extract_asset_safely",
17
+ ]
@@ -0,0 +1,194 @@
1
+ import shutil
2
+ from concurrent.futures import ThreadPoolExecutor
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING, Any, Optional
5
+
6
+ if TYPE_CHECKING:
7
+ from aereo.interfaces import Downloader
8
+
9
+
10
+ def download_asset_safely(
11
+ href: str,
12
+ local_path: Path,
13
+ s3_client: Optional[Any] = None,
14
+ http_session: Optional[Any] = None,
15
+ downloader: Optional["Downloader"] = None,
16
+ ) -> None:
17
+ """Download asset with a filelock to avoid corruption in multi-processing.
18
+
19
+ Args:
20
+ href: URL or local path to the asset.
21
+ local_path: Destination path for the downloaded file.
22
+ s3_client: Optional authenticated S3FileSystem for S3 access.
23
+ Note: Only works when running in AWS us-west-2 region.
24
+ http_session: Optional authenticated requests.Session for HTTPS downloads.
25
+ Use earthaccess.get_requests_https_session() to create.
26
+ Works from anywhere (no AWS region requirement).
27
+ downloader: Optional callable that handles the download itself.
28
+ If provided, it is called unconditionally inside the file lock
29
+ and all built-in logic is skipped.
30
+ """
31
+ import filelock
32
+ import s3fs
33
+ import requests
34
+
35
+ local_path.parent.mkdir(parents=True, exist_ok=True)
36
+ lock_path = local_path.with_suffix(".lock")
37
+
38
+ with filelock.FileLock(str(lock_path)):
39
+ if not local_path.exists():
40
+ if downloader is not None:
41
+ downloader(href, local_path)
42
+ elif href.startswith("s3://"):
43
+ fs = (
44
+ s3_client if s3_client is not None else s3fs.S3FileSystem(anon=True)
45
+ )
46
+ fs.get(href.replace("s3://", ""), str(local_path))
47
+ elif href.startswith("http://") or href.startswith("https://"):
48
+ http = http_session if http_session is not None else requests
49
+ response = http.get(href, stream=True)
50
+ response.raise_for_status()
51
+ with open(local_path, "wb") as f:
52
+ for chunk in response.iter_content(chunk_size=8192):
53
+ f.write(chunk)
54
+ elif Path(href).exists():
55
+ if Path(href).absolute() != local_path.absolute():
56
+ shutil.copy(href, local_path)
57
+ else:
58
+ raise FileNotFoundError(f"Source file not found at {href}")
59
+
60
+
61
+ def download_assets_safely(
62
+ hrefs: list[str],
63
+ local_paths: list[Path],
64
+ s3_client: Optional[Any] = None,
65
+ http_session: Optional[Any] = None,
66
+ downloader: Optional["Downloader"] = None,
67
+ max_workers: Optional[int] = None,
68
+ ) -> None:
69
+ """Download multiple assets concurrently using a thread pool.
70
+
71
+ Args:
72
+ hrefs: List of URLs or local paths to the assets.
73
+ local_paths: List of destination paths for the downloaded files.
74
+ s3_client: Optional authenticated S3FileSystem for S3 access.
75
+ http_session: Optional authenticated requests.Session for HTTPS downloads.
76
+ downloader: Optional callable that handles the download itself.
77
+ max_workers: The maximum number of threads to use. Defaults to None.
78
+ """
79
+ if len(hrefs) != len(local_paths):
80
+ raise ValueError("hrefs and local_paths must have the same length")
81
+
82
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
83
+ futures = [
84
+ executor.submit(
85
+ download_asset_safely,
86
+ href=href,
87
+ local_path=local_path,
88
+ s3_client=s3_client,
89
+ http_session=http_session,
90
+ downloader=downloader,
91
+ )
92
+ for href, local_path in zip(hrefs, local_paths, strict=True)
93
+ ]
94
+ for future in futures:
95
+ future.result()
96
+
97
+
98
+ def extract_asset_safely(
99
+ archive_path: Path,
100
+ extract_dir: Optional[Path] = None,
101
+ lock_path: Optional[Path] = None,
102
+ ) -> None:
103
+ """Extract a zip archive safely with file locking.
104
+
105
+ Uses atomic extraction (temp directory + rename) so that other
106
+ processes never see a partially extracted directory. An
107
+ ``.extracted`` marker file is written on success; if the marker
108
+ exists the function returns immediately.
109
+
110
+ Args:
111
+ archive_path: Path to the zip archive.
112
+ extract_dir: Destination directory. Defaults to
113
+ ``archive_path.with_suffix("")``.
114
+ lock_path: Path to the lock file. Defaults to
115
+ ``extract_dir.with_suffix(".lock")``.
116
+ """
117
+ import filelock
118
+ import shutil
119
+ import tempfile
120
+ import zipfile
121
+
122
+ archive_path = Path(archive_path)
123
+ if extract_dir is None:
124
+ extract_dir = archive_path.with_suffix("")
125
+ extract_dir = Path(extract_dir)
126
+
127
+ if lock_path is None:
128
+ lock_path = extract_dir.with_suffix(".lock")
129
+ lock_path = Path(lock_path)
130
+
131
+ marker_path = extract_dir.with_suffix(".extracted")
132
+
133
+ with filelock.FileLock(str(lock_path)):
134
+ # Already extracted and marked complete
135
+ if marker_path.exists() and extract_dir.exists():
136
+ return
137
+
138
+ # Remove any stale partial extraction
139
+ if extract_dir.exists():
140
+ shutil.rmtree(extract_dir, ignore_errors=True)
141
+
142
+ extract_dir.parent.mkdir(parents=True, exist_ok=True)
143
+
144
+ # Extract to a temporary directory so other processes never
145
+ # see an incomplete destination.
146
+ temp_dir = tempfile.mkdtemp(
147
+ prefix=extract_dir.name + "_tmp_",
148
+ dir=extract_dir.parent,
149
+ )
150
+ try:
151
+ with zipfile.ZipFile(archive_path, "r") as zf:
152
+ zf.extractall(temp_dir)
153
+
154
+ Path(temp_dir).rename(extract_dir)
155
+ except Exception:
156
+ shutil.rmtree(temp_dir, ignore_errors=True)
157
+ raise
158
+
159
+ marker_path.touch()
160
+
161
+
162
+ def cleanup_asset_safely(
163
+ local_path: Path, chunk_id: Optional[int] = None, total_chunks: int = 1
164
+ ) -> None:
165
+ """Safely clean up the downloaded asset after all chunks are processed."""
166
+ import filelock
167
+
168
+ lock_path = local_path.with_suffix(".lock")
169
+ if total_chunks > 1 and chunk_id is not None:
170
+ done_file = local_path.with_suffix(f".chunk_{chunk_id}.done")
171
+ done_file.touch()
172
+ with filelock.FileLock(str(lock_path)):
173
+ done_files = list(local_path.parent.glob(f"{local_path.stem}.chunk_*.done"))
174
+ if len(done_files) >= total_chunks:
175
+ if local_path.exists():
176
+ try:
177
+ local_path.unlink()
178
+ except Exception:
179
+ pass
180
+ for df in done_files:
181
+ try:
182
+ df.unlink()
183
+ except Exception:
184
+ pass
185
+ try:
186
+ lock_path.unlink()
187
+ except Exception:
188
+ pass
189
+ else:
190
+ if local_path.exists():
191
+ try:
192
+ local_path.unlink()
193
+ except Exception:
194
+ pass
aereo/cli/__init__.py ADDED
File without changes
aereo/cli/main.py ADDED
@@ -0,0 +1,491 @@
1
+ """AER CLI — command-line interface for the AER satellite data framework."""
2
+
3
+ # ruff: noqa: E402
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import pickle
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Annotated, Any, Optional
11
+
12
+ import geopandas as gpd
13
+ import typer
14
+ from rich.console import Console
15
+ from rich.table import Table
16
+
17
+ from aereo.client import AereoClient
18
+ from aereo.execution import LocalProcessBackend
19
+ from aereo.interfaces import AereoProfile, GridConfig
20
+ from aereo.schemas import AssetSchema
21
+
22
+ app = typer.Typer(
23
+ name="aereo",
24
+ help="AER — Modular satellite data discovery, extraction, and processing",
25
+ no_args_is_help=True,
26
+ )
27
+ console = Console()
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Helpers
31
+ # ---------------------------------------------------------------------------
32
+
33
+
34
+ def _load_geometry(geojson_path: Path) -> Optional[dict[str, Any]]:
35
+ """Load a GeoJSON file and return the geometry dict."""
36
+ data = json.loads(geojson_path.read_text())
37
+ if data.get("type") == "FeatureCollection":
38
+ if not data.get("features"):
39
+ raise ValueError("GeoJSON FeatureCollection has no features.")
40
+ return data["features"][0]["geometry"]
41
+ elif data.get("type") == "Feature":
42
+ return data["geometry"]
43
+ elif "type" in data and data["type"] in (
44
+ "Point",
45
+ "MultiPoint",
46
+ "LineString",
47
+ "MultiLineString",
48
+ "Polygon",
49
+ "MultiPolygon",
50
+ "GeometryCollection",
51
+ ):
52
+ return data
53
+ raise ValueError("Could not extract geometry from GeoJSON.")
54
+
55
+
56
+ def _search_results_to_json(df: gpd.GeoDataFrame) -> list[dict[str, Any]]:
57
+ """Convert search results GeoDataFrame to JSON-serializable records."""
58
+ # Convert to GeoJSON feature collection then to simple records
59
+ df = df.copy()
60
+ df["geometry"] = df.geometry.apply(
61
+ lambda g: g.__geo_interface__ if g is not None else None
62
+ )
63
+ records = df.to_dict(orient="records")
64
+ # Convert datetime to ISO strings
65
+ for rec in records:
66
+ for key in ("start_time", "end_time"):
67
+ if key in rec and rec[key] is not None:
68
+ rec[key] = (
69
+ rec[key].isoformat() if hasattr(rec[key], "isoformat") else rec[key]
70
+ )
71
+ return records
72
+
73
+
74
+ def _search_results_from_json(records: list[dict[str, Any]]) -> Any:
75
+ """Reconstruct a GeoDataFrame from JSON records."""
76
+ df = gpd.GeoDataFrame.from_records(records)
77
+ if "geometry" in df.columns:
78
+ from shapely.geometry import shape
79
+
80
+ def _to_geom(g: Any) -> Any:
81
+ if isinstance(g, dict):
82
+ return shape(g)
83
+ return g
84
+
85
+ df["geometry"] = gpd.GeoSeries(df["geometry"].apply(_to_geom))
86
+ df = gpd.GeoDataFrame(df, geometry="geometry")
87
+ df.set_crs(epsg=4326, inplace=True)
88
+ for key in ("start_time", "end_time"):
89
+ if key in df.columns:
90
+ df[key] = gpd.pd.to_datetime(df[key])
91
+ return gpd.GeoDataFrame(AssetSchema.validate(df))
92
+
93
+
94
+ def _print_search_table(df: gpd.GeoDataFrame) -> None:
95
+ """Pretty-print search results as a Rich table."""
96
+ table = Table(title=f"Search Results ({len(df)} scenes)")
97
+ table.add_column("ID", style="cyan", no_wrap=True)
98
+ table.add_column("Collection", style="magenta")
99
+ table.add_column("Start Time", style="green")
100
+ table.add_column("End Time", style="green")
101
+ table.add_column("Href", style="blue", overflow="fold")
102
+
103
+ for _, row in df.head(50).iterrows():
104
+ table.add_row(
105
+ str(row.get("id", "")),
106
+ str(row.get("collection", "")),
107
+ str(row.get("start_time", "")),
108
+ str(row.get("end_time", "")),
109
+ str(row.get("href", ""))[:60] + "...",
110
+ )
111
+ if len(df) > 50:
112
+ table.add_row("...", f"... and {len(df) - 50} more rows", "", "", "")
113
+ console.print(table)
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Commands
118
+ # ---------------------------------------------------------------------------
119
+
120
+
121
+ @app.command()
122
+ def search(
123
+ profile: Annotated[
124
+ list[Path],
125
+ typer.Option("--profile", "-p", help="Path to profile YAML (repeatable)"),
126
+ ],
127
+ config: Annotated[
128
+ Optional[Path], typer.Option("--config", "-c", help="Path to grid config YAML")
129
+ ] = None,
130
+ geojson: Annotated[
131
+ Optional[Path], typer.Option("--geojson", "-g", help="Path to AOI GeoJSON file")
132
+ ] = None,
133
+ start: Annotated[
134
+ Optional[str], typer.Option("--start", "-s", help="Start datetime (ISO 8601)")
135
+ ] = None,
136
+ end: Annotated[
137
+ Optional[str], typer.Option("--end", "-e", help="End datetime (ISO 8601)")
138
+ ] = None,
139
+ output: Annotated[
140
+ Optional[Path],
141
+ typer.Option("--output", "-o", help="Output JSON file for search results"),
142
+ ] = None,
143
+ fmt: Annotated[
144
+ str, typer.Option("--format", help="Output format: table or json")
145
+ ] = "table",
146
+ verbose: Annotated[
147
+ bool, typer.Option("--verbose", "-v", help="Enable verbose logging")
148
+ ] = False,
149
+ ) -> None:
150
+ """Search for satellite data across configured profiles."""
151
+ if verbose:
152
+ import structlog
153
+
154
+ structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(10))
155
+
156
+ # Load profiles
157
+ profiles: list[AereoProfile] = []
158
+ for p in profile:
159
+ if not p.exists():
160
+ console.print(f"[red]Profile not found:[/red] {p}")
161
+ raise typer.Exit(code=1)
162
+ profiles.extend(AereoProfile.from_yaml(p))
163
+
164
+ # Load geometry
165
+ intersects = _load_geometry(geojson) if geojson and geojson.exists() else None
166
+
167
+ # Parse datetimes
168
+ start_dt = datetime.fromisoformat(start) if start else None
169
+ end_dt = datetime.fromisoformat(end) if end else None
170
+
171
+ client = AereoClient()
172
+ try:
173
+ results = client.search(
174
+ profiles=profiles,
175
+ intersects=intersects,
176
+ start_datetime=start_dt,
177
+ end_datetime=end_dt,
178
+ )
179
+ except Exception as exc:
180
+ console.print(f"[red]Search failed:[/red] {exc}")
181
+ raise typer.Exit(code=1)
182
+
183
+ if results is None or len(results) == 0:
184
+ console.print("[yellow]No results found.[/yellow]")
185
+ raise typer.Exit(code=2)
186
+
187
+ if fmt == "json":
188
+ records = _search_results_to_json(results)
189
+ json_out = json.dumps(records, indent=2, default=str)
190
+ if output:
191
+ output.write_text(json_out)
192
+ console.print(f"[green]Wrote {len(records)} results to[/green] {output}")
193
+ else:
194
+ console.print(json_out)
195
+ else:
196
+ _print_search_table(results)
197
+ if output:
198
+ records = _search_results_to_json(results)
199
+ output.write_text(json.dumps(records, indent=2, default=str))
200
+ console.print(f"[green]Wrote results to[/green] {output}")
201
+
202
+
203
+ @app.command()
204
+ def prepare(
205
+ search_results: Annotated[Path, typer.Argument(help="Path to search results JSON")],
206
+ profile: Annotated[
207
+ list[Path],
208
+ typer.Option("--profile", "-p", help="Path to profile YAML (repeatable)"),
209
+ ],
210
+ config: Annotated[
211
+ Optional[Path], typer.Option("--config", "-c", help="Path to grid config YAML")
212
+ ] = None,
213
+ output_dir: Annotated[
214
+ Path, typer.Option("--output-dir", "-d", help="Output directory for extraction")
215
+ ] = Path("./out"),
216
+ output: Annotated[
217
+ Optional[Path],
218
+ typer.Option("--output", "-o", help="Output pickle file for tasks"),
219
+ ] = None,
220
+ cells_per_chunk: Annotated[
221
+ int, typer.Option("--cells-per-chunk", help="Max grid cells per task")
222
+ ] = 50,
223
+ verbose: Annotated[
224
+ bool, typer.Option("--verbose", "-v", help="Enable verbose logging")
225
+ ] = False,
226
+ ) -> None:
227
+ """Prepare search results for extraction."""
228
+ if verbose:
229
+ import structlog
230
+
231
+ structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(10))
232
+
233
+ if not search_results.exists():
234
+ console.print(f"[red]Search results not found:[/red] {search_results}")
235
+ raise typer.Exit(code=1)
236
+
237
+ records = json.loads(search_results.read_text())
238
+ df = _search_results_from_json(records)
239
+
240
+ profiles: list[AereoProfile] = []
241
+ for p in profile:
242
+ if not p.exists():
243
+ console.print(f"[red]Profile not found:[/red] {p}")
244
+ raise typer.Exit(code=1)
245
+ profiles.extend(AereoProfile.from_yaml(p))
246
+
247
+ grid_config = (
248
+ GridConfig.from_yaml(config) if config and config.exists() else GridConfig()
249
+ )
250
+
251
+ output_dir.mkdir(parents=True, exist_ok=True)
252
+
253
+ client = AereoClient()
254
+ try:
255
+ tasks = client.prepare_for_extraction(
256
+ search_results=df, # type: ignore[arg-type]
257
+ grid_config=grid_config,
258
+ profiles=profiles,
259
+ uri=str(output_dir),
260
+ cells_per_chunk=cells_per_chunk,
261
+ )
262
+ except Exception as exc:
263
+ console.print(f"[red]Prepare failed:[/red] {exc}")
264
+ raise typer.Exit(code=1)
265
+
266
+ task_file = output or (output_dir / "tasks.pkl")
267
+ task_file.write_bytes(pickle.dumps(tasks))
268
+ console.print(
269
+ f"[green]✓ Prepared {len(tasks)} tasks (chunk size: {cells_per_chunk}).[/green]"
270
+ )
271
+ console.print(f"[green]Wrote tasks to[/green] {task_file}")
272
+
273
+
274
+ @app.command()
275
+ def extract(
276
+ tasks: Annotated[Path, typer.Argument(help="Path to prepared tasks pickle file")],
277
+ output_dir: Annotated[
278
+ Path, typer.Option("--output-dir", "-d", help="Output directory")
279
+ ] = Path("./out"),
280
+ workers: Annotated[
281
+ int, typer.Option("--workers", "-w", help="Max batch workers")
282
+ ] = 1,
283
+ verbose: Annotated[
284
+ bool, typer.Option("--verbose", "-v", help="Enable verbose logging")
285
+ ] = False,
286
+ ) -> None:
287
+ """Run extraction on prepared tasks."""
288
+ if verbose:
289
+ import structlog
290
+
291
+ structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(10))
292
+
293
+ if not tasks.exists():
294
+ console.print(f"[red]Tasks file not found:[/red] {tasks}")
295
+ raise typer.Exit(code=1)
296
+
297
+ task_list = pickle.loads(tasks.read_bytes())
298
+
299
+ backend = LocalProcessBackend(max_workers=workers)
300
+ client = AereoClient()
301
+ try:
302
+ artifacts = client.execute_tasks(task_list, backend=backend)
303
+ except Exception as exc:
304
+ console.print(f"[red]Extraction failed:[/red] {exc}")
305
+ raise typer.Exit(code=1)
306
+
307
+ # Write full GeoDataFrame to parquet
308
+ parquet_path = output_dir / "artifacts.parquet"
309
+ artifacts.to_parquet(parquet_path)
310
+
311
+ console.print(f"[green]✓ Extracted {len(artifacts)} artifacts.[/green]")
312
+ console.print(f"[green]Parquet saved to:[/green] {parquet_path}")
313
+ console.print(f"[green]Output directory:[/green] {output_dir}")
314
+
315
+
316
+ @app.command()
317
+ def run(
318
+ profile: Annotated[
319
+ list[Path],
320
+ typer.Option("--profile", "-p", help="Path to profile YAML (repeatable)"),
321
+ ],
322
+ config: Annotated[
323
+ Optional[Path], typer.Option("--config", "-c", help="Path to grid config YAML")
324
+ ] = None,
325
+ geojson: Annotated[
326
+ Optional[Path], typer.Option("--geojson", "-g", help="Path to AOI GeoJSON file")
327
+ ] = None,
328
+ start: Annotated[
329
+ Optional[str], typer.Option("--start", "-s", help="Start datetime (ISO 8601)")
330
+ ] = None,
331
+ end: Annotated[
332
+ Optional[str], typer.Option("--end", "-e", help="End datetime (ISO 8601)")
333
+ ] = None,
334
+ output_dir: Annotated[
335
+ Path, typer.Option("--output-dir", "-d", help="Output directory")
336
+ ] = Path("./out"),
337
+ workers: Annotated[
338
+ int, typer.Option("--workers", "-w", help="Max batch workers")
339
+ ] = 1,
340
+ cells_per_chunk: Annotated[
341
+ int, typer.Option("--cells-per-chunk", help="Max grid cells per task")
342
+ ] = 50,
343
+ verbose: Annotated[
344
+ bool, typer.Option("--verbose", "-v", help="Enable verbose logging")
345
+ ] = False,
346
+ ) -> None:
347
+ """One-shot: search → prepare → extract."""
348
+ if verbose:
349
+ import structlog
350
+
351
+ structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(10))
352
+
353
+ # Load profiles
354
+ profiles: list[AereoProfile] = []
355
+ for p in profile:
356
+ if not p.exists():
357
+ console.print(f"[red]Profile not found:[/red] {p}")
358
+ raise typer.Exit(code=1)
359
+ profiles.extend(AereoProfile.from_yaml(p))
360
+
361
+ grid_config = (
362
+ GridConfig.from_yaml(config) if config and config.exists() else GridConfig()
363
+ )
364
+ intersects = _load_geometry(geojson) if geojson and geojson.exists() else None
365
+ start_dt = datetime.fromisoformat(start) if start else None
366
+ end_dt = datetime.fromisoformat(end) if end else None
367
+
368
+ output_dir.mkdir(parents=True, exist_ok=True)
369
+ client = AereoClient()
370
+
371
+ # Search
372
+ console.print("[bold blue]🔍 Searching...[/bold blue]")
373
+ try:
374
+ results = client.search(
375
+ profiles=profiles,
376
+ intersects=intersects,
377
+ start_datetime=start_dt,
378
+ end_datetime=end_dt,
379
+ )
380
+ except Exception as exc:
381
+ console.print(f"[red]Search failed:[/red] {exc}")
382
+ raise typer.Exit(code=1)
383
+
384
+ if results is None or len(results) == 0:
385
+ console.print("[yellow]No results found.[/yellow]")
386
+ raise typer.Exit(code=2)
387
+ console.print(f"[green]✓ Found {len(results)} scenes.[/green]")
388
+
389
+ # Prepare
390
+ console.print("[bold blue]📦 Preparing...[/bold blue]")
391
+ try:
392
+ tasks = client.prepare_for_extraction(
393
+ search_results=results,
394
+ grid_config=grid_config,
395
+ profiles=profiles,
396
+ uri=str(output_dir),
397
+ cells_per_chunk=cells_per_chunk,
398
+ )
399
+ except Exception as exc:
400
+ console.print(f"[red]Prepare failed:[/red] {exc}")
401
+ raise typer.Exit(code=1)
402
+ console.print(
403
+ f"[green]✓ Prepared {len(tasks)} tasks (chunk size: {cells_per_chunk}).[/green]"
404
+ )
405
+
406
+ # Extract
407
+ console.print("[bold blue]⛏️ Extracting...[/bold blue]")
408
+ backend = LocalProcessBackend(max_workers=workers)
409
+ try:
410
+ artifacts = client.execute_tasks(tasks, backend=backend)
411
+ except Exception as exc:
412
+ console.print(f"[red]Extraction failed:[/red] {exc}")
413
+ raise typer.Exit(code=1)
414
+
415
+ parquet_path = output_dir / "artifacts.parquet"
416
+ artifacts.to_parquet(parquet_path)
417
+
418
+ console.print(f"[green]✓ Extracted {len(artifacts)} artifacts.[/green]")
419
+ console.print(f"[green]Parquet saved to:[/green] {parquet_path}")
420
+ console.print(f"[green]Output:[/green] {output_dir}")
421
+
422
+
423
+ @app.command()
424
+ def validate(
425
+ config: Annotated[
426
+ Optional[Path],
427
+ typer.Option("--config", "-c", help="Path to config YAML to validate"),
428
+ ] = None,
429
+ profile: Annotated[
430
+ Optional[Path],
431
+ typer.Option("--profile", "-p", help="Path to profile YAML to validate"),
432
+ ] = None,
433
+ ) -> None:
434
+ """Validate a config or profile YAML against AER schemas."""
435
+ if config:
436
+ if not config.exists():
437
+ console.print(f"[red]Config not found:[/red] {config}")
438
+ raise typer.Exit(code=1)
439
+ try:
440
+ GridConfig.from_yaml(config)
441
+ console.print(f"[green]✓ Config valid:[/green] {config}")
442
+ except Exception as exc:
443
+ console.print(f"[red]✗ Config invalid:[/red] {config}\n{exc}")
444
+ raise typer.Exit(code=1)
445
+
446
+ if profile:
447
+ if not profile.exists():
448
+ console.print(f"[red]Profile not found:[/red] {profile}")
449
+ raise typer.Exit(code=1)
450
+ try:
451
+ AereoProfile.from_yaml(profile)
452
+ console.print(f"[green]✓ Profile valid:[/green] {profile}")
453
+ except Exception as exc:
454
+ console.print(f"[red]✗ Profile invalid:[/red] {profile}\n{exc}")
455
+ raise typer.Exit(code=1)
456
+
457
+ if not config and not profile:
458
+ console.print("[yellow]Provide --config or --profile to validate.[/yellow]")
459
+ raise typer.Exit(code=1)
460
+
461
+
462
+ @app.command()
463
+ def plugins() -> None:
464
+ """List installed AER plugins."""
465
+ from aereo.registry import AereoRegistry
466
+
467
+ registry = AereoRegistry()
468
+
469
+ table = Table(title="Installed AER Plugins")
470
+ table.add_column("Type", style="cyan")
471
+ table.add_column("Name", style="magenta")
472
+ table.add_column("Collections", style="green")
473
+
474
+ for name, cls in registry._searchers.items():
475
+ cols = ", ".join(cls.supported_collections[:3])
476
+ if len(cls.supported_collections) > 3:
477
+ cols += " ..."
478
+ table.add_row("Searcher", name, cols)
479
+
480
+ for name, cls in registry._extractors.items():
481
+ cols = ", ".join(cls.supported_collections[:3])
482
+ if len(cls.supported_collections) > 3:
483
+ cols += " ..."
484
+ table.add_row("Extractor", name, cols)
485
+
486
+ console.print(table)
487
+
488
+
489
+ # Entry point for `python -m aer.cli`
490
+ if __name__ == "__main__":
491
+ app()