diff --git a/Start-SentinelUI.ps1 b/Start-SentinelUI.ps1 index f39c1b1..40fd212 100644 --- a/Start-SentinelUI.ps1 +++ b/Start-SentinelUI.ps1 @@ -71,6 +71,29 @@ function Get-RunningProcess { } } +function Get-ListeningProcessId { + param([int]$Port) + + try { + $connection = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction Stop | + Select-Object -First 1 + if ($connection) { + return [int]$connection.OwningProcess + } + } catch { + } + + $lines = netstat -ano -p tcp | Select-String "LISTENING" + foreach ($line in $lines) { + $parts = ($line.Line.Trim() -split "\s+") + if ($parts.Count -ge 5 -and $parts[1].EndsWith(":$Port")) { + return [int]$parts[4] + } + } + + return 0 +} + function Load-State { param([string]$Path) @@ -133,6 +156,29 @@ if ($state -and -not $ForceRestart) { } } +if ($ForceRestart -and $state) { + $oldProcess = Get-RunningProcess -Id ([int]$state.pid) + if ($oldProcess) { + Write-Info "Stopping previous tracked process $($oldProcess.Id)" + Stop-Process -Id $oldProcess.Id -Force + Start-Sleep -Seconds 1 + } + Remove-State -Path $statePath +} + +if ($ForceRestart) { + foreach ($port in $PreferredPorts) { + if (Test-Health -Port $port) { + $listenerProcessId = Get-ListeningProcessId -Port $port + if ($listenerProcessId -gt 0) { + Write-Info "Stopping existing UI listener $listenerProcessId on port $port" + Stop-Process -Id $listenerProcessId -Force + Start-Sleep -Seconds 1 + } + } + } +} + foreach ($port in $PreferredPorts) { if (Test-Health -Port $port) { $url = "http://127.0.0.1:$port/" @@ -145,16 +191,6 @@ foreach ($port in $PreferredPorts) { } } -if ($ForceRestart -and $state) { - $oldProcess = Get-RunningProcess -Id ([int]$state.pid) - if ($oldProcess) { - Write-Info "Stopping previous tracked process $($oldProcess.Id)" - Stop-Process -Id $oldProcess.Id -Force - Start-Sleep -Seconds 1 - } - Remove-State -Path $statePath -} - $uvPath = Get-UvPath Write-Info "Using uv at $uvPath" Write-Info "Syncing project environment" @@ -226,6 +262,12 @@ if (-not $started) { } Save-State -Path $statePath -ProcessId $process.Id -Port $portToUse -Url $url +$listenerProcessId = Get-ListeningProcessId -Port $portToUse +if ($listenerProcessId -gt 0) { + Save-State -Path $statePath -ProcessId $listenerProcessId -Port $portToUse -Url $url +} else { + Save-State -Path $statePath -ProcessId $process.Id -Port $portToUse -Url $url +} Write-Info "UI is ready: $url" if (-not $NoBrowser) { diff --git a/Stop-SentinelUI.ps1 b/Stop-SentinelUI.ps1 index b2a845b..6d6c624 100644 --- a/Stop-SentinelUI.ps1 +++ b/Stop-SentinelUI.ps1 @@ -21,6 +21,29 @@ function Get-RunningProcess { } } +function Get-ListeningProcessId { + param([int]$Port) + + try { + $connection = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction Stop | + Select-Object -First 1 + if ($connection) { + return [int]$connection.OwningProcess + } + } catch { + } + + $lines = netstat -ano -p tcp | Select-String "LISTENING" + foreach ($line in $lines) { + $parts = ($line.Line.Trim() -split "\s+") + if ($parts.Count -ge 5 -and $parts[1].EndsWith(":$Port")) { + return [int]$parts[4] + } + } + + return 0 +} + $projectRoot = Split-Path -Parent $PSCommandPath $statePath = Join-Path $projectRoot ".s1dl\web-ui-state.json" @@ -36,7 +59,16 @@ if ($process) { Write-Info "Stopping UI process $($process.Id)" Stop-Process -Id $process.Id -Force } else { - Write-Info "Tracked process is not running." + $listenerProcessId = 0 + if ($state.port) { + $listenerProcessId = Get-ListeningProcessId -Port ([int]$state.port) + } + if ($listenerProcessId -gt 0) { + Write-Info "Stopping listener process $listenerProcessId" + Stop-Process -Id $listenerProcessId -Force + } else { + Write-Info "Tracked process is not running." + } } Remove-Item -LiteralPath $statePath -Force diff --git a/pyproject.toml b/pyproject.toml index 1897c28..d92a5e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,9 @@ requires-python = ">=3.11,<3.14" dependencies = [ "asf-search>=12.0.7", "fastapi>=0.116.1", + "pyproj>=3.7.0", + "python-multipart>=0.0.20", + "pyshp>=2.3.1", "s1-orbits>=0.2.0", "shapely>=2.1.2", "uvicorn>=0.35.0", diff --git a/src/sentinel_orbit_downloader/api/routes_search.py b/src/sentinel_orbit_downloader/api/routes_search.py index 7f62197..fdf8284 100644 --- a/src/sentinel_orbit_downloader/api/routes_search.py +++ b/src/sentinel_orbit_downloader/api/routes_search.py @@ -1,10 +1,16 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from sentinel_orbit_downloader.api.deps import get_db_path -from sentinel_orbit_downloader.api.schemas import JobSceneResponse, JobSummary, SearchJobCreateRequest, SearchJobCreateResponse -from sentinel_orbit_downloader.services.aoi import bbox_to_wkt +from sentinel_orbit_downloader.api.schemas import ( + JobSceneResponse, + JobSummary, + SearchJobCreateRequest, + SearchJobCreateResponse, + UploadedAoiResponse, +) +from sentinel_orbit_downloader.services.aoi import bbox_to_wkt, parse_uploaded_aoi from sentinel_orbit_downloader.services.asf_client import SearchParams, query_scenes from sentinel_orbit_downloader.services.project_store import ( create_search_job, @@ -21,6 +27,32 @@ from sentinel_orbit_downloader.services.region_catalog import get_region, region router = APIRouter(prefix="/search-jobs", tags=["search-jobs"]) +@router.post("/upload-aoi", response_model=UploadedAoiResponse) +async def upload_search_aoi_endpoint( + files: list[UploadFile] = File(...), +) -> UploadedAoiResponse: + uploads: list[tuple[str, bytes]] = [] + for file in files: + if not file.filename: + continue + content = await file.read() + uploads.append((file.filename, content)) + + try: + parsed = parse_uploaded_aoi(uploads) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return UploadedAoiResponse( + name=parsed.name, + source_format=parsed.source_format, + geometry_type=parsed.geometry_type, + feature_count=parsed.feature_count, + bbox=parsed.bbox, + wkt=parsed.wkt, + ) + + @router.post("", response_model=SearchJobCreateResponse) def create_search_job_endpoint( request: SearchJobCreateRequest, @@ -102,8 +134,8 @@ def resolve_search_aoi(request: SearchJobCreateRequest, db_path) -> dict[str, st if request.wkt: return { "aoi_wkt": request.wkt.strip(), - "source_type": "wkt", - "source_value": request.wkt.strip(), + "source_type": "aoi_file" if request.source_name else "wkt", + "source_value": request.source_name.strip() if request.source_name else request.wkt.strip(), "region_adcode": None, "region_name": None, } diff --git a/src/sentinel_orbit_downloader/api/schemas.py b/src/sentinel_orbit_downloader/api/schemas.py index 67ab8bc..d014e8a 100644 --- a/src/sentinel_orbit_downloader/api/schemas.py +++ b/src/sentinel_orbit_downloader/api/schemas.py @@ -121,11 +121,21 @@ class JobDetailResponse(BaseModel): events: list[JobEventResponse] = Field(default_factory=list) +class UploadedAoiResponse(BaseModel): + name: str + source_format: str + geometry_type: str + feature_count: int + bbox: list[float] = Field(default_factory=list) + wkt: str + + class SearchJobCreateRequest(BaseModel): project_id: int | None = None bbox: str | None = None wkt: str | None = None region_adcode: str | None = None + source_name: str | None = None start: str end: str processing_level: str = "SLC" @@ -158,7 +168,7 @@ class DownloadJobCreateRequest(BaseModel): manifest_path: str | None = None skip_data: bool = False skip_orbits: bool = False - processes: int = Field(default=2, ge=1) + processes: int = Field(default=3, ge=1) ignore_orbit_errors: bool = False username: str | None = None password: str | None = None diff --git a/src/sentinel_orbit_downloader/cli.py b/src/sentinel_orbit_downloader/cli.py index 9c626d7..29ec9f8 100644 --- a/src/sentinel_orbit_downloader/cli.py +++ b/src/sentinel_orbit_downloader/cli.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import getpass import json import sys from pathlib import Path @@ -10,7 +11,6 @@ from sentinel_orbit_downloader.core.config import DEFAULT_DB_PATH from sentinel_orbit_downloader.services.aoi import aoi_file_to_wkt, bbox_to_wkt from sentinel_orbit_downloader.services.asf_client import ( SearchParams, - build_session, product_size_mb, query_scenes, scene_name_from_product, @@ -19,7 +19,11 @@ from sentinel_orbit_downloader.services.asf_client import ( from sentinel_orbit_downloader.services.orbit_client import ( collect_orbit_scenes, download_orbits, - scene_names_from_results, +) +from sentinel_orbit_downloader.services.download_manager import ( + DownloadJobRequest, + execute_download_job, + queue_download_job, ) from sentinel_orbit_downloader.services.project_store import ( create_project, @@ -112,8 +116,8 @@ def build_parser() -> argparse.ArgumentParser: download_parser.add_argument( "--processes", type=positive_int, - default=2, - help="parallel ASF download processes", + default=3, + help="retry attempts for each product download", ) download_parser.add_argument( "--skip-data", @@ -418,6 +422,15 @@ def cmd_download(args: argparse.Namespace) -> int: results = perform_query(args, aoi_meta["aoi_wkt"]) save_query_results(args.db, job.id, results) record_job_event(args.db, job.id, "info", f"Query returned {len(results)} scene(s)") + update_search_job( + args.db, + job.id, + status="running", + result_count=len(results), + data_dir=str(data_dir), + orbit_dir=str(orbit_dir), + manifest_path=str(manifest), + ) print_results(results) write_manifest(results, manifest) @@ -437,51 +450,31 @@ def cmd_download(args: argparse.Namespace) -> int: print(f"Saved job: {job.id}") return 0 - if not args.skip_data: - data_dir.mkdir(parents=True, exist_ok=True) - session = build_session( - username=args.username, - password=args.password, - ask_password=args.ask_password, - ) - print(f"Downloading {len(results)} product(s) to {data_dir} ...") - results.download(path=str(data_dir), session=session, processes=args.processes) - print("Product download finished.") - record_job_event(args.db, job.id, "info", f"Product download finished into {data_dir}") - record_product_downloads(args.db, job.id, results, data_dir) - else: - record_job_event(args.db, job.id, "info", "Product download skipped by request") - record_product_downloads(args.db, job.id, results, None) + password = args.password + if args.ask_password and not args.skip_data: + password = getpass.getpass("Earthdata password: ") - orbit_failures = 0 - if not args.skip_orbits: - scenes = scene_names_from_results(results) - print(f"Downloading orbit files for {len(scenes)} scene(s) to {orbit_dir} ...") - orbit_results = download_orbits(scenes, orbit_dir) - orbit_failures = summarize_orbit_results(orbit_results) - record_orbit_downloads(args.db, job.id, orbit_results) - record_job_event( - args.db, - job.id, - "info", - f"Orbit download finished: {len(orbit_results) - orbit_failures} ok, {orbit_failures} failed", - ) - else: - record_job_event(args.db, job.id, "info", "Orbit download skipped by request") - - final_status = "partial_failed" if orbit_failures else "succeeded" - update_search_job( - args.db, - job.id, - status=final_status, - result_count=len(results), - data_dir=str(data_dir), - orbit_dir=str(orbit_dir), - manifest_path=str(manifest), + manager_request = DownloadJobRequest( + job_id=job.id, + data_dir=data_dir, + orbit_dir=orbit_dir, + manifest_path=manifest, + skip_data=args.skip_data, + skip_orbits=args.skip_orbits, + processes=args.processes, + ignore_orbit_errors=args.ignore_orbit_errors, + username=args.username, + password=password, ) + queue_download_job(args.db, manager_request) + print(f"Downloading {len(results)} scene(s) with resume and validation support ...") + execute_download_job(args.db, manager_request) + finished_job = get_job(args.db, job.id) + final_status = finished_job.status if finished_job is not None else "failed" + print(f"Download job finished: {final_status}") print(f"Saved job: {job.id}") - if orbit_failures and not args.ignore_orbit_errors: + if final_status in {"failed", "partial_failed"}: return 1 return 0 except Exception as exc: diff --git a/src/sentinel_orbit_downloader/services/aoi.py b/src/sentinel_orbit_downloader/services/aoi.py index 6edf331..e89c6be 100644 --- a/src/sentinel_orbit_downloader/services/aoi.py +++ b/src/sentinel_orbit_downloader/services/aoi.py @@ -1,8 +1,34 @@ from __future__ import annotations +from dataclasses import dataclass +from io import BytesIO import json from pathlib import Path from typing import Any, Iterable, Sequence +import zipfile + +import shapefile +from pyproj import CRS, Transformer +from shapely import make_valid +from shapely.geometry import shape as shapely_shape +from shapely.geometry.base import BaseGeometry +from shapely.ops import unary_union +from shapely.ops import transform as shapely_transform + + +GEOJSON_SUFFIXES = {".geojson", ".json"} +TEXT_WKT_SUFFIXES = {".wkt", ".txt"} +SHAPEFILE_PART_SUFFIXES = {".shp", ".shx", ".dbf", ".prj", ".cpg"} + + +@dataclass(frozen=True) +class ParsedAOI: + name: str + wkt: str + source_format: str + geometry_type: str + feature_count: int + bbox: list[float] def bbox_to_wkt(value: str) -> str: @@ -22,54 +48,285 @@ def bbox_to_wkt(value: str) -> str: def aoi_file_to_wkt(path: Path) -> str: - text = path.read_text(encoding="utf-8").strip() + suffix = path.suffix.lower() + if suffix in GEOJSON_SUFFIXES: + return parse_geojson_bytes(path.name, path.read_bytes()).wkt + if suffix == ".zip": + return parse_zip_bytes(path.name, path.read_bytes()).wkt + if suffix == ".shp": + return parse_shapefile_paths(path).wkt + + text = decode_text_bytes(path.read_bytes()).strip() if not text: raise ValueError(f"AOI file is empty: {path}") - if text[0] != "{": + if suffix in TEXT_WKT_SUFFIXES: return text - - data = json.loads(text) - geometry = geojson_geometry(data) - return geometry_to_wkt(geometry) + if text[0] in "{[": + return parse_geojson_text(path.name, text).wkt + return text -def geojson_geometry(data: dict[str, Any]) -> dict[str, Any]: +def parse_uploaded_aoi(files: Sequence[tuple[str, bytes]]) -> ParsedAOI: + cleaned = [(Path(name).name or name, content) for name, content in files if content] + if not cleaned: + raise ValueError("No AOI files were provided") + + suffixes = {Path(name).suffix.lower() for name, _ in cleaned} + if len(cleaned) == 1: + name, content = cleaned[0] + suffix = Path(name).suffix.lower() + if suffix in GEOJSON_SUFFIXES: + return parse_geojson_bytes(name, content) + if suffix == ".zip": + return parse_zip_bytes(name, content) + if suffix == ".shp": + return parse_shapefile_uploads(cleaned) + if suffix in TEXT_WKT_SUFFIXES: + return parse_wkt_text(name, content) + + if suffixes & SHAPEFILE_PART_SUFFIXES: + return parse_shapefile_uploads(cleaned) + + raise ValueError( + "Unsupported AOI upload. Use .geojson/.json, .zip shapefile, " + "or shapefile parts (.shp/.shx/.dbf)." + ) + + +def parse_wkt_text(name: str, content: bytes) -> ParsedAOI: + text = decode_text_bytes(content).strip() + if not text: + raise ValueError(f"AOI file is empty: {name}") + return ParsedAOI( + name=Path(name).stem or name, + wkt=text, + source_format="wkt", + geometry_type="WKT", + feature_count=1, + bbox=[], + ) + + +def parse_geojson_bytes(name: str, content: bytes) -> ParsedAOI: + text = decode_text_bytes(content) + return parse_geojson_text(name, text) + + +def parse_geojson_text(name: str, text: str) -> ParsedAOI: + stripped = text.strip() + if not stripped: + raise ValueError(f"AOI file is empty: {name}") + try: + data = json.loads(stripped) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid GeoJSON: {exc}") from exc + geometry, feature_count = geojson_to_geometry(data) + return build_parsed_aoi( + name=Path(name).stem or name, + source_format="geojson", + geometry=geometry, + feature_count=feature_count, + ) + + +def parse_zip_bytes(name: str, content: bytes) -> ParsedAOI: + try: + archive = zipfile.ZipFile(BytesIO(content)) + except zipfile.BadZipFile as exc: + raise ValueError(f"Invalid zip file: {name}") from exc + + with archive: + file_names = [member for member in archive.namelist() if not member.endswith("/")] + geojson_members = [member for member in file_names if Path(member).suffix.lower() in GEOJSON_SUFFIXES] + if geojson_members: + member = geojson_members[0] + parsed = parse_geojson_bytes(Path(member).name, archive.read(member)) + return ParsedAOI( + name=Path(name).stem or parsed.name, + wkt=parsed.wkt, + source_format="geojson-zip", + geometry_type=parsed.geometry_type, + feature_count=parsed.feature_count, + bbox=parsed.bbox, + ) + + shape_members = [ + (Path(member).name, archive.read(member)) + for member in file_names + if Path(member).suffix.lower() in SHAPEFILE_PART_SUFFIXES + ] + if shape_members: + parsed = parse_shapefile_uploads(shape_members) + return ParsedAOI( + name=Path(name).stem or parsed.name, + wkt=parsed.wkt, + source_format="shapefile-zip", + geometry_type=parsed.geometry_type, + feature_count=parsed.feature_count, + bbox=parsed.bbox, + ) + + raise ValueError("Zip file does not contain GeoJSON or shapefile content") + + +def parse_shapefile_paths(shp_path: Path) -> ParsedAOI: + base = shp_path.with_suffix("") + uploads: list[tuple[str, bytes]] = [(shp_path.name, shp_path.read_bytes())] + for suffix in (".shx", ".dbf", ".prj", ".cpg"): + candidate = Path(f"{base}{suffix}") + if candidate.exists(): + uploads.append((candidate.name, candidate.read_bytes())) + return parse_shapefile_uploads(uploads) + + +def parse_shapefile_uploads(files: Sequence[tuple[str, bytes]]) -> ParsedAOI: + groups: dict[str, dict[str, bytes]] = {} + display_names: dict[str, str] = {} + for name, content in files: + suffix = Path(name).suffix.lower() + if suffix not in SHAPEFILE_PART_SUFFIXES: + continue + stem = Path(name).stem + key = stem.lower() + groups.setdefault(key, {})[suffix] = content + display_names[key] = stem + + shp_groups = [key for key, parts in groups.items() if ".shp" in parts] + if not shp_groups: + raise ValueError("Shapefile upload requires at least a .shp file") + if len(shp_groups) > 1: + names = ", ".join(display_names[key] for key in shp_groups) + raise ValueError(f"Multiple shapefiles were uploaded at once: {names}") + + key = shp_groups[0] + parts = groups[key] + reader = shapefile.Reader( + shp=BytesIO(parts[".shp"]), + shx=BytesIO(parts[".shx"]) if ".shx" in parts else None, + dbf=BytesIO(parts[".dbf"]) if ".dbf" in parts else None, + ) + try: + geometries = [shapely_shape(shape.__geo_interface__) for shape in reader.shapes()] + finally: + close = getattr(reader, "close", None) + if callable(close): + close() + + if not geometries: + raise ValueError("Shapefile has no geometry records") + geometry = combine_geometries(geometries) + geometry = reproject_shapefile_geometry(geometry, parts.get(".prj")) + return build_parsed_aoi( + name=display_names[key], + source_format="shapefile", + geometry=geometry, + feature_count=len(geometries), + ) + + +def geojson_to_geometry(data: dict[str, Any]) -> tuple[BaseGeometry, int]: kind = data.get("type") if kind == "FeatureCollection": features = data.get("features") or [] - if not features: - raise ValueError("GeoJSON FeatureCollection has no features") - return geojson_geometry(features[0]) + geometries = [] + for feature in features: + geometry = feature.get("geometry") if isinstance(feature, dict) else None + if geometry: + geometries.append(shapely_shape(geometry)) + if not geometries: + raise ValueError("GeoJSON FeatureCollection has no geometry") + return combine_geometries(geometries), len(geometries) + if kind == "Feature": geometry = data.get("geometry") if not geometry: raise ValueError("GeoJSON Feature has no geometry") + return normalize_geometry(shapely_shape(geometry)), 1 + + if kind: + return normalize_geometry(shapely_shape(data)), 1 + + raise ValueError("Invalid GeoJSON: missing type") + + +def combine_geometries(geometries: Iterable[BaseGeometry]) -> BaseGeometry: + items = [normalize_geometry(geometry) for geometry in geometries if not geometry.is_empty] + if not items: + raise ValueError("AOI has no valid geometry") + geometry = items[0] if len(items) == 1 else unary_union(items) + return normalize_geometry(geometry) + + +def normalize_geometry(geometry: BaseGeometry) -> BaseGeometry: + if geometry.is_empty: + raise ValueError("AOI geometry is empty") + if not geometry.is_valid: + repaired = make_valid(geometry) + if not repaired.is_empty: + geometry = repaired + if geometry.is_empty: + raise ValueError("AOI geometry is empty") + return geometry + + +def reproject_shapefile_geometry(geometry: BaseGeometry, prj_content: bytes | None) -> BaseGeometry: + if not prj_content: + validate_lon_lat_bounds(geometry) return geometry - if kind in {"Polygon", "MultiPolygon", "Point", "MultiPoint", "LineString"}: - return data - raise ValueError(f"unsupported GeoJSON type: {kind}") + + prj_text = decode_text_bytes(prj_content).strip() + if not prj_text: + validate_lon_lat_bounds(geometry) + return geometry + + try: + source_crs = CRS.from_wkt(prj_text) + except Exception as exc: + raise ValueError(f"Unable to read shapefile .prj CRS: {exc}") from exc + + target_crs = CRS.from_epsg(4326) + if source_crs.equals(target_crs): + validate_lon_lat_bounds(geometry) + return geometry + + transformer = Transformer.from_crs(source_crs, target_crs, always_xy=True) + transformed = shapely_transform(transformer.transform, geometry) + validate_lon_lat_bounds(transformed) + return normalize_geometry(transformed) -def geometry_to_wkt(geometry: dict[str, Any]) -> str: - kind = geometry.get("type") - coordinates = geometry.get("coordinates") - if kind == "Point": - lon, lat = coordinates - return f"POINT({lon} {lat})" - if kind == "MultiPoint": - return "MULTIPOINT(" + ",".join(f"({lon} {lat})" for lon, lat in coordinates) + ")" - if kind == "LineString": - return "LINESTRING(" + coords_to_text(coordinates) + ")" - if kind == "Polygon": - return "POLYGON(" + ",".join(f"({coords_to_text(ring)})" for ring in coordinates) + ")" - if kind == "MultiPolygon": - polygons = [] - for polygon in coordinates: - rings = ",".join(f"({coords_to_text(ring)})" for ring in polygon) - polygons.append(f"({rings})") - return "MULTIPOLYGON(" + ",".join(polygons) + ")" - raise ValueError(f"unsupported GeoJSON geometry type: {kind}") +def validate_lon_lat_bounds(geometry: BaseGeometry) -> None: + min_x, min_y, max_x, max_y = geometry.bounds + if min_x < -180 or max_x > 180 or min_y < -90 or max_y > 90: + raise ValueError( + "AOI coordinates are outside longitude/latitude bounds. " + "For shapefiles, include a valid .prj file or convert the data to WGS84/EPSG:4326 first." + ) -def coords_to_text(coords: Iterable[Sequence[float]]) -> str: - return ",".join(f"{lon} {lat}" for lon, lat, *_ in coords) +def build_parsed_aoi( + *, + name: str, + source_format: str, + geometry: BaseGeometry, + feature_count: int, +) -> ParsedAOI: + geometry = normalize_geometry(geometry) + bounds = list(geometry.bounds) if geometry.bounds else [] + return ParsedAOI( + name=name, + wkt=geometry.wkt, + source_format=source_format, + geometry_type=geometry.geom_type, + feature_count=feature_count, + bbox=[float(value) for value in bounds], + ) + + +def decode_text_bytes(content: bytes) -> str: + for encoding in ("utf-8-sig", "utf-8", "gb18030"): + try: + return content.decode(encoding) + except UnicodeDecodeError: + continue + return content.decode("latin-1") diff --git a/src/sentinel_orbit_downloader/services/download_manager.py b/src/sentinel_orbit_downloader/services/download_manager.py index 79bbfb5..ca3d092 100644 --- a/src/sentinel_orbit_downloader/services/download_manager.py +++ b/src/sentinel_orbit_downloader/services/download_manager.py @@ -2,9 +2,12 @@ from __future__ import annotations from dataclasses import dataclass from pathlib import Path +import time from typing import Sequence +import zipfile import asf_search as asf +from asf_search.download.download import strip_auth_if_aws from sentinel_orbit_downloader.services.asf_client import build_session, credentials_available, scene_name_from_product from sentinel_orbit_downloader.services.orbit_client import download_orbits @@ -40,6 +43,17 @@ class DownloadJobPaths: manifest_path: Path +@dataclass(frozen=True) +class ProductDownloadResult: + scene_id: str + status: str + path: Path | None + url: str | None + bytes_total: float | None + bytes_done: float | None + error_message: str | None = None + + def resolve_download_paths(db_path: Path, request: DownloadJobRequest) -> DownloadJobPaths: job = get_job(db_path, request.job_id) if job is None: @@ -163,7 +177,7 @@ def execute_download_job(db_path: Path, request: DownloadJobRequest) -> None: record_product_skips(db_path, request.job_id, scene_ids) else: results = asf.granule_search(scene_ids) - products = {scene_name_from_product(product): product for product in results} + products = select_data_products(results, scene_ids, job.processing_level) ordered_products = [products[scene_id] for scene_id in scene_ids if scene_id in products] missing_scene_ids = [scene_id for scene_id in scene_ids if scene_id not in products] if missing_scene_ids: @@ -193,19 +207,52 @@ def execute_download_job(db_path: Path, request: DownloadJobRequest) -> None: path=None, ) paths.data_dir.mkdir(parents=True, exist_ok=True) - try: - session = build_session(username=request.username, password=request.password) - results.download(path=str(paths.data_dir), session=session, processes=request.processes) + session = build_session(username=request.username, password=request.password) + product_attempts = max(1, min(5, request.processes)) + product_results: list[ProductDownloadResult] = [] + for index, product in enumerate(ordered_products, start=1): + scene_id = scene_name_from_product(product) or f"scene #{index}" record_job_event( db_path, request.job_id, "info", - f"Product download finished into {paths.data_dir}", + f"Product download {index}/{len(ordered_products)} started: {scene_id}", ) - except Exception as exc: - product_error = str(exc) - record_job_event(db_path, request.job_id, "error", f"Product download error: {exc}") - record_product_downloads(db_path, request.job_id, ordered_products, paths.data_dir, product_error) + result = download_product_with_retries( + product, + paths.data_dir, + session, + attempts=product_attempts, + ) + product_results.append(result) + record_product_download_result(db_path, request.job_id, result) + if result.status == "succeeded": + record_job_event( + db_path, + request.job_id, + "info", + f"Product download {index}/{len(ordered_products)} succeeded: {scene_id}", + ) + else: + record_job_event( + db_path, + request.job_id, + "warning", + f"Product download {index}/{len(ordered_products)} failed: {scene_id}; {result.error_message}", + ) + + product_failures = sum(1 for result in product_results if result.status == "failed") + len( + missing_scene_ids + ) + product_successes = sum(1 for result in product_results if result.status == "succeeded") + if product_failures: + product_error = f"Product download failed for {product_failures} of {len(scene_ids)} scene(s)" + record_job_event( + db_path, + request.job_id, + "info", + f"Product download finished: {product_successes} ok, {product_failures} failed", + ) orbit_failures = 0 if request.skip_orbits: @@ -233,12 +280,25 @@ def execute_download_job(db_path: Path, request: DownloadJobRequest) -> None: ) product_failures = count_download_failures(db_path, request.job_id, "product") + product_successes = count_download_status(db_path, request.job_id, "product", "succeeded") effective_orbit_failures = 0 if request.ignore_orbit_errors else orbit_failures final_status = "succeeded" if product_failures or effective_orbit_failures: final_status = "partial_failed" - if product_failures and (request.skip_orbits or effective_orbit_failures == len(scene_ids)): + + if not request.skip_data and product_failures and product_successes == 0: final_status = "failed" + elif request.skip_data and not request.skip_orbits and effective_orbit_failures == len(scene_ids): + final_status = "failed" + + error_message = None + if final_status != "succeeded": + error_message = summarize_failure( + product_failures=product_failures if not request.skip_data else 0, + orbit_failures=effective_orbit_failures if not request.skip_orbits else 0, + scene_count=len(scene_ids), + fallback=product_error, + ) update_search_job( db_path, @@ -248,7 +308,7 @@ def execute_download_job(db_path: Path, request: DownloadJobRequest) -> None: data_dir=str(paths.data_dir), orbit_dir=str(paths.orbit_dir), manifest_path=str(paths.manifest_path), - error_message=product_error, + error_message=error_message, ) except Exception as exc: update_search_job( @@ -306,6 +366,21 @@ def record_product_downloads( ) +def record_product_download_result(db_path: Path, job_id: int, result: ProductDownloadResult) -> None: + record_download_result( + db_path, + job_id=job_id, + scene_id=result.scene_id, + kind="product", + status=result.status, + path=str(result.path) if result.path is not None else None, + url=result.url, + bytes_total=result.bytes_total, + bytes_done=result.bytes_done, + error_message=result.error_message, + ) + + def record_product_skips(db_path: Path, job_id: int, scene_ids: Sequence[str]) -> None: for scene_id in scene_ids: record_download_result( @@ -342,6 +417,209 @@ def expected_product_path(product, data_dir: Path) -> Path: return data_dir / f"{scene}.zip" +def select_data_products(results, scene_ids: Sequence[str], processing_level: str) -> dict[str, object]: + wanted = set(scene_ids) + selected: dict[str, object] = {} + selected_rank: dict[str, int] = {} + for product in results: + scene_id = scene_name_from_product(product) + if scene_id not in wanted: + continue + rank = data_product_rank(product, processing_level) + if rank is None: + continue + current_rank = selected_rank.get(scene_id) + if current_rank is None or rank < current_rank: + selected[scene_id] = product + selected_rank[scene_id] = rank + return selected + + +def data_product_rank(product, processing_level: str) -> int | None: + props = product.properties + product_level = string_value(props.get("processingLevel")) + file_name = string_value(props.get("fileName")) or "" + url = string_value(props.get("url")) or "" + lower_name = file_name.lower() + lower_url = url.lower() + + if product_level and product_level.upper().startswith("METADATA"): + return None + if lower_name.endswith(".iso.xml") or lower_url.endswith(".iso.xml"): + return None + if processing_level and product_level and product_level.upper() == processing_level.upper(): + return 0 + if lower_name.endswith(".zip") or lower_url.endswith(".zip"): + return 1 + return None + + +def download_product_with_retries(product, data_dir: Path, session, attempts: int = 3) -> ProductDownloadResult: + scene_id = scene_name_from_product(product) or "unknown_scene" + props = product.properties + expected = expected_product_path(product, data_dir) + bytes_total = float(props["bytes"]) if props.get("bytes") is not None else None + expected_size = int(props["bytes"]) if props.get("bytes") is not None else None + url = product_download_url(product) + + if file_complete(expected, expected_size): + return ProductDownloadResult( + scene_id=scene_id, + status="succeeded", + path=expected, + url=url, + bytes_total=bytes_total, + bytes_done=float(expected.stat().st_size), + ) + + last_error = "Downloaded file not found at expected path" + for attempt in range(1, max(1, attempts) + 1): + try: + if url is None: + raise ValueError("Product has no downloadable URL") + download_product_file(url, expected, session, expected_size) + if file_complete(expected, expected_size): + return ProductDownloadResult( + scene_id=scene_id, + status="succeeded", + path=expected, + url=url, + bytes_total=bytes_total, + bytes_done=float(expected.stat().st_size), + ) + if expected.exists(): + last_error = ( + f"Downloaded file size mismatch: got {expected.stat().st_size}, expected {expected_size}" + ) + else: + last_error = "Downloaded file not found at expected path" + except Exception as exc: + last_error = str(exc) or exc.__class__.__name__ + + if attempt < attempts: + time.sleep(min(15, attempt * 3)) + + part_path = partial_product_path(expected) + return ProductDownloadResult( + scene_id=scene_id, + status="failed", + path=None, + url=url, + bytes_total=bytes_total, + bytes_done=partial_size(expected, part_path), + error_message=last_error, + ) + + +def product_download_url(product) -> str | None: + try: + urls = product.get_urls() + except Exception: + urls = [] + if urls: + return str(urls[0]) + return string_value(product.properties.get("url")) + + +def download_product_file(url: str, final_path: Path, session, expected_size: int | None) -> None: + final_path.parent.mkdir(parents=True, exist_ok=True) + if file_complete(final_path, expected_size): + return + + part_path = prepare_partial_file(final_path, expected_size) + if validate_product_file(part_path, expected_size) is None: + part_path.replace(final_path) + return + + resume_from = part_path.stat().st_size if part_path.exists() else 0 + headers = {"Range": f"bytes={resume_from}-"} if resume_from else {} + response = session.get( + url, + stream=True, + headers=headers, + hooks={"response": strip_auth_if_aws}, + timeout=(30, 120), + ) + try: + response.raise_for_status() + + if resume_from and response.status_code != 206: + part_path.unlink(missing_ok=True) + resume_from = 0 + elif resume_from: + validate_content_range(response.headers.get("Content-Range"), resume_from) + + mode = "ab" if resume_from else "wb" + with part_path.open(mode) as handle: + for chunk in response.iter_content(chunk_size=1024 * 1024): + if chunk: + handle.write(chunk) + finally: + response.close() + + validation_error = validate_product_file(part_path, expected_size) + if validation_error is not None: + raise IOError(validation_error) + + part_path.replace(final_path) + + +def prepare_partial_file(final_path: Path, expected_size: int | None) -> Path: + part_path = partial_product_path(final_path) + + if final_path.exists() and not file_complete(final_path, expected_size): + final_size = final_path.stat().st_size + if expected_size is not None and final_size < expected_size and not part_path.exists(): + final_path.replace(part_path) + else: + final_path.unlink() + + if part_path.exists() and expected_size is not None: + part_size = part_path.stat().st_size + if part_size > expected_size or ( + part_size == expected_size and validate_product_file(part_path, expected_size) + ): + part_path.unlink() + + return part_path + + +def partial_product_path(final_path: Path) -> Path: + return final_path.with_name(f"{final_path.name}.part") + + +def file_complete(path: Path, expected_size: int | None) -> bool: + return validate_product_file(path, expected_size) is None + + +def validate_product_file(path: Path, expected_size: int | None) -> str | None: + if not path.exists() or not path.is_file(): + return "Downloaded file not found at expected path" + actual_size = path.stat().st_size + if expected_size is not None and actual_size != expected_size: + return f"Downloaded file size mismatch: got {actual_size}, expected {expected_size}" + lower_name = path.name.lower() + if (lower_name.endswith(".zip") or lower_name.endswith(".zip.part")) and not zipfile.is_zipfile(path): + return "Downloaded ZIP failed integrity check" + return None + + +def validate_content_range(content_range: str | None, expected_start: int) -> None: + if not content_range: + return + prefix = f"bytes {expected_start}-" + if not content_range.startswith(prefix): + raise IOError(f"Unexpected resume range from server: {content_range}") + + +def partial_size(final_path: Path, part_path: Path) -> float | None: + if final_path.exists(): + return float(final_path.stat().st_size) + if part_path.exists(): + return float(part_path.stat().st_size) + return None + + def string_value(value) -> str | None: if value is None: return None @@ -349,6 +627,10 @@ def string_value(value) -> str | None: def count_download_failures(db_path: Path, job_id: int, kind: str) -> int: + return count_download_status(db_path, job_id, kind, "failed") + + +def count_download_status(db_path: Path, job_id: int, kind: str, status: str) -> int: from sentinel_orbit_downloader.storage.db import connect if kind == "product": @@ -361,10 +643,27 @@ def count_download_failures(db_path: Path, job_id: int, kind: str) -> int: with connect(db_path) as connection: row = connection.execute( f""" - SELECT COUNT(*) AS failure_count + SELECT COUNT(*) AS status_count FROM job_scenes - WHERE job_id = ? AND {column} = 'failed' + WHERE job_id = ? AND {column} = ? """, - (job_id,), + (job_id, status), ).fetchone() - return int(row["failure_count"]) + return int(row["status_count"]) + + +def summarize_failure( + *, + product_failures: int, + orbit_failures: int, + scene_count: int, + fallback: str | None, +) -> str: + parts = [] + if product_failures: + parts.append(f"Product failed for {product_failures}/{scene_count} scene(s)") + if orbit_failures: + parts.append(f"Orbit failed for {orbit_failures}/{scene_count} scene(s)") + if parts: + return "; ".join(parts) + return fallback or "Download finished with failures" diff --git a/src/sentinel_orbit_downloader/services/orbit_client.py b/src/sentinel_orbit_downloader/services/orbit_client.py index a07c621..e1f9757 100644 --- a/src/sentinel_orbit_downloader/services/orbit_client.py +++ b/src/sentinel_orbit_downloader/services/orbit_client.py @@ -88,4 +88,7 @@ def download_orbits(scenes: Sequence[str], orbit_dir: Path) -> list[OrbitResult] except (InvalidSceneError, OrbitNotFoundError) as exc: message = str(exc) or exc.__class__.__name__ results.append(OrbitResult(scene=scene, path=None, error=message)) + except Exception as exc: + message = str(exc) or exc.__class__.__name__ + results.append(OrbitResult(scene=scene, path=None, error=message)) return results diff --git a/src/sentinel_orbit_downloader/web/app.js b/src/sentinel_orbit_downloader/web/app.js index 354c28c..1e2ae66 100644 --- a/src/sentinel_orbit_downloader/web/app.js +++ b/src/sentinel_orbit_downloader/web/app.js @@ -73,6 +73,25 @@ const api = { body: JSON.stringify(payload), }); }, + async uploadSearchAoi(files) { + const formData = new FormData(); + for (const file of files) { + formData.append("files", file); + } + const response = await fetch("/api/search-jobs/upload-aoi", { + method: "POST", + body: formData, + }); + if (!response.ok) { + let detail = response.statusText; + try { + const payload = await response.json(); + detail = payload.detail || JSON.stringify(payload); + } catch (_) {} + throw new Error(detail); + } + return response.json(); + }, createDownloadJob(payload) { return this.request("/api/download-jobs", { method: "POST", @@ -98,6 +117,7 @@ const state = { regions: [], selectedRegion: null, selectedRegionDetail: null, + uploadedAoi: null, earthdataSettings: null, jobs: [], selectedJobId: null, @@ -119,6 +139,9 @@ const el = { regionKeyword: document.getElementById("regionKeyword"), regionList: document.getElementById("regionList"), regionDetailCard: document.getElementById("regionDetailCard"), + aoiMode: document.getElementById("aoiMode"), + uploadAoiFiles: document.getElementById("uploadAoiFiles"), + uploadAoiSummary: document.getElementById("uploadAoiSummary"), startDate: document.getElementById("startDate"), endDate: document.getElementById("endDate"), maxResults: document.getElementById("maxResults"), @@ -236,6 +259,10 @@ function currentProject() { return state.projects.find((project) => project.id === state.selectedProjectId) || null; } +function currentAoiMode() { + return el.aoiMode.value || "region"; +} + function autoFillProjectPaths(project) { if (!project || !project.data_root) { return; @@ -352,6 +379,22 @@ function renderEarthdataSummary() { ); } +function renderUploadedAoiSummary() { + el.uploadAoiSummary.innerHTML = ""; + const uploadedAoi = state.uploadedAoi; + if (!uploadedAoi) { + el.uploadAoiSummary.appendChild(makeChip("上传 AOI:未解析")); + return; + } + el.uploadAoiSummary.appendChild(makeChip(`AOI ${uploadedAoi.name}`)); + el.uploadAoiSummary.appendChild(makeChip(`格式 ${uploadedAoi.source_format}`)); + el.uploadAoiSummary.appendChild(makeChip(`几何 ${uploadedAoi.geometry_type}`)); + el.uploadAoiSummary.appendChild(makeChip(`要素 ${uploadedAoi.feature_count}`)); + if (Array.isArray(uploadedAoi.bbox) && uploadedAoi.bbox.length === 4) { + el.uploadAoiSummary.appendChild(makeChip(`范围 ${formatBBox(uploadedAoi.bbox)}`)); + } +} + function renderJobFilterSummary() { el.jobFilterSummary.innerHTML = ""; const project = currentProject(); @@ -515,8 +558,16 @@ function updateSelectionSummary() { if (project) { el.selectionSummary.appendChild(makeChip(`项目 ${project.name}`)); } - if (state.selectedRegion) { - el.selectionSummary.appendChild(makeChip(`行政区 ${state.selectedRegion.label}`)); + if (currentAoiMode() === "upload") { + el.selectionSummary.appendChild(makeChip("AOI 来源 上传文件")); + if (state.uploadedAoi) { + el.selectionSummary.appendChild(makeChip(`上传 AOI ${state.uploadedAoi.name}`)); + } + } else { + el.selectionSummary.appendChild(makeChip("AOI 来源 行政区")); + if (state.selectedRegion) { + el.selectionSummary.appendChild(makeChip(`行政区 ${state.selectedRegion.label}`)); + } } el.selectionSummary.appendChild(makeChip(`时间 ${el.startDate.value} - ${el.endDate.value}`)); el.selectionSummary.appendChild(makeChip(`产品 ${el.processingLevel.value}`)); @@ -614,6 +665,25 @@ async function loadSelectedJobDetail() { updateSelectionSummary(); } +async function uploadAoi() { + const files = Array.from(el.uploadAoiFiles.files || []); + if (!files.length) { + throw new Error("请先选择 AOI 文件"); + } + state.uploadedAoi = await api.uploadSearchAoi(files); + renderUploadedAoiSummary(); + updateSelectionSummary(); + setMessage(`AOI 已解析:${state.uploadedAoi.name}`, "success"); +} + +function clearUploadedAoi() { + state.uploadedAoi = null; + el.uploadAoiFiles.value = ""; + renderUploadedAoiSummary(); + updateSelectionSummary(); + setMessage("上传 AOI 已清空", "info"); +} + async function importCatalog() { const payload = { source_path: el.catalogPath.value.trim() || DEFAULT_CATALOG_PATH, @@ -669,13 +739,9 @@ async function createSearchJob() { if (!state.selectedProjectId) { throw new Error("请先选择项目"); } - if (!state.selectedRegion) { - throw new Error("请先选择行政区"); - } const payload = { project_id: state.selectedProjectId, - region_adcode: state.selectedRegion.adcode, start: el.startDate.value, end: el.endDate.value, processing_level: el.processingLevel.value, @@ -685,9 +751,23 @@ async function createSearchJob() { relative_orbit: numberOrNull(el.relativeOrbit.value), max_results: Number(el.maxResults.value || 20), manifest_path: emptyToNull(el.manifestPath.value), - raw_region: el.rawRegion.checked, }; + if (currentAoiMode() === "upload") { + if (!state.uploadedAoi) { + throw new Error("请先上传并解析 AOI 文件"); + } + payload.wkt = state.uploadedAoi.wkt; + payload.source_name = state.uploadedAoi.name; + payload.raw_region = false; + } else { + if (!state.selectedRegion) { + throw new Error("请先选择行政区"); + } + payload.region_adcode = state.selectedRegion.adcode; + payload.raw_region = el.rawRegion.checked; + } + const response = await api.createSearchJob(payload); state.selectedJobId = response.job.id; state.selectedJobDetail = { @@ -822,6 +902,11 @@ function bindEvents() { document.getElementById("createProjectBtn").addEventListener("click", runAction(createProject, "正在创建项目")); document.getElementById("refreshProjectsBtn").addEventListener("click", runAction(loadProjects, "正在刷新项目")); document.getElementById("searchRegionsBtn").addEventListener("click", runAction(loadRegions, "正在查询行政区")); + document.getElementById("uploadAoiBtn").addEventListener("click", runAction(uploadAoi, "正在解析上传 AOI")); + document.getElementById("clearAoiBtn").addEventListener("click", clearUploadedAoi); + document.getElementById("aoiMode").addEventListener("change", () => { + updateSelectionSummary(); + }); document.getElementById("clearRegionsBtn").addEventListener("click", () => { state.regions = []; state.selectedRegion = null; @@ -848,6 +933,7 @@ async function bootstrap() { if (!el.catalogPath.value) { el.catalogPath.value = DEFAULT_CATALOG_PATH; } + renderUploadedAoiSummary(); el.healthDetail.textContent = window.location.origin; setMessage("正在初始化工作台", "info"); await loadHealth(); diff --git a/src/sentinel_orbit_downloader/web/index.html b/src/sentinel_orbit_downloader/web/index.html index 4e17097..648d685 100644 --- a/src/sentinel_orbit_downloader/web/index.html +++ b/src/sentinel_orbit_downloader/web/index.html @@ -95,6 +95,27 @@

检索参数

+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ 支持 `.geojson`、`.json`、`.wkt`、`.txt`、zip shapefile,或同时选择 `.shp`、`.shx`、`.dbf`、`.prj` 等文件。带 `.prj` 的 shapefile 会自动转为 WGS84,经纬度 GeoJSON 会自动合并成一个 AOI 范围。 +
@@ -193,8 +214,8 @@
- - + +
@@ -219,7 +240,7 @@
- 下载原始 Sentinel-1 产品必须提供 Earthdata 用户名和密码。你可以手填后直接下载,也可以先保存到本地配置。当前阶段只下载原始产品包和匹配的 EOF 轨道文件,暂不包含 GeoTIFF 处理。 + 下载原始 Sentinel-1 产品必须提供 Earthdata 用户名和密码。你可以手填后直接下载,也可以先保存到本地配置。产品下载支持完整文件跳过、.part 断点续传和下载后校验。当前阶段只下载原始产品包和匹配的 EOF 轨道文件,暂不包含 GeoTIFF 处理。
diff --git a/uv.lock b/uv.lock index 790e06e..3a14d96 100644 --- a/uv.lock +++ b/uv.lock @@ -332,6 +332,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/0f/1c34a74c8d07136f0d729ffe5e1fdab04fbdaa7684f61a92f92511a84a15/pydantic_core-2.46.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff", size = 2184144, upload-time = "2026-04-20T14:42:57Z" }, ] +[[package]] +name = "pyproj" +version = "3.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/90/67bd7260b4ea9b8b20b4f58afef6c223ecb3abf368eb4ec5bc2cdef81b49/pyproj-3.7.2.tar.gz", hash = "sha256:39a0cf1ecc7e282d1d30f36594ebd55c9fae1fda8a2622cee5d100430628f88c", size = 226279, upload-time = "2025-08-14T12:05:42.18Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/bd/f205552cd1713b08f93b09e39a3ec99edef0b3ebbbca67b486fdf1abe2de/pyproj-3.7.2-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:2514d61f24c4e0bb9913e2c51487ecdaeca5f8748d8313c933693416ca41d4d5", size = 6227022, upload-time = "2025-08-14T12:03:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/75/4c/9a937e659b8b418ab573c6d340d27e68716928953273e0837e7922fcac34/pyproj-3.7.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:8693ca3892d82e70de077701ee76dd13d7bca4ae1c9d1e739d72004df015923a", size = 4625810, upload-time = "2025-08-14T12:03:53.808Z" }, + { url = "https://files.pythonhosted.org/packages/c0/7d/a9f41e814dc4d1dc54e95b2ccaf0b3ebe3eb18b1740df05fe334724c3d89/pyproj-3.7.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5e26484d80fea56273ed1555abaea161e9661d81a6c07815d54b8e883d4ceb25", size = 9638694, upload-time = "2025-08-14T12:03:55.669Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ab/9bdb4a6216b712a1f9aab1c0fcbee5d3726f34a366f29c3e8c08a78d6b70/pyproj-3.7.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:281cb92847814e8018010c48b4069ff858a30236638631c1a91dd7bfa68f8a8a", size = 9493977, upload-time = "2025-08-14T12:03:57.937Z" }, + { url = "https://files.pythonhosted.org/packages/c9/db/2db75b1b6190f1137b1c4e8ef6a22e1c338e46320f6329bfac819143e063/pyproj-3.7.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9c8577f0b7bb09118ec2e57e3babdc977127dd66326d6c5d755c76b063e6d9dc", size = 10841151, upload-time = "2025-08-14T12:04:00.271Z" }, + { url = "https://files.pythonhosted.org/packages/89/f7/989643394ba23a286e9b7b3f09981496172f9e0d4512457ffea7dc47ffc7/pyproj-3.7.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a23f59904fac3a5e7364b3aa44d288234af267ca041adb2c2b14a903cd5d3ac5", size = 10751585, upload-time = "2025-08-14T12:04:02.228Z" }, + { url = "https://files.pythonhosted.org/packages/53/6d/ad928fe975a6c14a093c92e6a319ca18f479f3336bb353a740bdba335681/pyproj-3.7.2-cp311-cp311-win32.whl", hash = "sha256:f2af4ed34b2cf3e031a2d85b067a3ecbd38df073c567e04b52fa7a0202afde8a", size = 5908533, upload-time = "2025-08-14T12:04:04.821Z" }, + { url = "https://files.pythonhosted.org/packages/79/e0/b95584605cec9ed50b7ebaf7975d1c4ddeec5a86b7a20554ed8b60042bd7/pyproj-3.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:0b7cb633565129677b2a183c4d807c727d1c736fcb0568a12299383056e67433", size = 6320742, upload-time = "2025-08-14T12:04:06.357Z" }, + { url = "https://files.pythonhosted.org/packages/b7/4d/536e8f93bca808175c2d0a5ac9fdf69b960d8ab6b14f25030dccb07464d7/pyproj-3.7.2-cp311-cp311-win_arm64.whl", hash = "sha256:38b08d85e3a38e455625b80e9eb9f78027c8e2649a21dec4df1f9c3525460c71", size = 6245772, upload-time = "2025-08-14T12:04:08.365Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ab/9893ea9fb066be70ed9074ae543914a618c131ed8dff2da1e08b3a4df4db/pyproj-3.7.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:0a9bb26a6356fb5b033433a6d1b4542158fb71e3c51de49b4c318a1dff3aeaab", size = 6219832, upload-time = "2025-08-14T12:04:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/53/78/4c64199146eed7184eb0e85bedec60a4aa8853b6ffe1ab1f3a8b962e70a0/pyproj-3.7.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:567caa03021178861fad27fabde87500ec6d2ee173dd32f3e2d9871e40eebd68", size = 4620650, upload-time = "2025-08-14T12:04:11.978Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ac/14a78d17943898a93ef4f8c6a9d4169911c994e3161e54a7cedeba9d8dde/pyproj-3.7.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c203101d1dc3c038a56cff0447acc515dd29d6e14811406ac539c21eed422b2a", size = 9667087, upload-time = "2025-08-14T12:04:13.964Z" }, + { url = "https://files.pythonhosted.org/packages/b8/be/212882c450bba74fc8d7d35cbd57e4af84792f0a56194819d98106b075af/pyproj-3.7.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1edc34266c0c23ced85f95a1ee8b47c9035eae6aca5b6b340327250e8e281630", size = 9552797, upload-time = "2025-08-14T12:04:16.624Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c0/c0f25c87b5d2a8686341c53c1792a222a480d6c9caf60311fec12c99ec26/pyproj-3.7.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa9f26c21bc0e2dc3d224cb1eb4020cf23e76af179a7c66fea49b828611e4260", size = 10837036, upload-time = "2025-08-14T12:04:18.733Z" }, + { url = "https://files.pythonhosted.org/packages/5d/37/5cbd6772addde2090c91113332623a86e8c7d583eccb2ad02ea634c4a89f/pyproj-3.7.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9428b318530625cb389b9ddc9c51251e172808a4af79b82809376daaeabe5e9", size = 10775952, upload-time = "2025-08-14T12:04:20.709Z" }, + { url = "https://files.pythonhosted.org/packages/69/a1/dc250e3cf83eb4b3b9a2cf86fdb5e25288bd40037ae449695550f9e96b2f/pyproj-3.7.2-cp312-cp312-win32.whl", hash = "sha256:b3d99ed57d319da042f175f4554fc7038aa4bcecc4ac89e217e350346b742c9d", size = 5898872, upload-time = "2025-08-14T12:04:22.485Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a6/6fe724b72b70f2b00152d77282e14964d60ab092ec225e67c196c9b463e5/pyproj-3.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:11614a054cd86a2ed968a657d00987a86eeb91fdcbd9ad3310478685dc14a128", size = 6312176, upload-time = "2025-08-14T12:04:24.736Z" }, + { url = "https://files.pythonhosted.org/packages/5d/68/915cc32c02a91e76d02c8f55d5a138d6ef9e47a0d96d259df98f4842e558/pyproj-3.7.2-cp312-cp312-win_arm64.whl", hash = "sha256:509a146d1398bafe4f53273398c3bb0b4732535065fa995270e52a9d3676bca3", size = 6233452, upload-time = "2025-08-14T12:04:27.287Z" }, + { url = "https://files.pythonhosted.org/packages/be/14/faf1b90d267cea68d7e70662e7f88cefdb1bc890bd596c74b959e0517a72/pyproj-3.7.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:19466e529b1b15eeefdf8ff26b06fa745856c044f2f77bf0edbae94078c1dfa1", size = 6214580, upload-time = "2025-08-14T12:04:28.804Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/da9a45b184d375f62667f62eba0ca68569b0bd980a0bb7ffcc1d50440520/pyproj-3.7.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:c79b9b84c4a626c5dc324c0d666be0bfcebd99f7538d66e8898c2444221b3da7", size = 4615388, upload-time = "2025-08-14T12:04:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e7/d2b459a4a64bca328b712c1b544e109df88e5c800f7c143cfbc404d39bfb/pyproj-3.7.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ceecf374cacca317bc09e165db38ac548ee3cad07c3609442bd70311c59c21aa", size = 9628455, upload-time = "2025-08-14T12:04:32.435Z" }, + { url = "https://files.pythonhosted.org/packages/f8/85/c2b1706e51942de19076eff082f8495e57d5151364e78b5bef4af4a1d94a/pyproj-3.7.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5141a538ffdbe4bfd157421828bb2e07123a90a7a2d6f30fa1462abcfb5ce681", size = 9514269, upload-time = "2025-08-14T12:04:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/34/38/07a9b89ae7467872f9a476883a5bad9e4f4d1219d31060f0f2b282276cbe/pyproj-3.7.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f000841e98ea99acbb7b8ca168d67773b0191de95187228a16110245c5d954d5", size = 10808437, upload-time = "2025-08-14T12:04:36.485Z" }, + { url = "https://files.pythonhosted.org/packages/12/56/fda1daeabbd39dec5b07f67233d09f31facb762587b498e6fc4572be9837/pyproj-3.7.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8115faf2597f281a42ab608ceac346b4eb1383d3b45ab474fd37341c4bf82a67", size = 10745540, upload-time = "2025-08-14T12:04:38.568Z" }, + { url = "https://files.pythonhosted.org/packages/0d/90/c793182cbba65a39a11db2ac6b479fe76c59e6509ae75e5744c344a0da9d/pyproj-3.7.2-cp313-cp313-win32.whl", hash = "sha256:f18c0579dd6be00b970cb1a6719197fceecc407515bab37da0066f0184aafdf3", size = 5896506, upload-time = "2025-08-14T12:04:41.059Z" }, + { url = "https://files.pythonhosted.org/packages/be/0f/747974129cf0d800906f81cd25efd098c96509026e454d4b66868779ab04/pyproj-3.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:bb41c29d5f60854b1075853fe80c58950b398d4ebb404eb532536ac8d2834ed7", size = 6310195, upload-time = "2025-08-14T12:04:42.974Z" }, + { url = "https://files.pythonhosted.org/packages/82/64/fc7598a53172c4931ec6edf5228280663063150625d3f6423b4c20f9daff/pyproj-3.7.2-cp313-cp313-win_arm64.whl", hash = "sha256:2b617d573be4118c11cd96b8891a0b7f65778fa7733ed8ecdb297a447d439100", size = 6230748, upload-time = "2025-08-14T12:04:44.491Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f0/611dd5cddb0d277f94b7af12981f56e1441bf8d22695065d4f0df5218498/pyproj-3.7.2-cp313-cp313t-macosx_13_0_x86_64.whl", hash = "sha256:d27b48f0e81beeaa2b4d60c516c3a1cfbb0c7ff6ef71256d8e9c07792f735279", size = 6241729, upload-time = "2025-08-14T12:04:46.274Z" }, + { url = "https://files.pythonhosted.org/packages/15/93/40bd4a6c523ff9965e480870611aed7eda5aa2c6128c6537345a2b77b542/pyproj-3.7.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:55a3610d75023c7b1c6e583e48ef8f62918e85a2ae81300569d9f104d6684bb6", size = 4652497, upload-time = "2025-08-14T12:04:48.203Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ae/7150ead53c117880b35e0d37960d3138fe640a235feb9605cb9386f50bb0/pyproj-3.7.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:8d7349182fa622696787cc9e195508d2a41a64765da9b8a6bee846702b9e6220", size = 9942610, upload-time = "2025-08-14T12:04:49.652Z" }, + { url = "https://files.pythonhosted.org/packages/d8/17/7a4a7eafecf2b46ab64e5c08176c20ceb5844b503eaa551bf12ccac77322/pyproj-3.7.2-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:d230b186eb876ed4f29a7c5ee310144c3a0e44e89e55f65fb3607e13f6db337c", size = 9692390, upload-time = "2025-08-14T12:04:51.731Z" }, + { url = "https://files.pythonhosted.org/packages/c3/55/ae18f040f6410f0ea547a21ada7ef3e26e6c82befa125b303b02759c0e9d/pyproj-3.7.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:237499c7862c578d0369e2b8ac56eec550e391a025ff70e2af8417139dabb41c", size = 11047596, upload-time = "2025-08-14T12:04:53.748Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2e/d3fff4d2909473f26ae799f9dda04caa322c417a51ff3b25763f7d03b233/pyproj-3.7.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8c225f5978abd506fd9a78eaaf794435e823c9156091cabaab5374efb29d7f69", size = 10896975, upload-time = "2025-08-14T12:04:55.875Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bc/8fc7d3963d87057b7b51ebe68c1e7c51c23129eee5072ba6b86558544a46/pyproj-3.7.2-cp313-cp313t-win32.whl", hash = "sha256:2da731876d27639ff9d2d81c151f6ab90a1546455fabd93368e753047be344a2", size = 5953057, upload-time = "2025-08-14T12:04:58.466Z" }, + { url = "https://files.pythonhosted.org/packages/cc/27/ea9809966cc47d2d51e6d5ae631ea895f7c7c7b9b3c29718f900a8f7d197/pyproj-3.7.2-cp313-cp313t-win_amd64.whl", hash = "sha256:f54d91ae18dd23b6c0ab48126d446820e725419da10617d86a1b69ada6d881d3", size = 6375414, upload-time = "2025-08-14T12:04:59.861Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/1ef0129fba9a555c658e22af68989f35e7ba7b9136f25758809efec0cd6e/pyproj-3.7.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fc52ba896cfc3214dc9f9ca3c0677a623e8fdd096b257c14a31e719d21ff3fdd", size = 6262501, upload-time = "2025-08-14T12:05:01.39Z" }, +] + +[[package]] +name = "pyshp" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/20/8b07bae73aaa0c3f5a2683ba6e23b46e977e2d33a88126d56bbcc2d135cd/pyshp-3.0.3.tar.gz", hash = "sha256:bf4678b13dd53578ed87669676a2fffeccbcded1ec8ff9cafb36d1b660f4b305", size = 2192568, upload-time = "2025-11-28T17:47:31.616Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/06/cad54e8ce758bd836ee5411691cbd49efeb9cc611b374670fce299519334/pyshp-3.0.3-py3-none-any.whl", hash = "sha256:28c8fac8c0c25bb0fecbbfd10ead7f319c2ff2f3b0b44a94f22bd2c93510ad42", size = 58465, upload-time = "2025-11-28T17:47:30.328Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -344,6 +400,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.27" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" }, +] + [[package]] name = "pytz" version = "2026.1.post1" @@ -459,6 +524,9 @@ source = { editable = "." } dependencies = [ { name = "asf-search" }, { name = "fastapi" }, + { name = "pyproj" }, + { name = "pyshp" }, + { name = "python-multipart" }, { name = "s1-orbits" }, { name = "shapely" }, { name = "uvicorn" }, @@ -468,6 +536,9 @@ dependencies = [ requires-dist = [ { name = "asf-search", specifier = ">=12.0.7" }, { name = "fastapi", specifier = ">=0.116.1" }, + { name = "pyproj", specifier = ">=3.7.0" }, + { name = "pyshp", specifier = ">=2.3.1" }, + { name = "python-multipart", specifier = ">=0.0.20" }, { name = "s1-orbits", specifier = ">=0.2.0" }, { name = "shapely", specifier = ">=2.1.2" }, { name = "uvicorn", specifier = ">=0.35.0" },