commit 60527e935fd965802f7aedcd4978fb28d807e894 Author: Harmon Date: Tue Apr 28 10:44:45 2026 +0800 Initial project implementation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e7f3d6c --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv + +# Local data sources and runtime state +全国行政区.geojson +.s1dl/ +.local-data/ +*.db +*.sqlite +*.sqlite3 +*.log +downloads/ +data/ +orbits/ +projects/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/README.md b/README.md new file mode 100644 index 0000000..d564ed6 --- /dev/null +++ b/README.md @@ -0,0 +1,401 @@ +# Sentinel Orbit Downloader + +一个本地可用的 Sentinel-1 数据与精密轨道下载工具,支持: + +- 按行政区和时间范围检索 Sentinel-1 +- 下载原始 Sentinel-1 产品 +- 下载匹配的精密轨道/恢复轨道 `.EOF` +- 用 SQLite 管理项目、任务、场景和下载记录 +- 通过本地 Web 页面完成日常操作 + +当前项目面向 Windows 本机使用场景,已经包含一键启动脚本。 + +## 当前能力 + +已实现: + +- 行政区 GeoJSON 导入 SQLite +- 按行政区检索 Sentinel-1 场景 +- 原始 Sentinel-1 产品下载 +- 精密轨道 `.EOF` 下载 +- 项目管理 +- 任务列表、任务详情、事件日志 +- 任务删除与清空 +- Earthdata 凭据本地保存 + +暂未实现: + +- GeoTIFF / `.tif` 处理与导出 +- 后处理流水线 + +也就是说,现在项目专注于“检索 + 下载原始数据 + 下载轨道”。 + +## 目录 + +- 项目根目录:`C:\Users\16216\Desktop\Code\sentinel-orbit-downloader` +- 默认数据库:`.s1dl\app.db` +- 默认本地数据目录:`.local-data\` +- 行政区源文件:`全国行政区.geojson` + +## 环境要求 + +- Windows +- Python 3.11 - 3.13 +- `uv` +- 网络可访问 ASF / Earthdata + +## 快速开始 + +### 1. 一键启动 + +直接双击: + +- `Start-SentinelUI.cmd` + +它会自动: + +1. 检查 `uv` +2. 执行 `uv sync` +3. 启动本地服务 +4. 打开浏览器 + +停止服务时双击: + +- `Stop-SentinelUI.cmd` + +启动脚本优先尝试这些端口: + +- `8011` +- `8010` +- `8012` +- `8013` +- `8014` + +如果端口已被占用,会自动换到下一个可用端口。 + +### 2. 手动启动 + +```powershell +cd C:\Users\16216\Desktop\Code\sentinel-orbit-downloader +uv sync +$env:S1DL_API_PORT="8011" +uv run s1-web +``` + +打开页面: + +```text +http://127.0.0.1:8011/ +``` + +Swagger 文档: + +```text +http://127.0.0.1:8011/docs +``` + +## 首次使用流程 + +### 1. 导入行政区 + +项目根目录下已经放了: + +- `全国行政区.geojson` + +在页面中点击: + +- `导入行政区` + +也可以用命令行: + +```powershell +uv run s1-dl regions import +``` + +### 2. 创建项目 + +建议先建一个本地项目,例如: + +- 项目名称:`牡丹江 Sentinel-1 数据` +- 数据根目录:`.local-data\mudanjiang` + +### 3. 配置 Earthdata + +下载原始 Sentinel-1 产品必须有 NASA Earthdata 账号。 + +页面中可填写: + +- `Earthdata 用户名` +- `Earthdata 密码` + +然后点击: + +- `保存凭据` + +保存后: + +- 用户名和密码会写入本地 SQLite +- 密码不是明文保存 +- 密码使用当前 Windows 用户的 DPAPI 加密 + +如果不想保存,也可以每次手填。 + +### 4. Earthdata 首次授权 ASF + +很多第一次下载失败,不是用户名密码错,而是没有授权 ASF 应用访问 Earthdata。 + +如果任务日志出现类似错误: + +```text +Pre authorization required for this application +``` + +请先在浏览器登录 Earthdata,然后打开下面链接完成授权: + +```text +https://urs.earthdata.nasa.gov/approve_app?client_id=BO_n7nTIlMljdvU6kRRB3g +``` + +这是一次性的关键步骤。 + +### 5. 检索并下载 + +页面典型流程: + +1. 搜索行政区,例如 `牡丹江` +2. 选择时间范围 +3. 选择产品级别,如 `SLC` +4. 点击 `发起检索` +5. 选中任务 +6. 选择下载参数 +7. 点击 `启动下载` + +如果只想补精密轨道,不下载原始产品: + +- 把 `跳过产品` 设为 `是` + +## 页面功能说明 + +### 环境配置 + +- 导入行政区 GeoJSON +- 查看服务状态 + +### 项目 + +- 创建项目 +- 刷新项目列表 + +### 行政区选择 + +- 按名称或 `adcode` 搜索 +- 查看行政区范围框和中心点 + +### 检索参数 + +支持: + +- 开始日期 / 结束日期 +- 产品级别 +- 波束模式 +- 轨道方向 +- 极化 +- 相对轨道号 +- 最大结果数 +- 使用原始行政区边界 + +### 下载参数 + +支持: + +- 数据目录 +- 轨道目录 +- 跳过产品 +- 跳过轨道 +- 并发数 +- 忽略轨道失败 +- Earthdata 凭据保存 / 清除 + +### 任务区 + +支持: + +- 刷新任务 +- 刷新详情 +- 删除选中任务 +- 清空当前项目的非运行任务 + +## 输出结构 + +典型输出目录如下: + +```text +.local-data\mudanjiang\ + data\ # Sentinel-1 原始产品 + orbits\ # AUX_POEORB / AUX_RESORB EOF 文件 + manifest.csv +``` + +数据库中还会保存: + +- 项目信息 +- 搜索任务 +- 场景清单 +- 下载记录 +- 任务事件日志 + +## 命令行用法 + +### 同步环境 + +```powershell +uv sync +``` + +### 导入行政区 + +```powershell +uv run s1-dl regions import +``` + +### 搜索行政区 + +```powershell +uv run s1-dl regions search 牡丹江 +``` + +### 创建项目 + +```powershell +uv run s1-dl projects create ` + --name "牡丹江 Sentinel-1 数据" ` + --data-root ".local-data\mudanjiang" ` + --description "Mudanjiang Sentinel-1 scenes and orbits" +``` + +### 按行政区检索 + +牡丹江市 `adcode`: + +- `231000` + +```powershell +uv run s1-dl search ` + --project-id 1 ` + --region-adcode 231000 ` + --start 2024-01-01 ` + --end 2024-03-01 ` + --processing-level SLC ` + --max-results 20 ` + --manifest ".local-data\mudanjiang\manifest.csv" +``` + +### 直接下载 + +```powershell +uv run s1-dl download ` + --project-id 1 ` + --region-adcode 231000 ` + --start 2024-01-01 ` + --end 2024-03-01 ` + --processing-level SLC ` + --out ".local-data\mudanjiang-run" ` + --processes 2 +``` + +### 只补轨道 + +```powershell +uv run s1-dl orbits ` + --data-dir ".local-data\mudanjiang\data" ` + --out ".local-data\mudanjiang\orbits" +``` + +## 常见问题 + +### 1. 页面里任务全是 failed + +先看任务事件日志。 + +常见原因有两个: + +#### 原因 A:没填 Earthdata 用户名和密码 + +如果下载原始 Sentinel-1 产品,必须提供 Earthdata 凭据。 + +解决: + +- 页面填写用户名密码 +- 或保存到本地配置 +- 或设置环境变量 `EARTHDATA_USER` / `EARTHDATA_PASS` + +#### 原因 B:Earthdata 没授权 ASF + +如果日志里有: + +```text +Pre authorization required for this application +``` + +按前文的授权链接先批准 ASF 应用。 + +### 2. 为什么检索成功,但下载失败 + +因为检索 ASF 元数据不一定要求完整的下载授权;真正下载原始产品时才会触发 Earthdata 登录和授权校验。 + +### 3. 只想下载轨道,不想下载原始产品 + +页面里把: + +- `跳过产品` + +设成: + +- `是` + +### 4. 保存 Earthdata 凭据是否明文 + +不是。 + +当前实现: + +- 用户名保存在本地 SQLite +- 密码经过 Windows DPAPI 加密后保存 + +### 5. 为什么不支持 `.tif` + +当前项目只管原始 Sentinel-1 产品和轨道文件。 + +还没有做: + +- 解压 +- 预处理 +- GeoTIFF 导出 + +## 开发与维护 + +### 主要入口 + +- Web API: `src/sentinel_orbit_downloader/api/app.py` +- Web 页面: `src/sentinel_orbit_downloader/web/` +- 下载管理: `src/sentinel_orbit_downloader/services/download_manager.py` +- 项目/任务存储: `src/sentinel_orbit_downloader/services/project_store.py` +- Earthdata 本地设置: `src/sentinel_orbit_downloader/services/settings_store.py` + +### 本地脚本 + +- `Start-SentinelUI.cmd` +- `Start-SentinelUI.ps1` +- `Stop-SentinelUI.cmd` +- `Stop-SentinelUI.ps1` + +## 说明 + +这个项目当前阶段的目标很明确: + +- 把“按区域检索 Sentinel-1” +- “下载原始数据” +- “下载精密轨道” +- “管理任务记录” + +这四件事先稳定做好。 diff --git a/Start-SentinelUI.cmd b/Start-SentinelUI.cmd new file mode 100644 index 0000000..929675c --- /dev/null +++ b/Start-SentinelUI.cmd @@ -0,0 +1,5 @@ +@echo off +setlocal +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Start-SentinelUI.ps1" +if errorlevel 1 pause +endlocal diff --git a/Start-SentinelUI.ps1 b/Start-SentinelUI.ps1 new file mode 100644 index 0000000..f39c1b1 --- /dev/null +++ b/Start-SentinelUI.ps1 @@ -0,0 +1,233 @@ +param( + [int[]]$PreferredPorts = @(8011, 8010, 8012, 8013, 8014), + [switch]$NoBrowser, + [switch]$ForceRestart +) + +$ErrorActionPreference = "Stop" + +function Write-Info { + param([string]$Message) + Write-Host "[launcher] $Message" +} + +function Get-UvPath { + $command = Get-Command uv -ErrorAction SilentlyContinue + if ($command -and $command.Source) { + return $command.Source + } + + $fallback = Join-Path $HOME ".local\bin\uv.exe" + if (Test-Path -LiteralPath $fallback) { + return $fallback + } + + throw "uv.exe was not found. Install uv first or add it to PATH." +} + +function Test-Health { + param([int]$Port) + + try { + $null = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/api/health" -TimeoutSec 2 + return $true + } catch { + return $false + } +} + +function Get-FreePort { + param([int[]]$Candidates) + + foreach ($port in $Candidates) { + $listener = $null + try { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $port) + $listener.Start() + return $port + } catch { + continue + } finally { + if ($listener) { + $listener.Stop() + } + } + } + + throw "No free port was found in: $($Candidates -join ', ')" +} + +function Get-RunningProcess { + param([int]$Id) + + if ($Id -le 0) { + return $null + } + + try { + return Get-Process -Id $Id -ErrorAction Stop + } catch { + return $null + } +} + +function Load-State { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path)) { + return $null + } + + try { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } catch { + return $null + } +} + +function Save-State { + param( + [string]$Path, + [int]$ProcessId, + [int]$Port, + [string]$Url + ) + + $payload = [ordered]@{ + pid = $ProcessId + port = $Port + url = $Url + started_at = (Get-Date).ToString("s") + } + + $payload | ConvertTo-Json | Set-Content -LiteralPath $Path -Encoding UTF8 +} + +function Remove-State { + param([string]$Path) + + if (Test-Path -LiteralPath $Path) { + Remove-Item -LiteralPath $Path -Force + } +} + +$projectRoot = Split-Path -Parent $PSCommandPath +$runtimeDir = Join-Path $projectRoot ".s1dl" +$statePath = Join-Path $runtimeDir "web-ui-state.json" +$stdoutPath = Join-Path $runtimeDir "web-ui.stdout.log" +$stderrPath = Join-Path $runtimeDir "web-ui.stderr.log" + +New-Item -ItemType Directory -Path $runtimeDir -Force | Out-Null + +$state = Load-State -Path $statePath + +if ($state -and -not $ForceRestart) { + $existingProcess = Get-RunningProcess -Id ([int]$state.pid) + if ($existingProcess -and (Test-Health -Port ([int]$state.port))) { + $existingUrl = "http://127.0.0.1:$($state.port)/" + Write-Info "UI is already running at $existingUrl" + if (-not $NoBrowser) { + Start-Process $existingUrl | Out-Null + } + exit 0 + } +} + +foreach ($port in $PreferredPorts) { + if (Test-Health -Port $port) { + $url = "http://127.0.0.1:$port/" + Write-Info "Found an existing running UI at $url" + Save-State -Path $statePath -ProcessId 0 -Port $port -Url $url + if (-not $NoBrowser) { + Start-Process $url | Out-Null + } + exit 0 + } +} + +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" +& $uvPath sync --directory $projectRoot +if ($LASTEXITCODE -ne 0) { + throw "uv sync failed with exit code $LASTEXITCODE" +} + +$pythonPath = Join-Path $projectRoot ".venv\Scripts\python.exe" +if (-not (Test-Path -LiteralPath $pythonPath)) { + throw "Python executable was not found at $pythonPath" +} + +$portToUse = Get-FreePort -Candidates $PreferredPorts +$url = "http://127.0.0.1:$portToUse/" + +if (Test-Path -LiteralPath $stdoutPath) { + Remove-Item -LiteralPath $stdoutPath -Force +} +if (Test-Path -LiteralPath $stderrPath) { + Remove-Item -LiteralPath $stderrPath -Force +} + +$arguments = @( + "-m", + "uvicorn", + "sentinel_orbit_downloader.api.app:app", + "--host", + "127.0.0.1", + "--port", + "$portToUse" +) + +Write-Info "Starting UI at $url" +$process = Start-Process ` + -FilePath $pythonPath ` + -ArgumentList $arguments ` + -WorkingDirectory $projectRoot ` + -WindowStyle Hidden ` + -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath ` + -PassThru + +$started = $false +for ($i = 0; $i -lt 30; $i++) { + Start-Sleep -Seconds 1 + if (Test-Health -Port $portToUse) { + $started = $true + break + } +} + +if (-not $started) { + $running = Get-RunningProcess -Id $process.Id + if ($running) { + Stop-Process -Id $running.Id -Force + } + + $stderrTail = "" + if (Test-Path -LiteralPath $stderrPath) { + $stderrTail = (Get-Content -LiteralPath $stderrPath -Tail 20) -join [Environment]::NewLine + } + + if ($stderrTail) { + throw "UI failed to start.`n$stderrTail" + } + + throw "UI failed to start." +} + +Save-State -Path $statePath -ProcessId $process.Id -Port $portToUse -Url $url +Write-Info "UI is ready: $url" + +if (-not $NoBrowser) { + Start-Process $url | Out-Null +} diff --git a/Stop-SentinelUI.cmd b/Stop-SentinelUI.cmd new file mode 100644 index 0000000..905591d --- /dev/null +++ b/Stop-SentinelUI.cmd @@ -0,0 +1,5 @@ +@echo off +setlocal +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Stop-SentinelUI.ps1" +if errorlevel 1 pause +endlocal diff --git a/Stop-SentinelUI.ps1 b/Stop-SentinelUI.ps1 new file mode 100644 index 0000000..b2a845b --- /dev/null +++ b/Stop-SentinelUI.ps1 @@ -0,0 +1,43 @@ +param() + +$ErrorActionPreference = "Stop" + +function Write-Info { + param([string]$Message) + Write-Host "[launcher] $Message" +} + +function Get-RunningProcess { + param([int]$Id) + + if ($Id -le 0) { + return $null + } + + try { + return Get-Process -Id $Id -ErrorAction Stop + } catch { + return $null + } +} + +$projectRoot = Split-Path -Parent $PSCommandPath +$statePath = Join-Path $projectRoot ".s1dl\web-ui-state.json" + +if (-not (Test-Path -LiteralPath $statePath)) { + Write-Info "No tracked UI process was found." + exit 0 +} + +$state = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json +$process = Get-RunningProcess -Id ([int]$state.pid) + +if ($process) { + Write-Info "Stopping UI process $($process.Id)" + Stop-Process -Id $process.Id -Force +} else { + Write-Info "Tracked process is not running." +} + +Remove-Item -LiteralPath $statePath -Force +Write-Info "Done." diff --git a/docs/PROJECT_DESIGN.md b/docs/PROJECT_DESIGN.md new file mode 100644 index 0000000..17a74ac --- /dev/null +++ b/docs/PROJECT_DESIGN.md @@ -0,0 +1,818 @@ +# Sentinel 数据与精密轨道下载管理系统设计 + +## 目标 + +把当前命令行下载器扩展成一个本地化的 Sentinel-1 数据管理系统。系统需要支持按行政区、手绘范围、bbox、GeoJSON/WKT 查询影像,选择下载任务,自动下载 Sentinel-1 数据和对应精密轨道文件,并长期管理任务、文件、状态和日志。 + +第一阶段重点服务 Sentinel-1 SLC/GRD 数据和 `AUX_POEORB` 精密轨道;后续架构保留接入 Sentinel-2/3、Copernicus Data Space、批量处理流程的空间。 + +## 当前基础 + +已有能力: + +- `s1-dl search`:通过 ASF Search 按 AOI 和时间查询 Sentinel-1。 +- `s1-dl download`:查询后下载 Sentinel-1 产品,并下载匹配轨道。 +- `s1-dl orbits`:为已有场景名、`.zip`、`.SAFE` 补下载轨道。 +- Python 环境由 `uv` 管理。 + +项目下已有 `全国行政区.geojson`: + +- 文件大小约 53MB。 +- 顶层类型为 `FeatureCollection`。 +- 要素数:477。 +- 常见属性:`adcode`、`name`、`center`、`centroid`、`childrenNum`、`level`、`parent`、`acroutes`、`treeID`。 +- `level` 分布:多数为 `city`,少量 `district`,省级要素不完整。 +- 牡丹江市要素:`adcode=231000`、`name=牡丹江市`、`level=city`、`parent.adcode=230000`。 +- 牡丹江市边界范围:`128.232144,43.425798,131.310629,45.9875`。 + +设计结论:不能每次页面加载都直接解析 53MB GeoJSON。应在首次启动或数据更新时导入 SQLite,建立行政区索引、bbox、简化 WKT,并用内置省份 adcode 表补齐省级树。 + +## 产品形态 + +推荐形态:本地 Web 管理器 + 保留 CLI。 + +```text +浏览器页面 + 区域选择、地图查看、查询条件、结果表格、任务监控 + +FastAPI 后端 + 行政区查询、ASF 查询、下载任务、轨道匹配、文件管理 + +SQLite 数据库 + 项目、AOI、搜索任务、场景、下载记录、日志、设置 + +文件仓库 + Sentinel-1 zip、轨道 EOF、manifest、任务日志 + +CLI + 保留自动化和批处理入口,复用后端 service 层 +``` + +前端建议使用 Vite + React + TypeScript + Leaflet/MapLibre;后端使用 FastAPI + SQLAlchemy/SQLModel + SQLite。这样后端仍是 Python,能复用现有 ASF 和轨道下载逻辑;前端只负责交互和展示。 + +如果要先快速交付,也可以用 Streamlit 做 MVP。但作为长期项目,FastAPI + 独立前端更适合任务状态、日志、地图交互和后续扩展。 + +## 核心流程 + +### 1. 首次启动 + +1. 检查 `app.db` 是否存在。 +2. 检查行政区索引版本。 +3. 如果未导入,读取 `全国行政区.geojson`。 +4. 为每个要素计算: + - 原始 GeoJSON 几何。 + - bbox。 + - 中心点。 + - 原始 WKT。 + - 查询用简化 WKT。 + - 几何 hash。 +5. 根据 `parent.adcode` 和内置省份表生成省-市-区县树。 +6. 写入 `regions` 表。 + +### 2. 创建项目 + +用户创建一个项目,例如: + +```text +项目名:牡丹江积雪/形变数据 +数据根目录:D:\Sentinel\Mudanjiang +默认产品:SLC +默认波束:IW +默认下载并发:4 +``` + +项目用于隔离任务、下载目录和 manifest。 + +### 3. 选择 AOI + +支持四种来源: + +- 行政区:省/市/区县选择,例如 `黑龙江省 -> 牡丹江市`。 +- bbox:输入 `min_lon,min_lat,max_lon,max_lat`。 +- 上传文件:GeoJSON/WKT。 +- 地图绘制:画矩形或多边形。 + +对于行政区 AOI,系统默认使用行政区边界的简化 WKT 查询 ASF。页面允许切换: + +- 精确边界:减少周边误匹配,但 WKT 更长。 +- 简化边界:默认推荐。 +- bbox:速度快,但会包含边界外数据。 + +### 4. 查询 Sentinel-1 + +用户设置: + +- 时间范围。 +- 产品级别:`SLC`、`GRD_HD`、`GRD_MD`、`OCN`。 +- 波束:默认 `IW`。 +- 升降轨:`ASCENDING` / `DESCENDING` / 不限。 +- 极化:`VV`、`VH`、`VV+VH` 等。 +- 相对轨道号:可选。 +- 最大结果数。 + +后端调用 ASF Search,结果写入: + +- `search_jobs` +- `scenes` +- `job_scenes` + +前端展示可筛选表格: + +- 场景名。 +- 开始/结束时间。 +- 卫星:S1A/S1B/S1C/S1D。 +- 产品级别。 +- 升降轨。 +- 相对轨道。 +- 极化。 +- 文件大小。 +- 是否已有本地产品。 +- 是否已有轨道。 + +### 5. 创建下载任务 + +用户勾选场景后创建下载任务。任务分三段: + +1. 产品下载。 +2. 轨道下载。 +3. 文件核查和状态归档。 + +下载任务需要支持: + +- 暂停。 +- 取消。 +- 失败重试。 +- 跳过已存在文件。 +- 单独补轨道。 +- 只查询不下载。 +- 只下载轨道。 + +### 6. 下载数据 + +产品下载通过 ASF Session 使用 NASA Earthdata 账号。账号密码不写入数据库明文,优先使用: + +- 环境变量:`EARTHDATA_USER`、`EARTHDATA_PASS`。 +- Windows Credential Manager/keyring。 +- 页面临时输入,只保存在内存 session。 + +下载目录按项目和任务组织: + +```text +\ + projects\ + \ + jobs\ + ___\ + data\ + S1A_....zip + orbits\ + S1A_OPER_AUX_POEORB_....EOF + manifest.csv + manifest.json + logs\ + job.log +``` + +同一个场景如果已在仓库里存在,系统通过 `scene_id` 和文件路径复用,不重复下载。 + +### 7. 下载精密轨道 + +轨道不是按位置查询,而是按 Sentinel-1 场景名匹配: + +```text +场景名 -> 卫星平台 + 成像时间 -> AUX_POEORB EOF +``` + +系统行为: + +- 默认优先 `AUX_POEORB`。 +- 如果精密轨道暂不可用,可允许回退 `AUX_RESORB`,并在 UI 中明确标记。 +- 轨道下载失败不应阻断已下载产品,但任务状态应显示 `产品完成 / 轨道失败`。 +- 支持后续“一键补齐精密轨道”。 + +## 系统架构 + +### 后端模块 + +建议目录: + +```text +src/sentinel_orbit_downloader/ + api/ + app.py # FastAPI app + routes_regions.py + routes_projects.py + routes_search.py + routes_downloads.py + schemas.py # API DTO + core/ + config.py # 路径、并发、凭据配置 + paths.py # 文件目录规范 + logging.py + domain/ + enums.py + models.py # 领域对象,不绑定数据库 + services/ + region_catalog.py # 行政区导入和查询 + aoi.py # AOI 转换、简化、bbox + asf_client.py # ASF 查询和下载适配 + orbit_client.py # s1-orbits 适配 + download_manager.py # 下载编排 + job_runner.py # 后台任务执行 + inventory.py # 本地文件扫描和去重 + storage/ + db.py + orm.py + repositories.py + migrations/ + cli.py # 继续保留 CLI,调用 services +``` + +前端建议目录: + +```text +frontend/ + package.json + src/ + api/ + components/ + pages/ + styles/ +``` + +### 数据库模型 + +#### settings + +保存本地配置。 + +| 字段 | 说明 | +| --- | --- | +| key | 配置名 | +| value | 配置值 | +| updated_at | 更新时间 | + +#### regions + +行政区索引。 + +| 字段 | 说明 | +| --- | --- | +| adcode | 行政区代码 | +| name | 名称 | +| level | `country/province/city/district/custom` | +| parent_adcode | 父级代码 | +| province_adcode | 省级代码 | +| center_lon/center_lat | 中心点 | +| min_lon/min_lat/max_lon/max_lat | bbox | +| geometry_json | 原始几何 | +| wkt | 原始 WKT | +| simplified_wkt | ASF 查询用简化 WKT | +| geometry_hash | 几何 hash | + +#### projects + +用户项目。 + +| 字段 | 说明 | +| --- | --- | +| id | 项目 ID | +| name | 项目名 | +| slug | 目录友好名称 | +| data_root | 数据根目录 | +| description | 说明 | +| created_at/updated_at | 时间 | + +#### aois + +项目 AOI。 + +| 字段 | 说明 | +| --- | --- | +| id | AOI ID | +| project_id | 项目 ID | +| name | AOI 名称 | +| source_type | `region/bbox/upload/drawn` | +| region_adcode | 可选行政区代码 | +| wkt | 查询 WKT | +| bbox | bbox | +| geometry_json | 几何 | +| simplification_tolerance | 简化参数 | + +#### search_jobs + +一次查询任务。 + +| 字段 | 说明 | +| --- | --- | +| id | 查询 ID | +| project_id | 项目 ID | +| aoi_id | AOI ID | +| start_time/end_time | 查询时间范围 | +| processing_level | 产品级别 | +| beam_mode | 波束 | +| flight_direction | 升降轨 | +| polarization | 极化 | +| relative_orbit | 相对轨道 | +| max_results | 最大结果 | +| status | `pending/running/succeeded/failed/canceled` | +| result_count | 结果数 | +| error_message | 错误 | +| created_at/finished_at | 时间 | + +#### scenes + +ASF 场景主表。 + +| 字段 | 说明 | +| --- | --- | +| scene_id | Sentinel-1 场景名 | +| platform | S1A/S1B/S1C/S1D | +| start_time/stop_time | 成像时间 | +| processing_level | 产品级别 | +| beam_mode | 波束 | +| flight_direction | 升降轨 | +| polarization | 极化 | +| relative_orbit | 相对轨道 | +| size_bytes | 文件大小 | +| download_url | ASF 下载 URL | +| geometry_json | 场景 footprint | +| properties_json | ASF 原始属性 | + +#### job_scenes + +查询任务和场景的关系。 + +| 字段 | 说明 | +| --- | --- | +| job_id | 查询 ID | +| scene_id | 场景 ID | +| selected | 用户是否选择下载 | +| product_status | 产品下载状态 | +| orbit_status | 轨道下载状态 | +| product_path | 本地产品路径 | +| orbit_path | 本地轨道路径 | + +#### downloads + +下载记录。 + +| 字段 | 说明 | +| --- | --- | +| id | 下载 ID | +| job_id | 任务 ID | +| scene_id | 场景 ID | +| kind | `product/orbit` | +| status | `queued/running/succeeded/failed/skipped/canceled` | +| url | 下载 URL | +| path | 本地路径 | +| bytes_total/bytes_done | 进度 | +| attempt_count | 尝试次数 | +| error_message | 错误 | +| started_at/finished_at | 时间 | + +#### job_events + +任务日志。 + +| 字段 | 说明 | +| --- | --- | +| id | 日志 ID | +| job_id | 任务 ID | +| level | `info/warning/error` | +| message | 日志内容 | +| created_at | 时间 | + +## API 设计 + +### 行政区 + +```text +GET /api/regions/tree +GET /api/regions/search?q=牡丹江 +GET /api/regions/{adcode} +GET /api/regions/{adcode}/geometry?simplified=true +POST /api/regions/reindex +``` + +### 项目和 AOI + +```text +GET /api/projects +POST /api/projects +GET /api/projects/{id} +PATCH /api/projects/{id} +DELETE /api/projects/{id} + +GET /api/projects/{id}/aois +POST /api/projects/{id}/aois +``` + +### 查询 + +```text +POST /api/search-jobs +GET /api/search-jobs/{id} +GET /api/search-jobs/{id}/scenes +POST /api/search-jobs/{id}/select-scenes +``` + +### 下载 + +```text +POST /api/download-jobs +GET /api/download-jobs/{id} +GET /api/download-jobs/{id}/events +POST /api/download-jobs/{id}/pause +POST /api/download-jobs/{id}/resume +POST /api/download-jobs/{id}/cancel +POST /api/download-jobs/{id}/retry +POST /api/scenes/{scene_id}/download-orbit +``` + +### 系统状态 + +```text +GET /api/health +GET /api/settings +PUT /api/settings +GET /api/inventory/scan +``` + +## 前端页面设计 + +### 页面 1:首页 / 仪表盘 + +显示: + +- 项目列表。 +- 最近查询任务。 +- 最近下载任务。 +- 下载成功/失败数量。 +- 数据目录占用。 +- 凭据状态和 ASF 连通性。 + +### 页面 2:项目详情 + +显示: + +- 项目信息。 +- AOI 列表。 +- 历史任务。 +- 本地数据清单。 +- 一键新建查询。 + +### 页面 3:新建查询 + +布局: + +```text +左侧:区域选择和查询参数 +右侧:地图 +底部:查询按钮和参数摘要 +``` + +区域选择: + +- 省份下拉。 +- 城市下拉。 +- 搜索框:输入 `牡丹江` 可以直接定位。 +- 上传 GeoJSON。 +- bbox 输入。 +- 地图绘制。 + +查询参数: + +- 时间范围。 +- 产品类型。 +- 波束。 +- 升降轨。 +- 极化。 +- 相对轨道。 +- 最大结果数。 + +### 页面 4:查询结果 + +功能: + +- 表格排序、筛选、勾选。 +- 地图显示选中场景 footprint。 +- 显示本地文件存在状态。 +- 下载选中产品。 +- 只下载轨道。 +- 导出 manifest。 + +### 页面 5:下载任务 + +功能: + +- 进度条。 +- 当前下载文件。 +- 产品状态。 +- 轨道状态。 +- 失败原因。 +- 暂停/取消/重试。 +- 打开本地目录。 + +### 页面 6:数据仓库 + +功能: + +- 按项目、区域、时间、产品类型筛选本地数据。 +- 查看每景产品和轨道是否匹配。 +- 补下载缺失轨道。 +- 删除任务记录但保留文件。 +- 扫描外部已有数据入库。 + +## 文件和命名规范 + +项目目录: + +```text +\ + app.db + app.log + regions\ + regions_index_version.json + projects\ + \ + project.json + aois\ + jobs\ + __\ + data\ + orbits\ + manifests\ + logs\ +``` + +任务目录名示例: + +```text +000001_mudanjiang_20240101_20240301_s1_slc +``` + +manifest: + +- `manifest.csv`:给用户打开查看。 +- `manifest.json`:完整机器可读版本。 + +## 状态机 + +查询任务: + +```text +pending -> running -> succeeded + -> failed + -> canceled +``` + +下载任务: + +```text +queued -> running -> succeeded + -> partial_failed + -> failed + -> paused + -> canceled +``` + +单文件下载: + +```text +queued -> running -> succeeded + -> skipped + -> failed + -> canceled +``` + +产品和轨道状态分开记录。一个任务可以是: + +```text +产品全部成功,轨道部分失败 +``` + +这种情况应显示为 `partial_failed`,并允许单独补轨道。 + +## 关键工程决策 + +### 行政区边界处理 + +`全国行政区.geojson` 不作为运行时主数据源,而是导入源。导入后使用 SQLite 查询。 + +对于 ASF 查询,默认使用 `simplified_wkt`。原因: + +- 原始边界可能很长。 +- ASF 对复杂 WKT 查询更慢。 +- Sentinel-1 景幅较大,过细边界通常没有必要。 + +简化参数建议: + +- 默认 tolerance:`0.01` 度。 +- 牡丹江市原始 WKT 约 10755 字符,`0.01` 简化后约 5694 字符。 +- 用户可在高级设置中切换为原始边界或 bbox。 + +### 下载幂等 + +任何下载开始前先检查: + +- 数据库中是否已有该 `scene_id`。 +- 本地目标文件是否存在。 +- 文件大小是否合理。 +- 已有轨道是否覆盖该场景时间。 + +存在则标记 `skipped`,避免重复下载。 + +### 凭据 + +不把 Earthdata 密码明文写入 SQLite。 + +第一版: + +- 支持环境变量。 +- 支持页面临时输入。 + +第二版: + +- 接入 `keyring`,在 Windows 上使用凭据管理器。 + +### 后台任务 + +本地单用户应用可以先用进程内任务队列: + +- `asyncio.Queue` 或 `ThreadPoolExecutor`。 +- 下载任务串行调度,单任务内部并发下载。 +- 任务状态写 SQLite。 + +以后如果需要更强任务管理,再接入 RQ/Celery,但第一版不需要。 + +## 实现阶段 + +### 阶段 1:服务层重构 + +目标:把现有 CLI 逻辑拆成可复用 service。 + +任务: + +- 新增 `services/asf_client.py`。 +- 新增 `services/orbit_client.py`。 +- 新增 `services/aoi.py`。 +- CLI 改为调用 service。 +- 保持现有 CLI 行为不变。 +- 增加基础单元测试。 + +验收: + +- `uv run s1-dl search` 仍可用。 +- `uv run s1-dl download` 仍可用。 +- `uv run s1-dl orbits` 仍可用。 + +### 阶段 2:行政区索引 + +目标:支持从 `全国行政区.geojson` 快速检索牡丹江等行政区。 + +任务: + +- 新增 SQLite。 +- 新增 `regions` 表。 +- 新增导入命令:`s1-dl regions import 全国行政区.geojson`。 +- 新增搜索命令:`s1-dl regions search 牡丹江`。 +- 新增导出 AOI 命令:`s1-dl regions export 231000 --format wkt`。 + +验收: + +- 能查到 `牡丹江市 adcode=231000`。 +- 能用 `231000` 直接发起查询。 + +### 阶段 3:项目和任务数据库 + +目标:所有查询和下载有记录可追踪。 + +任务: + +- 新增 `projects`、`aois`、`search_jobs`、`scenes`、`job_scenes`、`downloads`、`job_events`。 +- 查询结果入库。 +- 下载结果入库。 +- 生成规范目录和 manifest。 + +验收: + +- 查询牡丹江后,数据库可看到任务和场景。 +- 下载后,数据库记录产品和轨道路径。 + +### 阶段 4:FastAPI 后端 + +目标:提供本地 Web API。 + +任务: + +- 新增 `api/app.py`。 +- 实现行政区、项目、查询、下载接口。 +- 提供 Swagger 文档。 +- 提供 `uv run s1-web` 启动命令。 + +验收: + +- `http://127.0.0.1:8000/docs` 可访问。 +- API 能返回牡丹江边界和查询结果。 + +### 阶段 5:前端 MVP + +目标:页面完成主要流程。 + +任务: + +- 项目列表。 +- 行政区选择。 +- 地图显示边界。 +- 查询表单。 +- 查询结果表格。 +- 创建下载任务。 +- 下载任务进度页。 + +验收: + +- 用户能在页面选择 `黑龙江省 -> 牡丹江市`。 +- 设置时间范围。 +- 查询 Sentinel-1。 +- 勾选场景并下载数据和轨道。 + +### 阶段 6:管理和增强 + +目标:长期可用。 + +任务: + +- 文件仓库扫描。 +- 缺失轨道补齐。 +- 失败重试。 +- 任务日志查看。 +- keyring 凭据管理。 +- 导出任务报告。 +- 支持 Copernicus Data Space 作为备用数据源。 + +## 牡丹江市示例流程 + +1. 用户打开本地页面。 +2. 进入项目 `牡丹江 Sentinel-1 数据`。 +3. 选择行政区: + +```text +黑龙江省 -> 牡丹江市 +``` + +4. 设置: + +```text +时间:2024-01-01 至 2024-03-01 +产品:SLC +波束:IW +升降轨:不限 +极化:不限 +``` + +5. 点击查询。 +6. 页面展示 ASF 返回的 Sentinel-1 场景。 +7. 用户勾选需要的场景。 +8. 点击下载。 +9. 系统下载: + +```text +data\S1A_....zip +orbits\S1A_OPER_AUX_POEORB_....EOF +manifest.csv +``` + +10. 下载任务页显示每景产品和轨道状态。 +11. 如果某景轨道失败,用户点击“补轨道”。 + +## 风险和处理 + +### ASF 下载需要 Earthdata 账号 + +处理:页面启动时做凭据检查,查询可以不登录,下载时必须登录。 + +### 精密轨道可能有延迟 + +处理:允许暂时使用 `AUX_RESORB`,并提供后续补 `AUX_POEORB`。 + +### 行政区 GeoJSON 层级不完整 + +处理:用 `parent.adcode` 和内置省级 adcode 表补层级,不依赖文件里存在完整省级 Feature。 + +### 大范围长时间查询结果过多 + +处理:限制最大结果数,页面提醒分批查询;任务层支持按月份自动拆分。 + +### 文件重复和路径混乱 + +处理:以 `scene_id` 为唯一键,所有任务引用同一场景,文件仓库扫描可修复数据库状态。 + +## 后续扩展 + +- Sentinel-2 数据下载。 +- Copernicus Data Space 数据源。 +- 自动按月份/季度拆分任务。 +- InSAR 处理链对接,例如 ISCE、SNAP、MintPy。 +- 场景覆盖图层和轨道号统计。 +- 下载完成后的数据质量检查。 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1897c28 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "sentinel-orbit-downloader" +version = "0.1.0" +description = "Query and download Sentinel-1 scenes and precise orbit files" +readme = "README.md" +authors = [ + { name = "Harmon", email = "rs_bhm@163.com" } +] +requires-python = ">=3.11,<3.14" +dependencies = [ + "asf-search>=12.0.7", + "fastapi>=0.116.1", + "s1-orbits>=0.2.0", + "shapely>=2.1.2", + "uvicorn>=0.35.0", +] + +[project.scripts] +sentinel-orbit-downloader = "sentinel_orbit_downloader:main" +s1-dl = "sentinel_orbit_downloader:main" +s1-web = "sentinel_orbit_downloader.api.app:main" + +[build-system] +requires = ["uv_build>=0.11.8,<0.12.0"] +build-backend = "uv_build" diff --git a/src/sentinel_orbit_downloader/__init__.py b/src/sentinel_orbit_downloader/__init__.py new file mode 100644 index 0000000..ed32c05 --- /dev/null +++ b/src/sentinel_orbit_downloader/__init__.py @@ -0,0 +1,3 @@ +from .cli import main + +__all__ = ["main"] diff --git a/src/sentinel_orbit_downloader/__main__.py b/src/sentinel_orbit_downloader/__main__.py new file mode 100644 index 0000000..a049ad7 --- /dev/null +++ b/src/sentinel_orbit_downloader/__main__.py @@ -0,0 +1,5 @@ +from .cli import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/sentinel_orbit_downloader/api/__init__.py b/src/sentinel_orbit_downloader/api/__init__.py new file mode 100644 index 0000000..d9c5b70 --- /dev/null +++ b/src/sentinel_orbit_downloader/api/__init__.py @@ -0,0 +1 @@ +"""FastAPI application package.""" diff --git a/src/sentinel_orbit_downloader/api/app.py b/src/sentinel_orbit_downloader/api/app.py new file mode 100644 index 0000000..3e65c8a --- /dev/null +++ b/src/sentinel_orbit_downloader/api/app.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from pathlib import Path + +import uvicorn +from fastapi import FastAPI +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from sentinel_orbit_downloader.api.routes_downloads import router as downloads_router +from sentinel_orbit_downloader.api.routes_health import router as health_router +from sentinel_orbit_downloader.api.routes_jobs import router as jobs_router +from sentinel_orbit_downloader.api.routes_projects import router as projects_router +from sentinel_orbit_downloader.api.routes_regions import router as regions_router +from sentinel_orbit_downloader.api.routes_search import router as search_router +from sentinel_orbit_downloader.api.routes_settings import router as settings_router +from sentinel_orbit_downloader.core.config import configured_api_host, configured_api_port + + +def create_app() -> FastAPI: + app = FastAPI( + title="Sentinel Orbit Downloader API", + version="0.1.0", + description="Local API for Sentinel-1 search, orbit matching, and task management.", + ) + + web_root = Path(__file__).resolve().parents[1] / "web" + app.mount("/static", StaticFiles(directory=web_root), name="static") + + @app.get("/", include_in_schema=False) + def index() -> FileResponse: + return FileResponse(web_root / "index.html") + + @app.get("/app", include_in_schema=False) + def app_index() -> FileResponse: + return FileResponse(web_root / "index.html") + + app.include_router(health_router, prefix="/api") + app.include_router(regions_router, prefix="/api") + app.include_router(projects_router, prefix="/api") + app.include_router(jobs_router, prefix="/api") + app.include_router(search_router, prefix="/api") + app.include_router(downloads_router, prefix="/api") + app.include_router(settings_router, prefix="/api") + return app + + +app = create_app() + + +def main() -> None: + uvicorn.run( + "sentinel_orbit_downloader.api.app:app", + host=configured_api_host(), + port=configured_api_port(), + reload=False, + ) diff --git a/src/sentinel_orbit_downloader/api/deps.py b/src/sentinel_orbit_downloader/api/deps.py new file mode 100644 index 0000000..7ffb4db --- /dev/null +++ b/src/sentinel_orbit_downloader/api/deps.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from pathlib import Path + +from sentinel_orbit_downloader.core.config import configured_db_path + + +def get_db_path() -> Path: + return configured_db_path() diff --git a/src/sentinel_orbit_downloader/api/routes_downloads.py b/src/sentinel_orbit_downloader/api/routes_downloads.py new file mode 100644 index 0000000..6addca0 --- /dev/null +++ b/src/sentinel_orbit_downloader/api/routes_downloads.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status + +from sentinel_orbit_downloader.api.deps import get_db_path +from sentinel_orbit_downloader.api.schemas import DownloadJobCreateRequest, DownloadJobCreateResponse, JobSummary +from sentinel_orbit_downloader.services.asf_client import credentials_available +from sentinel_orbit_downloader.services.download_manager import DownloadJobRequest, execute_download_job, queue_download_job +from sentinel_orbit_downloader.services.project_store import get_job +from sentinel_orbit_downloader.services.settings_store import get_saved_earthdata_credentials + + +router = APIRouter(prefix="/download-jobs", tags=["download-jobs"]) + + +@router.post("", response_model=DownloadJobCreateResponse, status_code=status.HTTP_202_ACCEPTED) +def create_download_job_endpoint( + request: DownloadJobCreateRequest, + background_tasks: BackgroundTasks, + db_path=Depends(get_db_path), +) -> DownloadJobCreateResponse: + job = get_job(db_path, request.job_id) + if job is None: + raise HTTPException(status_code=404, detail=f"Job not found: {request.job_id}") + + saved_username, saved_password = get_saved_earthdata_credentials(db_path) + resolved_username = request.username or saved_username + resolved_password = request.password or saved_password + + if not request.skip_data and not credentials_available(resolved_username, resolved_password): + raise HTTPException( + status_code=400, + detail=( + "下载 Sentinel-1 原始产品需要 Earthdata 用户名和密码。" + "请在页面填写 Earthdata 用户名/密码,或先保存到本地配置,或先设置 EARTHDATA_USER 和 EARTHDATA_PASS。" + "如果这次只想补轨道,请把“跳过产品”设为“是”。" + ), + ) + + manager_request = DownloadJobRequest( + job_id=request.job_id, + data_dir=Path(request.data_dir) if request.data_dir else None, + orbit_dir=Path(request.orbit_dir) if request.orbit_dir else None, + manifest_path=Path(request.manifest_path) if request.manifest_path else None, + skip_data=request.skip_data, + skip_orbits=request.skip_orbits, + processes=request.processes, + ignore_orbit_errors=request.ignore_orbit_errors, + username=resolved_username, + password=resolved_password, + ) + + try: + queue_download_job(db_path, manager_request) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + background_tasks.add_task(execute_download_job, db_path, manager_request) + queued_job = get_job(db_path, request.job_id) + if queued_job is None: + raise HTTPException(status_code=500, detail=f"Job disappeared after queueing: {request.job_id}") + return DownloadJobCreateResponse( + message="Download job queued", + job=JobSummary.model_validate(queued_job), + ) diff --git a/src/sentinel_orbit_downloader/api/routes_health.py b/src/sentinel_orbit_downloader/api/routes_health.py new file mode 100644 index 0000000..99cbdb7 --- /dev/null +++ b/src/sentinel_orbit_downloader/api/routes_health.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from sentinel_orbit_downloader.api.deps import get_db_path +from sentinel_orbit_downloader.api.schemas import HealthResponse + + +router = APIRouter(tags=["health"]) + + +@router.get("/health", response_model=HealthResponse) +def health(db_path=Depends(get_db_path)) -> HealthResponse: + return HealthResponse(status="ok", db_path=str(db_path)) diff --git a/src/sentinel_orbit_downloader/api/routes_jobs.py b/src/sentinel_orbit_downloader/api/routes_jobs.py new file mode 100644 index 0000000..7b82042 --- /dev/null +++ b/src/sentinel_orbit_downloader/api/routes_jobs.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, status + +from sentinel_orbit_downloader.api.deps import get_db_path +from sentinel_orbit_downloader.api.schemas import ( + JobDeleteResponse, + JobDetailResponse, + JobEventResponse, + JobSceneResponse, + JobSummary, +) +from sentinel_orbit_downloader.services.project_store import ( + clear_jobs, + delete_job, + get_job, + list_job_events, + list_job_scenes, + list_jobs, +) + + +router = APIRouter(prefix="/jobs", tags=["jobs"]) + + +@router.get("", response_model=list[JobSummary]) +def jobs( + project_id: int | None = Query(default=None), + limit: int = Query(default=20, ge=1, le=200), + db_path=Depends(get_db_path), +) -> list[JobSummary]: + return [JobSummary.model_validate(job) for job in list_jobs(db_path, project_id=project_id, limit=limit)] + + +@router.delete("", response_model=JobDeleteResponse) +def clear_jobs_endpoint( + project_id: int | None = Query(default=None), + force: bool = Query(default=False), + db_path=Depends(get_db_path), +) -> JobDeleteResponse: + deleted_job_ids = clear_jobs(db_path, project_id=project_id, include_running=force) + return JobDeleteResponse( + message="任务已清空" if deleted_job_ids else "没有可清空的任务", + deleted_count=len(deleted_job_ids), + deleted_job_ids=deleted_job_ids, + ) + + +@router.get("/{job_id}", response_model=JobDetailResponse) +def job_detail( + job_id: int, + include_events: bool = Query(default=False), + db_path=Depends(get_db_path), +) -> JobDetailResponse: + job = get_job(db_path, job_id) + if job is None: + raise HTTPException(status_code=404, detail=f"Job not found: {job_id}") + scenes = [JobSceneResponse(**scene) for scene in list_job_scenes(db_path, job_id)] + events = [] + if include_events: + events = [JobEventResponse(**event) for event in list_job_events(db_path, job_id)] + return JobDetailResponse( + job=JobSummary.model_validate(job), + scenes=scenes, + events=events, + ) + + +@router.delete("/{job_id}", response_model=JobDeleteResponse, status_code=status.HTTP_200_OK) +def delete_job_endpoint( + job_id: int, + force: bool = Query(default=False), + db_path=Depends(get_db_path), +) -> JobDeleteResponse: + job = get_job(db_path, job_id) + if job is None: + raise HTTPException(status_code=404, detail=f"Job not found: {job_id}") + if job.status == "running" and not force: + raise HTTPException( + status_code=409, + detail="任务仍在运行。请先停止服务后再删,或使用强制删除。", + ) + deleted = delete_job(db_path, job_id) + if not deleted: + raise HTTPException(status_code=404, detail=f"Job not found: {job_id}") + return JobDeleteResponse( + message="任务已删除", + deleted_count=1, + deleted_job_ids=[job_id], + ) diff --git a/src/sentinel_orbit_downloader/api/routes_projects.py b/src/sentinel_orbit_downloader/api/routes_projects.py new file mode 100644 index 0000000..95b1a80 --- /dev/null +++ b/src/sentinel_orbit_downloader/api/routes_projects.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException + +from sentinel_orbit_downloader.api.deps import get_db_path +from sentinel_orbit_downloader.api.schemas import ProjectCreateRequest, ProjectResponse +from sentinel_orbit_downloader.services.project_store import create_project, get_project, list_projects + + +router = APIRouter(prefix="/projects", tags=["projects"]) + + +@router.get("", response_model=list[ProjectResponse]) +def projects(db_path=Depends(get_db_path)) -> list[ProjectResponse]: + return [ProjectResponse.model_validate(project) for project in list_projects(db_path)] + + +@router.post("", response_model=ProjectResponse) +def create_project_endpoint( + request: ProjectCreateRequest, + db_path=Depends(get_db_path), +) -> ProjectResponse: + try: + project = create_project( + db_path, + name=request.name, + data_root=Path(request.data_root), + description=request.description, + slug=request.slug, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return ProjectResponse.model_validate(project) + + +@router.get("/{project_id}", response_model=ProjectResponse) +def project_detail(project_id: int, db_path=Depends(get_db_path)) -> ProjectResponse: + project = get_project(db_path, project_id) + if project is None: + raise HTTPException(status_code=404, detail=f"Project not found: {project_id}") + return ProjectResponse.model_validate(project) diff --git a/src/sentinel_orbit_downloader/api/routes_regions.py b/src/sentinel_orbit_downloader/api/routes_regions.py new file mode 100644 index 0000000..35b6e8e --- /dev/null +++ b/src/sentinel_orbit_downloader/api/routes_regions.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException, Query + +from sentinel_orbit_downloader.api.deps import get_db_path +from sentinel_orbit_downloader.api.schemas import ( + RegionDetail, + RegionImportRequest, + RegionImportResponse, + RegionSummary, +) +from sentinel_orbit_downloader.api.serializers import region_detail, region_summary +from sentinel_orbit_downloader.services.region_catalog import ( + get_region, + import_regions, + search_regions, +) + + +router = APIRouter(prefix="/regions", tags=["regions"]) + + +@router.post("/import", response_model=RegionImportResponse) +def import_region_catalog( + request: RegionImportRequest, + db_path=Depends(get_db_path), +) -> RegionImportResponse: + source_path = Path(request.source_path) + if not source_path.exists(): + raise HTTPException(status_code=404, detail=f"GeoJSON file not found: {source_path}") + stats = import_regions(db_path, source_path, simplify_tolerance=request.simplify_tolerance) + return RegionImportResponse( + source_path=str(stats.source_path), + simplify_tolerance=stats.simplify_tolerance, + total_features=stats.total_features, + imported=stats.imported, + ) + + +@router.get("/search", response_model=list[RegionSummary]) +def search_region_catalog( + q: str = Query(..., min_length=1), + level: str | None = Query(default=None), + limit: int = Query(default=20, ge=1, le=200), + db_path=Depends(get_db_path), +) -> list[RegionSummary]: + return [ + region_summary(region) + for region in search_regions(db_path, q, limit=limit, level=level) + ] + + +@router.get("/{adcode}", response_model=RegionDetail) +def get_region_detail(adcode: str, db_path=Depends(get_db_path)) -> RegionDetail: + region = get_region(db_path, adcode) + if region is None: + raise HTTPException(status_code=404, detail=f"Region not found: {adcode}") + return region_detail(region) diff --git a/src/sentinel_orbit_downloader/api/routes_search.py b/src/sentinel_orbit_downloader/api/routes_search.py new file mode 100644 index 0000000..7f62197 --- /dev/null +++ b/src/sentinel_orbit_downloader/api/routes_search.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException + +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.services.asf_client import SearchParams, query_scenes +from sentinel_orbit_downloader.services.project_store import ( + create_search_job, + get_job, + get_project, + list_job_scenes, + record_job_event, + save_query_results, + update_search_job, +) +from sentinel_orbit_downloader.services.region_catalog import get_region, region_wkt + + +router = APIRouter(prefix="/search-jobs", tags=["search-jobs"]) + + +@router.post("", response_model=SearchJobCreateResponse) +def create_search_job_endpoint( + request: SearchJobCreateRequest, + db_path=Depends(get_db_path), +) -> SearchJobCreateResponse: + if request.project_id is not None and get_project(db_path, request.project_id) is None: + raise HTTPException(status_code=404, detail=f"Project not found: {request.project_id}") + + try: + aoi_meta = resolve_search_aoi(request, db_path) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + job = create_search_job( + db_path, + project_id=request.project_id, + source_type=aoi_meta["source_type"], + source_value=aoi_meta["source_value"], + region_adcode=aoi_meta["region_adcode"], + region_name=aoi_meta["region_name"], + start_time=request.start, + end_time=request.end, + processing_level=request.processing_level, + beam_mode=request.beam_mode, + flight_direction=request.flight_direction.upper() if request.flight_direction else None, + polarization=request.polarization, + relative_orbit=request.relative_orbit, + max_results=request.max_results, + manifest_path=request.manifest_path, + ) + record_job_event(db_path, job.id, "info", "API search job created") + + try: + params = SearchParams( + aoi_wkt=aoi_meta["aoi_wkt"], + start=request.start, + end=request.end, + processing_level=request.processing_level, + beam_mode=request.beam_mode, + flight_direction=request.flight_direction, + polarization=request.polarization, + relative_orbit=request.relative_orbit, + max_results=request.max_results, + ) + results = query_scenes(params) + save_query_results(db_path, job.id, results) + update_search_job( + db_path, + job.id, + status="succeeded", + result_count=len(results), + manifest_path=request.manifest_path, + ) + record_job_event(db_path, job.id, "info", f"API query returned {len(results)} scene(s)") + except Exception as exc: + update_search_job(db_path, job.id, status="failed", error_message=str(exc)) + record_job_event(db_path, job.id, "error", f"API search failed: {exc}") + raise HTTPException(status_code=500, detail=str(exc)) from exc + + final_job = get_job(db_path, job.id) + if final_job is None: + raise HTTPException(status_code=500, detail=f"Job disappeared after creation: {job.id}") + scenes = [JobSceneResponse(**scene) for scene in list_job_scenes(db_path, job.id)] + return SearchJobCreateResponse( + job=JobSummary.model_validate(final_job), + scenes=scenes, + ) + + +def resolve_search_aoi(request: SearchJobCreateRequest, db_path) -> dict[str, str | None]: + if request.bbox: + return { + "aoi_wkt": bbox_to_wkt(request.bbox), + "source_type": "bbox", + "source_value": request.bbox, + "region_adcode": None, + "region_name": None, + } + if request.wkt: + return { + "aoi_wkt": request.wkt.strip(), + "source_type": "wkt", + "source_value": request.wkt.strip(), + "region_adcode": None, + "region_name": None, + } + if request.region_adcode: + region = get_region(db_path, request.region_adcode) + if region is None: + raise ValueError(f"Region not found: {request.region_adcode}") + return { + "aoi_wkt": region_wkt(db_path, region.adcode, simplified=not request.raw_region), + "source_type": "region", + "source_value": region.label, + "region_adcode": region.adcode, + "region_name": region.label, + } + raise ValueError("one AOI input is required") diff --git a/src/sentinel_orbit_downloader/api/routes_settings.py b/src/sentinel_orbit_downloader/api/routes_settings.py new file mode 100644 index 0000000..c8124c2 --- /dev/null +++ b/src/sentinel_orbit_downloader/api/routes_settings.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status + +from sentinel_orbit_downloader.api.deps import get_db_path +from sentinel_orbit_downloader.api.schemas import ( + EarthdataSettingsRequest, + EarthdataSettingsResponse, + MessageResponse, +) +from sentinel_orbit_downloader.services.settings_store import ( + clear_earthdata_credentials, + get_earthdata_settings, + save_earthdata_credentials, +) + + +router = APIRouter(prefix="/settings", tags=["settings"]) + + +@router.get("/earthdata", response_model=EarthdataSettingsResponse) +def earthdata_settings(db_path=Depends(get_db_path)) -> EarthdataSettingsResponse: + settings = get_earthdata_settings(db_path) + return EarthdataSettingsResponse( + username=settings.username, + has_password=settings.has_password, + ) + + +@router.put("/earthdata", response_model=EarthdataSettingsResponse, status_code=status.HTTP_200_OK) +def save_earthdata_settings( + request: EarthdataSettingsRequest, + db_path=Depends(get_db_path), +) -> EarthdataSettingsResponse: + try: + settings = save_earthdata_credentials( + db_path, + username=request.username, + password=request.password, + ) + except (ValueError, RuntimeError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return EarthdataSettingsResponse( + username=settings.username, + has_password=settings.has_password, + ) + + +@router.delete("/earthdata", response_model=MessageResponse) +def delete_earthdata_settings(db_path=Depends(get_db_path)) -> MessageResponse: + clear_earthdata_credentials(db_path) + return MessageResponse(message="Earthdata 凭据已清除") diff --git a/src/sentinel_orbit_downloader/api/schemas.py b/src/sentinel_orbit_downloader/api/schemas.py new file mode 100644 index 0000000..67ab8bc --- /dev/null +++ b/src/sentinel_orbit_downloader/api/schemas.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class HealthResponse(BaseModel): + status: str + db_path: str + + +class RegionSummary(BaseModel): + adcode: str + name: str + level: str + parent_adcode: str | None = None + parent_name: str | None = None + province_adcode: str | None = None + province_name: str | None = None + label: str + bbox: list[float] | None = None + simplify_tolerance: float + + +class RegionDetail(RegionSummary): + center: list[float] | None = None + centroid: list[float] | None = None + geometry_json: dict[str, Any] + wkt: str + simplified_wkt: str + children_num: int | None = None + tree_id: str | None = None + + +class RegionImportRequest(BaseModel): + source_path: str = Field(default="\u5168\u56fd\u884c\u653f\u533a.geojson") + simplify_tolerance: float = Field(default=0.01, gt=0) + + +class RegionImportResponse(BaseModel): + source_path: str + simplify_tolerance: float + total_features: int + imported: int + + +class ProjectCreateRequest(BaseModel): + name: str + data_root: str + description: str | None = None + slug: str | None = None + + +class ProjectResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + slug: str + data_root: str + description: str | None = None + created_at: str + updated_at: str + + +class JobSummary(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + project_id: int | None = None + source_type: str + source_value: str | None = None + region_adcode: str | None = None + region_name: str | None = None + start_time: str + end_time: str + processing_level: str + beam_mode: str | None = None + flight_direction: str | None = None + polarization: str | None = None + relative_orbit: int | None = None + max_results: int + status: str + result_count: int | None = None + data_dir: str | None = None + orbit_dir: str | None = None + manifest_path: str | None = None + error_message: str | None = None + created_at: str + finished_at: str | None = None + + +class JobSceneResponse(BaseModel): + job_id: int + scene_id: str + selected: int + product_status: str + orbit_status: str + product_path: str | None = None + orbit_path: str | None = None + platform: str | None = None + start_time: str | None = None + stop_time: str | None = None + processing_level: str | None = None + flight_direction: str | None = None + relative_orbit: int | None = None + size_mb: float | None = None + + +class JobEventResponse(BaseModel): + id: int + level: str + message: str + created_at: str + + +class JobDetailResponse(BaseModel): + job: JobSummary + scenes: list[JobSceneResponse] + events: list[JobEventResponse] = Field(default_factory=list) + + +class SearchJobCreateRequest(BaseModel): + project_id: int | None = None + bbox: str | None = None + wkt: str | None = None + region_adcode: str | None = None + start: str + end: str + processing_level: str = "SLC" + beam_mode: str | None = "IW" + flight_direction: str | None = None + polarization: str | None = None + relative_orbit: int | None = None + max_results: int = Field(default=50, ge=1) + manifest_path: str | None = None + raw_region: bool = False + + @model_validator(mode="after") + def validate_aoi(self) -> "SearchJobCreateRequest": + provided = [self.bbox, self.wkt, self.region_adcode] + count = sum(1 for value in provided if value) + if count != 1: + raise ValueError("provide exactly one of bbox, wkt, or region_adcode") + return self + + +class SearchJobCreateResponse(BaseModel): + job: JobSummary + scenes: list[JobSceneResponse] + + +class DownloadJobCreateRequest(BaseModel): + job_id: int = Field(ge=1) + data_dir: str | None = None + orbit_dir: str | None = None + manifest_path: str | None = None + skip_data: bool = False + skip_orbits: bool = False + processes: int = Field(default=2, ge=1) + ignore_orbit_errors: bool = False + username: str | None = None + password: str | None = None + + @model_validator(mode="after") + def validate_download_flags(self) -> "DownloadJobCreateRequest": + if self.skip_data and self.skip_orbits: + raise ValueError("skip_data and skip_orbits cannot both be true") + return self + + +class DownloadJobCreateResponse(BaseModel): + message: str + job: JobSummary + + +class MessageResponse(BaseModel): + message: str + + +class EarthdataSettingsRequest(BaseModel): + username: str + password: str + + +class EarthdataSettingsResponse(BaseModel): + username: str | None = None + has_password: bool = False + + +class JobDeleteResponse(BaseModel): + message: str + deleted_count: int + deleted_job_ids: list[int] = Field(default_factory=list) diff --git a/src/sentinel_orbit_downloader/api/serializers.py b/src/sentinel_orbit_downloader/api/serializers.py new file mode 100644 index 0000000..97d26ae --- /dev/null +++ b/src/sentinel_orbit_downloader/api/serializers.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import json + +from sentinel_orbit_downloader.api.schemas import RegionDetail, RegionSummary + + +def region_summary(region) -> RegionSummary: + bbox = list(region.bbox) if region.bbox is not None else None + return RegionSummary( + adcode=region.adcode, + name=region.name, + level=region.level, + parent_adcode=region.parent_adcode, + parent_name=region.parent_name, + province_adcode=region.province_adcode, + province_name=region.province_name, + label=region.label, + bbox=bbox, + simplify_tolerance=region.simplify_tolerance, + ) + + +def region_detail(region) -> RegionDetail: + summary = region_summary(region) + center = ( + [region.center_lon, region.center_lat] + if region.center_lon is not None and region.center_lat is not None + else None + ) + centroid = ( + [region.centroid_lon, region.centroid_lat] + if region.centroid_lon is not None and region.centroid_lat is not None + else None + ) + return RegionDetail( + **summary.model_dump(), + center=center, + centroid=centroid, + geometry_json=json.loads(region.geometry_json), + wkt=region.wkt, + simplified_wkt=region.simplified_wkt, + children_num=region.children_num, + tree_id=region.tree_id, + ) diff --git a/src/sentinel_orbit_downloader/cli.py b/src/sentinel_orbit_downloader/cli.py new file mode 100644 index 0000000..9c626d7 --- /dev/null +++ b/src/sentinel_orbit_downloader/cli.py @@ -0,0 +1,815 @@ +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any, Sequence + +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, + write_manifest, +) +from sentinel_orbit_downloader.services.orbit_client import ( + collect_orbit_scenes, + download_orbits, + scene_names_from_results, +) +from sentinel_orbit_downloader.services.project_store import ( + create_project, + create_search_job, + get_job, + get_project, + list_job_events, + list_job_scenes, + list_jobs, + list_projects, + record_download_result, + record_job_event, + save_query_results, + update_search_job, +) +from sentinel_orbit_downloader.services.region_catalog import ( + get_region, + import_regions, + region_wkt, + search_regions, +) + + +DEFAULT_REGION_FILE = Path("\u5168\u56fd\u884c\u653f\u533a.geojson") + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + return args.func(args) + except KeyboardInterrupt: + print("Interrupted.", file=sys.stderr) + return 130 + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 2 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="s1-dl", + description="Query and download Sentinel-1 scenes plus matching orbit files.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + search_parser = subparsers.add_parser("search", help="query Sentinel-1 scenes") + add_database_arg(search_parser) + add_project_arg(search_parser) + add_query_args(search_parser) + add_manifest_arg(search_parser) + search_parser.set_defaults(func=cmd_search) + + download_parser = subparsers.add_parser( + "download", + help="query scenes, download products, and fetch matching orbit files", + ) + add_database_arg(download_parser) + add_project_arg(download_parser) + add_query_args(download_parser) + download_parser.add_argument( + "--out", + type=Path, + default=Path("downloads"), + help="output root directory; data and orbits subdirectories are created here", + ) + download_parser.add_argument( + "--data-dir", + type=Path, + help="override product download directory; default is /data", + ) + download_parser.add_argument( + "--orbit-dir", + type=Path, + help="override orbit download directory; default is /orbits", + ) + download_parser.add_argument( + "--username", + help="NASA Earthdata username; defaults to EARTHDATA_USER or EARTHDATA_USERNAME", + ) + download_parser.add_argument( + "--password", + help="NASA Earthdata password; defaults to EARTHDATA_PASS or EARTHDATA_PASSWORD", + ) + download_parser.add_argument( + "--ask-password", + action="store_true", + help="prompt for the Earthdata password instead of reading an argument/env var", + ) + download_parser.add_argument( + "--processes", + type=positive_int, + default=2, + help="parallel ASF download processes", + ) + download_parser.add_argument( + "--skip-data", + action="store_true", + help="do not download product zip files; useful for orbit-only runs from query results", + ) + download_parser.add_argument( + "--skip-orbits", + action="store_true", + help="do not download matching orbit files", + ) + download_parser.add_argument( + "--ignore-orbit-errors", + action="store_true", + help="return success even if some orbit files cannot be found", + ) + add_manifest_arg(download_parser) + download_parser.set_defaults(func=cmd_download) + + orbit_parser = subparsers.add_parser( + "orbits", + help="download orbit files for existing scenes or local .zip/.SAFE products", + ) + scene_group = orbit_parser.add_mutually_exclusive_group(required=True) + scene_group.add_argument( + "--scene", + action="append", + help="Sentinel-1 scene name; may be repeated", + ) + scene_group.add_argument( + "--scene-file", + type=Path, + help="text file containing one scene name per line", + ) + scene_group.add_argument( + "--data-dir", + type=Path, + help="directory containing Sentinel-1 .zip files or .SAFE folders", + ) + orbit_parser.add_argument( + "--out", + type=Path, + default=Path("orbits"), + help="orbit output directory", + ) + orbit_parser.add_argument( + "--ignore-errors", + action="store_true", + help="return success even if some orbit files cannot be found", + ) + orbit_parser.set_defaults(func=cmd_orbits) + + regions_parser = subparsers.add_parser( + "regions", + help="import and query administrative boundary regions", + ) + add_database_arg(regions_parser) + region_subparsers = regions_parser.add_subparsers(dest="regions_command", required=True) + + regions_import = region_subparsers.add_parser("import", help="import a GeoJSON region catalog") + add_database_arg(regions_import) + regions_import.add_argument( + "source", + nargs="?", + type=Path, + default=DEFAULT_REGION_FILE, + help=f"GeoJSON source file; default is .\\{DEFAULT_REGION_FILE.name}", + ) + regions_import.add_argument( + "--simplify-tolerance", + type=float, + default=0.01, + help="Shapely simplify tolerance used to derive query WKT", + ) + regions_import.set_defaults(func=cmd_regions_import) + + regions_search_parser = region_subparsers.add_parser("search", help="search regions by name or adcode") + add_database_arg(regions_search_parser) + regions_search_parser.add_argument("query", help="query text, for example mudanjiang or 231000") + regions_search_parser.add_argument( + "--limit", + type=positive_int, + default=20, + help="maximum number of matches to return", + ) + regions_search_parser.add_argument( + "--level", + choices=("country", "province", "city", "district"), + help="optional level filter", + ) + regions_search_parser.set_defaults(func=cmd_regions_search) + + regions_export_parser = region_subparsers.add_parser("export", help="export region geometry or metadata") + add_database_arg(regions_export_parser) + regions_export_parser.add_argument("adcode", help="region adcode") + regions_export_parser.add_argument( + "--format", + choices=("wkt", "geojson", "bbox", "json"), + default="wkt", + help="output format", + ) + regions_export_parser.add_argument( + "--raw", + action="store_true", + help="use the original geometry instead of simplified WKT/json", + ) + regions_export_parser.add_argument( + "--out", + type=Path, + help="optional file to write the exported value to", + ) + regions_export_parser.set_defaults(func=cmd_regions_export) + + projects_parser = subparsers.add_parser("projects", help="manage local projects") + add_database_arg(projects_parser) + project_subparsers = projects_parser.add_subparsers(dest="projects_command", required=True) + + projects_create = project_subparsers.add_parser("create", help="create a project") + add_database_arg(projects_create) + projects_create.add_argument("--name", required=True, help="project name") + projects_create.add_argument("--data-root", type=Path, required=True, help="project data root") + projects_create.add_argument("--description", help="optional description") + projects_create.add_argument("--slug", help="optional directory-friendly slug") + projects_create.set_defaults(func=cmd_projects_create) + + projects_list_parser = project_subparsers.add_parser("list", help="list projects") + add_database_arg(projects_list_parser) + projects_list_parser.set_defaults(func=cmd_projects_list) + + projects_show_parser = project_subparsers.add_parser("show", help="show one project") + add_database_arg(projects_show_parser) + projects_show_parser.add_argument("project_id", type=positive_int, help="project id") + projects_show_parser.set_defaults(func=cmd_projects_show) + + jobs_parser = subparsers.add_parser("jobs", help="inspect saved query and download jobs") + add_database_arg(jobs_parser) + jobs_subparsers = jobs_parser.add_subparsers(dest="jobs_command", required=True) + + jobs_list_parser = jobs_subparsers.add_parser("list", help="list recent jobs") + add_database_arg(jobs_list_parser) + jobs_list_parser.add_argument("--project-id", type=positive_int, help="optional project filter") + jobs_list_parser.add_argument("--limit", type=positive_int, default=20, help="maximum jobs to list") + jobs_list_parser.set_defaults(func=cmd_jobs_list) + + jobs_show_parser = jobs_subparsers.add_parser("show", help="show one job and its scenes") + add_database_arg(jobs_show_parser) + jobs_show_parser.add_argument("job_id", type=positive_int, help="job id") + jobs_show_parser.add_argument( + "--events", + action="store_true", + help="include stored job events", + ) + jobs_show_parser.set_defaults(func=cmd_jobs_show) + + return parser + + +def add_database_arg(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--db", + type=Path, + default=DEFAULT_DB_PATH, + help=f"SQLite database path; default is {DEFAULT_DB_PATH}", + ) + + +def add_project_arg(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--project-id", + type=positive_int, + help="optional project id used to group saved jobs", + ) + + +def add_query_args(parser: argparse.ArgumentParser) -> None: + aoi = parser.add_mutually_exclusive_group(required=True) + aoi.add_argument("--bbox", help="AOI bounding box as min_lon,min_lat,max_lon,max_lat") + aoi.add_argument("--wkt", help="AOI as WKT, for example POLYGON((...))") + aoi.add_argument( + "--aoi-file", + type=Path, + help="AOI file containing WKT, GeoJSON Feature, or GeoJSON geometry", + ) + aoi.add_argument( + "--region-adcode", + help="AOI from imported region catalog; use `s1-dl regions search` first", + ) + parser.add_argument( + "--raw-region", + action="store_true", + help="when using --region-adcode, use the original geometry instead of simplified WKT", + ) + parser.add_argument("--start", required=True, help="start date/time, e.g. 2024-01-01") + parser.add_argument("--end", required=True, help="end date/time, e.g. 2024-02-01") + parser.add_argument( + "--processing-level", + default="SLC", + help="ASF processing level, e.g. SLC, GRD_HD, GRD_MD, OCN", + ) + parser.add_argument("--beam-mode", default="IW", help="beam mode, e.g. IW, EW, WV") + parser.add_argument( + "--flight-direction", + choices=("ASCENDING", "DESCENDING", "ascending", "descending"), + help="optional orbit direction filter", + ) + parser.add_argument("--polarization", help="optional polarization filter, e.g. VV+VH") + parser.add_argument( + "--relative-orbit", + type=positive_int, + help="optional Sentinel-1 relative orbit/track number", + ) + parser.add_argument( + "--max-results", + type=positive_int, + default=50, + help="maximum scenes to return", + ) + + +def add_manifest_arg(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--manifest", type=Path, help="write search results to .csv or .json") + + +def cmd_search(args: argparse.Namespace) -> int: + ensure_project_exists(args.db, args.project_id) + aoi_meta = resolve_aoi(args) + job = create_search_job( + args.db, + project_id=args.project_id, + source_type=aoi_meta["source_type"], + source_value=aoi_meta["source_value"], + region_adcode=aoi_meta["region_adcode"], + region_name=aoi_meta["region_name"], + start_time=args.start, + end_time=args.end, + processing_level=args.processing_level, + beam_mode=args.beam_mode, + flight_direction=args.flight_direction.upper() if args.flight_direction else None, + polarization=args.polarization, + relative_orbit=args.relative_orbit, + max_results=args.max_results, + manifest_path=str(args.manifest) if args.manifest else None, + ) + record_job_event(args.db, job.id, "info", "Query created") + + try: + results = perform_query(args, aoi_meta["aoi_wkt"]) + save_query_results(args.db, job.id, results) + print_results(results) + if args.manifest: + write_manifest(results, args.manifest) + print(f"Wrote manifest: {args.manifest}") + update_search_job( + args.db, + job.id, + status="succeeded", + result_count=len(results), + manifest_path=str(args.manifest) if args.manifest else None, + ) + record_job_event(args.db, job.id, "info", f"Query returned {len(results)} scene(s)") + except Exception as exc: + update_search_job(args.db, job.id, status="failed", error_message=str(exc)) + record_job_event(args.db, job.id, "error", f"Query failed: {exc}") + raise + + print(f"Saved job: {job.id}") + return 0 + + +def cmd_download(args: argparse.Namespace) -> int: + ensure_project_exists(args.db, args.project_id) + aoi_meta = resolve_aoi(args) + + out_root = args.out + data_dir = args.data_dir or out_root / "data" + orbit_dir = args.orbit_dir or out_root / "orbits" + out_root.mkdir(parents=True, exist_ok=True) + manifest = args.manifest or out_root / "manifest.csv" + + job = create_search_job( + args.db, + project_id=args.project_id, + source_type=aoi_meta["source_type"], + source_value=aoi_meta["source_value"], + region_adcode=aoi_meta["region_adcode"], + region_name=aoi_meta["region_name"], + start_time=args.start, + end_time=args.end, + processing_level=args.processing_level, + beam_mode=args.beam_mode, + flight_direction=args.flight_direction.upper() if args.flight_direction else None, + polarization=args.polarization, + relative_orbit=args.relative_orbit, + max_results=args.max_results, + data_dir=str(data_dir), + orbit_dir=str(orbit_dir), + manifest_path=str(manifest), + ) + record_job_event(args.db, job.id, "info", "Download job created") + + try: + 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)") + + print_results(results) + write_manifest(results, manifest) + print(f"Wrote manifest: {manifest}") + + if not results: + update_search_job( + args.db, + job.id, + status="succeeded", + result_count=0, + data_dir=str(data_dir), + orbit_dir=str(orbit_dir), + manifest_path=str(manifest), + ) + record_job_event(args.db, job.id, "info", "No scenes matched the query") + 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) + + 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), + ) + + print(f"Saved job: {job.id}") + if orbit_failures and not args.ignore_orbit_errors: + return 1 + return 0 + except Exception as exc: + update_search_job( + args.db, + job.id, + status="failed", + result_count=None, + data_dir=str(data_dir), + orbit_dir=str(orbit_dir), + manifest_path=str(manifest), + error_message=str(exc), + ) + record_job_event(args.db, job.id, "error", f"Download job failed: {exc}") + raise + + +def cmd_orbits(args: argparse.Namespace) -> int: + scenes = collect_orbit_scenes( + scenes=args.scene, + scene_file=args.scene_file, + data_dir=args.data_dir, + ) + if not scenes: + print("No Sentinel-1 scenes found.", file=sys.stderr) + return 1 + + print(f"Downloading orbit files for {len(scenes)} scene(s) to {args.out} ...") + results = download_orbits(scenes, args.out) + failures = summarize_orbit_results(results) + if failures and not args.ignore_errors: + return 1 + return 0 + + +def cmd_regions_import(args: argparse.Namespace) -> int: + if not args.source.exists(): + raise ValueError(f"GeoJSON file does not exist: {args.source}") + stats = import_regions(args.db, args.source, simplify_tolerance=args.simplify_tolerance) + print( + "Imported region catalog: " + f"{stats.imported}/{stats.total_features} features " + f"from {stats.source_path} with simplify_tolerance={stats.simplify_tolerance}" + ) + return 0 + + +def cmd_regions_search(args: argparse.Namespace) -> int: + matches = search_regions(args.db, args.query, limit=args.limit, level=args.level) + if not matches: + print("No regions matched.") + return 0 + + for region in matches: + bbox_text = "" + if region.bbox is not None: + bbox_text = ",".join(f"{value:.6f}" for value in region.bbox) + print(f"{region.adcode} | {region.level:<8} | {region.label} | {bbox_text}") + return 0 + + +def cmd_regions_export(args: argparse.Namespace) -> int: + region = get_region(args.db, args.adcode) + if region is None: + raise ValueError(f"region not found: {args.adcode}") + + if args.format == "wkt": + output = region.wkt if args.raw else region.simplified_wkt + elif args.format == "geojson": + output = region.geometry_json + elif args.format == "bbox": + if region.bbox is None: + raise ValueError(f"region has no bbox: {args.adcode}") + output = ",".join(str(value) for value in region.bbox) + else: + payload: dict[str, Any] = { + "adcode": region.adcode, + "name": region.name, + "level": region.level, + "parent_adcode": region.parent_adcode, + "parent_name": region.parent_name, + "province_adcode": region.province_adcode, + "province_name": region.province_name, + "bbox": region.bbox, + "simplify_tolerance": region.simplify_tolerance, + "label": region.label, + } + output = json.dumps(payload, ensure_ascii=False, indent=2) + + if args.out: + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(output, encoding="utf-8") + print(f"Wrote region export: {args.out}") + else: + print(output) + return 0 + + +def cmd_projects_create(args: argparse.Namespace) -> int: + project = create_project( + args.db, + name=args.name, + data_root=args.data_root, + description=args.description, + slug=args.slug, + ) + print(f"Created project: {project.id} | {project.slug} | {project.data_root}") + return 0 + + +def cmd_projects_list(args: argparse.Namespace) -> int: + projects = list_projects(args.db) + if not projects: + print("No projects found.") + return 0 + for project in projects: + desc = f" | {project.description}" if project.description else "" + print(f"{project.id} | {project.slug} | {project.name} | {project.data_root}{desc}") + return 0 + + +def cmd_projects_show(args: argparse.Namespace) -> int: + project = get_project(args.db, args.project_id) + if project is None: + raise ValueError(f"project not found: {args.project_id}") + print(json.dumps(project.__dict__, ensure_ascii=False, indent=2)) + return 0 + + +def cmd_jobs_list(args: argparse.Namespace) -> int: + jobs = list_jobs(args.db, project_id=args.project_id, limit=args.limit) + if not jobs: + print("No jobs found.") + return 0 + for job in jobs: + project_text = job.project_id if job.project_id is not None else "-" + target = job.region_name or job.source_value or job.source_type + print( + f"{job.id} | project={project_text} | {job.status:<14} | " + f"{job.start_time}..{job.end_time} | {target} | count={job.result_count}" + ) + return 0 + + +def cmd_jobs_show(args: argparse.Namespace) -> int: + job = get_job(args.db, args.job_id) + if job is None: + raise ValueError(f"job not found: {args.job_id}") + + print(json.dumps(job.__dict__, ensure_ascii=False, indent=2)) + scenes = list_job_scenes(args.db, args.job_id) + if scenes: + print("\nScenes:") + for scene in scenes: + size_text = f"{scene['size_mb']:.1f} MB" if scene["size_mb"] is not None else "" + print( + f" {scene['scene_id']} | {scene['start_time']} | {scene['processing_level']} | " + f"product={scene['product_status']} | orbit={scene['orbit_status']} | {size_text}" + ) + if args.events: + events = list_job_events(args.db, args.job_id) + if events: + print("\nEvents:") + for event in events: + print(f" [{event['created_at']}] {event['level']}: {event['message']}") + return 0 + + +def perform_query(args: argparse.Namespace, aoi_wkt: str): + params = SearchParams( + aoi_wkt=aoi_wkt, + start=args.start, + end=args.end, + processing_level=args.processing_level, + beam_mode=args.beam_mode, + flight_direction=args.flight_direction, + polarization=args.polarization, + relative_orbit=args.relative_orbit, + max_results=args.max_results, + ) + print("Querying ASF Search ...") + return query_scenes(params) + + +def resolve_aoi(args: argparse.Namespace) -> dict[str, Any]: + if args.bbox: + return { + "aoi_wkt": bbox_to_wkt(args.bbox), + "source_type": "bbox", + "source_value": args.bbox, + "region_adcode": None, + "region_name": None, + } + if args.wkt: + return { + "aoi_wkt": args.wkt.strip(), + "source_type": "wkt", + "source_value": args.wkt.strip(), + "region_adcode": None, + "region_name": None, + } + if args.aoi_file: + return { + "aoi_wkt": aoi_file_to_wkt(args.aoi_file), + "source_type": "aoi_file", + "source_value": str(args.aoi_file), + "region_adcode": None, + "region_name": None, + } + if args.region_adcode: + region = get_region(args.db, args.region_adcode) + if region is None: + raise ValueError(f"region not found: {args.region_adcode}") + return { + "aoi_wkt": region_wkt(args.db, args.region_adcode, simplified=not args.raw_region), + "source_type": "region", + "source_value": region.label, + "region_adcode": region.adcode, + "region_name": region.label, + } + raise ValueError("one AOI input is required") + + +def print_results(results) -> None: + count = len(results) + print(f"Found {count} scene(s).") + if not count: + return + + for index, product in enumerate(results, start=1): + props = product.properties + scene = scene_name_from_product(product) or props.get("fileID") or "" + start = props.get("startTime") or "" + level = props.get("processingLevel") or "" + direction = props.get("flightDirection") or "" + rel_orbit = props.get("pathNumber") or "" + size = product_size_mb(props) + size_text = f"{size:.1f} MB" if size is not None else "" + print(f"{index:>3}. {scene} | {start} | {level} | {direction} | rel={rel_orbit} | {size_text}") + + +def record_product_downloads(db_path: Path, job_id: int, results, data_dir: Path | None) -> None: + for product in results: + scene_id = scene_name_from_product(product) + if scene_id is None: + continue + props = product.properties + if data_dir is None: + record_download_result( + db_path, + job_id=job_id, + scene_id=scene_id, + kind="product", + status="skipped", + path=None, + url=string_value(props.get("url")), + bytes_total=float(props["bytes"]) if props.get("bytes") is not None else None, + bytes_done=0, + ) + continue + + expected = expected_product_path(product, data_dir) + status = "succeeded" if expected.exists() else "failed" + record_download_result( + db_path, + job_id=job_id, + scene_id=scene_id, + kind="product", + status=status, + path=str(expected) if expected.exists() else None, + url=string_value(props.get("url")), + bytes_total=float(props["bytes"]) if props.get("bytes") is not None else None, + bytes_done=float(props["bytes"]) if expected.exists() and props.get("bytes") is not None else None, + error_message=None if expected.exists() else "Downloaded file not found at expected path", + ) + + +def record_orbit_downloads(db_path: Path, job_id: int, orbit_results) -> None: + for result in orbit_results: + record_download_result( + db_path, + job_id=job_id, + scene_id=result.scene, + kind="orbit", + status="succeeded" if result.error is None else "failed", + path=str(result.path) if result.path is not None else None, + error_message=result.error, + ) + + +def expected_product_path(product, data_dir: Path) -> Path: + props = product.properties + file_name = props.get("fileName") + if file_name: + return data_dir / str(file_name) + url = props.get("url") + if url: + return data_dir / Path(str(url)).name + scene = scene_name_from_product(product) or "unknown_scene" + return data_dir / f"{scene}.zip" + + +def summarize_orbit_results(results) -> int: + failures = [result for result in results if result.error] + for result in results: + if result.error: + print(f"Orbit failed: {result.scene}: {result.error}", file=sys.stderr) + else: + print(f"Orbit: {result.scene} -> {result.path.name}") + print(f"Orbit download finished: {len(results) - len(failures)} ok, {len(failures)} failed.") + return len(failures) + + +def ensure_project_exists(db_path: Path, project_id: int | None) -> None: + if project_id is None: + return + if get_project(db_path, project_id) is None: + raise ValueError(f"project not found: {project_id}") + + +def string_value(value: Any) -> str | None: + if value is None: + return None + return str(value) + + +def positive_int(value: str) -> int: + number = int(value) + if number < 1: + raise argparse.ArgumentTypeError("must be >= 1") + return number diff --git a/src/sentinel_orbit_downloader/core/__init__.py b/src/sentinel_orbit_downloader/core/__init__.py new file mode 100644 index 0000000..7c16cea --- /dev/null +++ b/src/sentinel_orbit_downloader/core/__init__.py @@ -0,0 +1 @@ +"""Core configuration and constants.""" diff --git a/src/sentinel_orbit_downloader/core/config.py b/src/sentinel_orbit_downloader/core/config.py new file mode 100644 index 0000000..aaf6659 --- /dev/null +++ b/src/sentinel_orbit_downloader/core/config.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import os +from pathlib import Path + + +DEFAULT_DB_PATH = Path(".s1dl/app.db") +DEFAULT_API_HOST = "127.0.0.1" +DEFAULT_API_PORT = 8010 + + +def configured_db_path() -> Path: + raw = os.getenv("S1DL_DB_PATH") + if not raw: + return DEFAULT_DB_PATH + return Path(raw) + + +def configured_api_host() -> str: + return os.getenv("S1DL_API_HOST", DEFAULT_API_HOST) + + +def configured_api_port() -> int: + raw = os.getenv("S1DL_API_PORT") + if not raw: + return DEFAULT_API_PORT + return int(raw) diff --git a/src/sentinel_orbit_downloader/core/provinces.py b/src/sentinel_orbit_downloader/core/provinces.py new file mode 100644 index 0000000..225e19f --- /dev/null +++ b/src/sentinel_orbit_downloader/core/provinces.py @@ -0,0 +1,40 @@ +from __future__ import annotations + + +PROVINCES: dict[str, str] = { + "110000": "北京市", + "120000": "天津市", + "130000": "河北省", + "140000": "山西省", + "150000": "内蒙古自治区", + "210000": "辽宁省", + "220000": "吉林省", + "230000": "黑龙江省", + "310000": "上海市", + "320000": "江苏省", + "330000": "浙江省", + "340000": "安徽省", + "350000": "福建省", + "360000": "江西省", + "370000": "山东省", + "410000": "河南省", + "420000": "湖北省", + "430000": "湖南省", + "440000": "广东省", + "450000": "广西壮族自治区", + "460000": "海南省", + "500000": "重庆市", + "510000": "四川省", + "520000": "贵州省", + "530000": "云南省", + "540000": "西藏自治区", + "610000": "陕西省", + "620000": "甘肃省", + "630000": "青海省", + "640000": "宁夏回族自治区", + "650000": "新疆维吾尔自治区", + "710000": "台湾省", + "810000": "香港特别行政区", + "820000": "澳门特别行政区", +} + diff --git a/src/sentinel_orbit_downloader/services/__init__.py b/src/sentinel_orbit_downloader/services/__init__.py new file mode 100644 index 0000000..a69ee20 --- /dev/null +++ b/src/sentinel_orbit_downloader/services/__init__.py @@ -0,0 +1 @@ +"""Application services.""" diff --git a/src/sentinel_orbit_downloader/services/aoi.py b/src/sentinel_orbit_downloader/services/aoi.py new file mode 100644 index 0000000..6edf331 --- /dev/null +++ b/src/sentinel_orbit_downloader/services/aoi.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Iterable, Sequence + + +def bbox_to_wkt(value: str) -> str: + parts = [part.strip() for part in value.split(",")] + if len(parts) != 4: + raise ValueError("--bbox must be min_lon,min_lat,max_lon,max_lat") + try: + min_lon, min_lat, max_lon, max_lat = (float(part) for part in parts) + except ValueError as exc: + raise ValueError("--bbox values must be numbers") from exc + if min_lon >= max_lon or min_lat >= max_lat: + raise ValueError("--bbox min values must be smaller than max values") + return ( + f"POLYGON(({min_lon} {min_lat},{max_lon} {min_lat}," + f"{max_lon} {max_lat},{min_lon} {max_lat},{min_lon} {min_lat}))" + ) + + +def aoi_file_to_wkt(path: Path) -> str: + text = path.read_text(encoding="utf-8").strip() + if not text: + raise ValueError(f"AOI file is empty: {path}") + if text[0] != "{": + return text + + data = json.loads(text) + geometry = geojson_geometry(data) + return geometry_to_wkt(geometry) + + +def geojson_geometry(data: dict[str, Any]) -> dict[str, Any]: + 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]) + if kind == "Feature": + geometry = data.get("geometry") + if not geometry: + raise ValueError("GeoJSON Feature has no geometry") + return geometry + if kind in {"Polygon", "MultiPolygon", "Point", "MultiPoint", "LineString"}: + return data + raise ValueError(f"unsupported GeoJSON type: {kind}") + + +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 coords_to_text(coords: Iterable[Sequence[float]]) -> str: + return ",".join(f"{lon} {lat}" for lon, lat, *_ in coords) diff --git a/src/sentinel_orbit_downloader/services/asf_client.py b/src/sentinel_orbit_downloader/services/asf_client.py new file mode 100644 index 0000000..5a7a3eb --- /dev/null +++ b/src/sentinel_orbit_downloader/services/asf_client.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import csv +import getpass +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Sequence + +import asf_search as asf + + +DEFAULT_FIELDS = ( + "scene", + "startTime", + "stopTime", + "processingLevel", + "beamModeType", + "flightDirection", + "polarization", + "pathNumber", + "platform", + "sizeMB", +) + + +@dataclass(frozen=True) +class SearchParams: + aoi_wkt: str + start: str + end: str + processing_level: str = "SLC" + beam_mode: str | None = "IW" + flight_direction: str | None = None + polarization: str | None = None + relative_orbit: int | None = None + max_results: int = 50 + + +def query_scenes(params: SearchParams): + kwargs: dict[str, Any] = { + "platform": [asf.PLATFORM.SENTINEL1], + "processingLevel": params.processing_level, + "intersectsWith": params.aoi_wkt, + "start": params.start, + "end": params.end, + "maxResults": params.max_results, + } + if params.beam_mode: + kwargs["beamMode"] = params.beam_mode + if params.flight_direction: + kwargs["flightDirection"] = params.flight_direction.upper() + if params.polarization: + kwargs["polarization"] = params.polarization + if params.relative_orbit: + kwargs["relativeOrbit"] = params.relative_orbit + return asf.geo_search(**kwargs) + + +def resolve_credentials( + username: str | None = None, + password: str | None = None, +) -> tuple[str | None, str | None]: + resolved_username = username or os.getenv("EARTHDATA_USER") or os.getenv("EARTHDATA_USERNAME") + resolved_password = password or os.getenv("EARTHDATA_PASS") or os.getenv("EARTHDATA_PASSWORD") + return resolved_username, resolved_password + + +def credentials_available( + username: str | None = None, + password: str | None = None, +) -> bool: + resolved_username, resolved_password = resolve_credentials(username, password) + return bool(resolved_username and resolved_password) + + +def build_session( + username: str | None = None, + password: str | None = None, + ask_password: bool = False, +) -> asf.ASFSession: + username, password = resolve_credentials(username, password) + if ask_password: + password = getpass.getpass("Earthdata password: ") + + if not username or not password: + raise ValueError( + "ASF product downloads require NASA Earthdata credentials. " + "Set EARTHDATA_USER/EARTHDATA_PASS or pass --username/--ask-password." + ) + + return asf.ASFSession().auth_with_creds(username, password) + + +def product_size_mb(props: dict[str, Any]) -> float | None: + if props.get("sizeMB") is not None: + return float(props["sizeMB"]) + if props.get("bytes") is not None: + return float(props["bytes"]) / (1024 * 1024) + return None + + +def scene_name_from_product(product) -> str | None: + from .orbit_client import normalize_scene_name + + props = getattr(product, "properties", {}) + candidates: Sequence[Any] = ( + props.get("sceneName"), + props.get("fileID"), + props.get("fileName"), + props.get("url"), + ) + for candidate in candidates: + if not candidate: + continue + scene = normalize_scene_name(str(candidate)) + if scene: + return scene + return None + + +def manifest_row(product) -> dict[str, Any]: + props = product.properties + return { + "scene": scene_name_from_product(product), + "startTime": props.get("startTime"), + "stopTime": props.get("stopTime"), + "processingLevel": props.get("processingLevel"), + "beamModeType": props.get("beamModeType"), + "flightDirection": props.get("flightDirection"), + "polarization": props.get("polarization"), + "pathNumber": props.get("pathNumber"), + "platform": props.get("platform"), + "sizeMB": product_size_mb(props), + } + + +def write_manifest(results, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + suffix = path.suffix.lower() + rows = [manifest_row(product) for product in results] + if suffix == ".json": + path.write_text(json.dumps(rows, indent=2, ensure_ascii=False, default=str), encoding="utf-8") + return + if suffix not in {"", ".csv"}: + raise ValueError("manifest path must end with .csv or .json") + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=DEFAULT_FIELDS) + writer.writeheader() + writer.writerows(rows) diff --git a/src/sentinel_orbit_downloader/services/download_manager.py b/src/sentinel_orbit_downloader/services/download_manager.py new file mode 100644 index 0000000..79bbfb5 --- /dev/null +++ b/src/sentinel_orbit_downloader/services/download_manager.py @@ -0,0 +1,370 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Sequence + +import asf_search as asf + +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 +from sentinel_orbit_downloader.services.project_store import ( + get_job, + get_project, + list_job_scenes, + record_download_result, + record_job_event, + update_search_job, +) + + +@dataclass(frozen=True) +class DownloadJobRequest: + job_id: int + data_dir: Path | None = None + orbit_dir: Path | None = None + manifest_path: Path | None = None + skip_data: bool = False + skip_orbits: bool = False + processes: int = 2 + ignore_orbit_errors: bool = False + username: str | None = None + password: str | None = None + + +@dataclass(frozen=True) +class DownloadJobPaths: + root: Path + data_dir: Path + orbit_dir: Path + manifest_path: Path + + +def resolve_download_paths(db_path: Path, request: DownloadJobRequest) -> DownloadJobPaths: + job = get_job(db_path, request.job_id) + if job is None: + raise ValueError(f"Job not found: {request.job_id}") + + root = None + if request.data_dir is not None: + root = request.data_dir.parent if request.orbit_dir is None else request.data_dir.parent + elif request.orbit_dir is not None: + root = request.orbit_dir.parent + elif job.data_dir: + root = Path(job.data_dir).parent + elif job.project_id is not None: + project = get_project(db_path, job.project_id) + if project is not None: + root = Path(project.data_root) / "jobs" / f"job-{job.id:06d}" + if root is None: + root = Path(".s1dl") / "downloads" / f"job-{job.id:06d}" + + data_dir = request.data_dir or (Path(job.data_dir) if job.data_dir else root / "data") + orbit_dir = request.orbit_dir or (Path(job.orbit_dir) if job.orbit_dir else root / "orbits") + manifest_path = request.manifest_path or ( + Path(job.manifest_path) if job.manifest_path else root / "manifest.csv" + ) + return DownloadJobPaths(root=root, data_dir=data_dir, orbit_dir=orbit_dir, manifest_path=manifest_path) + + +def queue_download_job(db_path: Path, request: DownloadJobRequest) -> DownloadJobPaths: + job = get_job(db_path, request.job_id) + if job is None: + raise ValueError(f"Job not found: {request.job_id}") + scenes = list_job_scenes(db_path, request.job_id) + if not scenes: + raise ValueError(f"Job has no scenes to download: {request.job_id}") + + paths = resolve_download_paths(db_path, request) + paths.root.mkdir(parents=True, exist_ok=True) + update_search_job( + db_path, + request.job_id, + status="running", + result_count=job.result_count, + data_dir=str(paths.data_dir), + orbit_dir=str(paths.orbit_dir), + manifest_path=str(paths.manifest_path), + error_message=None, + ) + record_job_event( + db_path, + request.job_id, + "info", + f"Download queued for {len(scenes)} scene(s) into {paths.root}", + ) + return paths + + +def execute_download_job(db_path: Path, request: DownloadJobRequest) -> None: + job = get_job(db_path, request.job_id) + if job is None: + return + + paths = resolve_download_paths(db_path, request) + paths.root.mkdir(parents=True, exist_ok=True) + + try: + scenes = list_job_scenes(db_path, request.job_id) + scene_ids = [scene["scene_id"] for scene in scenes] + if not scene_ids: + update_search_job( + db_path, + request.job_id, + status="failed", + result_count=0, + data_dir=str(paths.data_dir), + orbit_dir=str(paths.orbit_dir), + manifest_path=str(paths.manifest_path), + error_message="No scenes available for download", + ) + record_job_event(db_path, request.job_id, "error", "Download job failed: no scenes available") + return + + if not request.skip_data and not credentials_available(request.username, request.password): + message = ( + "Download failed: Sentinel-1 product downloads require NASA Earthdata credentials. " + "Fill in Earthdata username/password, or set EARTHDATA_USER and EARTHDATA_PASS first." + ) + for scene_id in scene_ids: + record_download_result( + db_path, + job_id=request.job_id, + scene_id=scene_id, + kind="product", + status="failed", + path=None, + error_message=message, + ) + record_download_result( + db_path, + job_id=request.job_id, + scene_id=scene_id, + kind="orbit", + status="skipped", + path=None, + ) + update_search_job( + db_path, + request.job_id, + status="failed", + result_count=job.result_count, + data_dir=str(paths.data_dir), + orbit_dir=str(paths.orbit_dir), + manifest_path=str(paths.manifest_path), + error_message=message, + ) + record_job_event(db_path, request.job_id, "error", message) + return + + product_error: str | None = None + if request.skip_data: + record_job_event(db_path, request.job_id, "info", "Product download skipped by request") + 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} + 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: + record_job_event( + db_path, + request.job_id, + "warning", + f"{len(missing_scene_ids)} scene(s) could not be reloaded from ASF search", + ) + for scene_id in missing_scene_ids: + record_download_result( + db_path, + job_id=request.job_id, + scene_id=scene_id, + kind="product", + status="failed", + path=None, + error_message="Scene was not returned by ASF granule_search", + ) + if request.skip_orbits: + record_download_result( + db_path, + job_id=request.job_id, + scene_id=scene_id, + kind="orbit", + status="skipped", + 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) + record_job_event( + db_path, + request.job_id, + "info", + f"Product download finished into {paths.data_dir}", + ) + 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) + + orbit_failures = 0 + if request.skip_orbits: + record_job_event(db_path, request.job_id, "info", "Orbit download skipped by request") + record_orbit_skips(db_path, request.job_id, scene_ids) + else: + paths.orbit_dir.mkdir(parents=True, exist_ok=True) + orbit_results = download_orbits(scene_ids, paths.orbit_dir) + orbit_failures = sum(1 for result in orbit_results if result.error) + for result in orbit_results: + record_download_result( + db_path, + job_id=request.job_id, + scene_id=result.scene, + kind="orbit", + status="succeeded" if result.error is None else "failed", + path=str(result.path) if result.path is not None else None, + error_message=result.error, + ) + record_job_event( + db_path, + request.job_id, + "info", + f"Orbit download finished: {len(orbit_results) - orbit_failures} ok, {orbit_failures} failed", + ) + + product_failures = count_download_failures(db_path, request.job_id, "product") + 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)): + final_status = "failed" + + update_search_job( + db_path, + request.job_id, + status=final_status, + result_count=job.result_count, + data_dir=str(paths.data_dir), + orbit_dir=str(paths.orbit_dir), + manifest_path=str(paths.manifest_path), + error_message=product_error, + ) + except Exception as exc: + update_search_job( + db_path, + request.job_id, + status="failed", + result_count=job.result_count, + data_dir=str(paths.data_dir), + orbit_dir=str(paths.orbit_dir), + manifest_path=str(paths.manifest_path), + error_message=str(exc), + ) + record_job_event(db_path, request.job_id, "error", f"Download execution failed: {exc}") + + +def record_product_downloads( + db_path: Path, + job_id: int, + products: Sequence, + data_dir: Path | None, + fallback_error: str | None = None, +) -> None: + for product in products: + scene_id = scene_name_from_product(product) + if scene_id is None: + continue + props = product.properties + if data_dir is None: + record_download_result( + db_path, + job_id=job_id, + scene_id=scene_id, + kind="product", + status="skipped", + path=None, + url=string_value(props.get("url")), + bytes_total=float(props["bytes"]) if props.get("bytes") is not None else None, + bytes_done=0, + ) + continue + + expected = expected_product_path(product, data_dir) + exists = expected.exists() + record_download_result( + db_path, + job_id=job_id, + scene_id=scene_id, + kind="product", + status="succeeded" if exists else "failed", + path=str(expected) if exists else None, + url=string_value(props.get("url")), + bytes_total=float(props["bytes"]) if props.get("bytes") is not None else None, + bytes_done=float(props["bytes"]) if exists and props.get("bytes") is not None else None, + error_message=None if exists else (fallback_error or "Downloaded file not found at expected path"), + ) + + +def record_product_skips(db_path: Path, job_id: int, scene_ids: Sequence[str]) -> None: + for scene_id in scene_ids: + record_download_result( + db_path, + job_id=job_id, + scene_id=scene_id, + kind="product", + status="skipped", + path=None, + ) + + +def record_orbit_skips(db_path: Path, job_id: int, scene_ids: Sequence[str]) -> None: + for scene_id in scene_ids: + record_download_result( + db_path, + job_id=job_id, + scene_id=scene_id, + kind="orbit", + status="skipped", + path=None, + ) + + +def expected_product_path(product, data_dir: Path) -> Path: + props = product.properties + file_name = props.get("fileName") + if file_name: + return data_dir / str(file_name) + url = props.get("url") + if url: + return data_dir / Path(str(url)).name + scene = scene_name_from_product(product) or "unknown_scene" + return data_dir / f"{scene}.zip" + + +def string_value(value) -> str | None: + if value is None: + return None + return str(value) + + +def count_download_failures(db_path: Path, job_id: int, kind: str) -> int: + from sentinel_orbit_downloader.storage.db import connect + + if kind == "product": + column = "product_status" + elif kind == "orbit": + column = "orbit_status" + else: + raise ValueError(f"unsupported download kind: {kind}") + + with connect(db_path) as connection: + row = connection.execute( + f""" + SELECT COUNT(*) AS failure_count + FROM job_scenes + WHERE job_id = ? AND {column} = 'failed' + """, + (job_id,), + ).fetchone() + return int(row["failure_count"]) diff --git a/src/sentinel_orbit_downloader/services/orbit_client.py b/src/sentinel_orbit_downloader/services/orbit_client.py new file mode 100644 index 0000000..a07c621 --- /dev/null +++ b/src/sentinel_orbit_downloader/services/orbit_client.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Sequence + +import s1_orbits +from s1_orbits.exceptions import InvalidSceneError, OrbitNotFoundError + +SCENE_RE = re.compile(r"^(S1[A-D]_.+?)(?:\.SAFE|\.zip)?$", re.IGNORECASE) + + +@dataclass(frozen=True) +class OrbitResult: + scene: str + path: Path | None + error: str | None = None + + +def normalize_scene_name(value: str) -> str | None: + name = Path(value).name.strip() + for suffix in (".SAFE", ".safe", ".ZIP", ".zip"): + if name.endswith(suffix): + name = name[: -len(suffix)] + break + match = SCENE_RE.match(name) + if not match: + return None + return match.group(1) + + +def unique(values: Iterable[str]) -> list[str]: + seen = set() + output = [] + for value in values: + if value in seen: + continue + seen.add(value) + output.append(value) + return output + + +def collect_orbit_scenes( + scenes: Sequence[str] | None = None, + scene_file: Path | None = None, + data_dir: Path | None = None, +) -> list[str]: + if scenes: + return unique(filter(None, (normalize_scene_name(scene) for scene in scenes))) + + if scene_file: + scene_names = [] + for line in scene_file.read_text(encoding="utf-8").splitlines(): + clean = line.strip() + if not clean or clean.startswith("#"): + continue + scene_names.append(normalize_scene_name(clean)) + return unique(filter(None, scene_names)) + + if not data_dir: + return [] + + found = [] + for path in data_dir.iterdir(): + if not path.name.upper().startswith(("S1A_", "S1B_", "S1C_", "S1D_")): + continue + scene = normalize_scene_name(path.name) + if scene: + found.append(scene) + return unique(found) + + +def scene_names_from_results(results) -> list[str]: + from .asf_client import scene_name_from_product + + scenes = [scene_name_from_product(product) for product in results] + return unique(scene for scene in scenes if scene) + + +def download_orbits(scenes: Sequence[str], orbit_dir: Path) -> list[OrbitResult]: + orbit_dir.mkdir(parents=True, exist_ok=True) + results: list[OrbitResult] = [] + for scene in scenes: + try: + path = s1_orbits.fetch_for_scene(scene, dir=orbit_dir) + results.append(OrbitResult(scene=scene, path=path)) + except (InvalidSceneError, OrbitNotFoundError) 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/services/project_store.py b/src/sentinel_orbit_downloader/services/project_store.py new file mode 100644 index 0000000..dfde788 --- /dev/null +++ b/src/sentinel_orbit_downloader/services/project_store.py @@ -0,0 +1,545 @@ +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from sentinel_orbit_downloader.storage.db import connect + + +NON_SLUG_RE = re.compile(r"[^\w\u4e00-\u9fff-]+", re.UNICODE) + + +@dataclass(frozen=True) +class ProjectRecord: + id: int + name: str + slug: str + data_root: str + description: str | None + created_at: str + updated_at: str + + +def create_project( + db_path: Path, + name: str, + data_root: Path, + description: str | None = None, + slug: str | None = None, +) -> ProjectRecord: + final_slug = slug or slugify(name) + if not final_slug: + raise ValueError("project slug is empty; choose a clearer project name") + + data_root.mkdir(parents=True, exist_ok=True) + + with connect(db_path) as connection: + exists = connection.execute("SELECT id FROM projects WHERE slug = ?", (final_slug,)).fetchone() + if exists is not None: + raise ValueError(f"project slug already exists: {final_slug}") + + cursor = connection.execute( + """ + INSERT INTO projects(name, slug, data_root, description) + VALUES (?, ?, ?, ?) + """, + (name, final_slug, str(data_root), description), + ) + project_id = int(cursor.lastrowid) + row = connection.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone() + + return row_to_project(row) + + +def list_projects(db_path: Path) -> list[ProjectRecord]: + with connect(db_path) as connection: + rows = connection.execute( + "SELECT * FROM projects ORDER BY created_at DESC, id DESC" + ).fetchall() + return [row_to_project(row) for row in rows] + + +def get_project(db_path: Path, project_id: int) -> ProjectRecord | None: + with connect(db_path) as connection: + row = connection.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone() + if row is None: + return None + return row_to_project(row) + + +def slugify(value: str) -> str: + text = value.strip().lower().replace(" ", "-") + text = NON_SLUG_RE.sub("-", text) + text = re.sub(r"-{2,}", "-", text) + text = text.strip("-") + return text + + +def row_to_project(row) -> ProjectRecord: + return ProjectRecord( + id=int(row["id"]), + name=str(row["name"]), + slug=str(row["slug"]), + data_root=str(row["data_root"]), + description=row["description"], + created_at=str(row["created_at"]), + updated_at=str(row["updated_at"]), + ) + + +@dataclass(frozen=True) +class SearchJobRecord: + id: int + project_id: int | None + aoi_id: int | None + source_type: str + source_value: str | None + region_adcode: str | None + region_name: str | None + start_time: str + end_time: str + processing_level: str + beam_mode: str | None + flight_direction: str | None + polarization: str | None + relative_orbit: int | None + max_results: int + status: str + result_count: int | None + data_dir: str | None + orbit_dir: str | None + manifest_path: str | None + error_message: str | None + created_at: str + finished_at: str | None + + +def create_search_job( + db_path: Path, + *, + project_id: int | None, + source_type: str, + source_value: str | None, + region_adcode: str | None, + region_name: str | None, + start_time: str, + end_time: str, + processing_level: str, + beam_mode: str | None, + flight_direction: str | None, + polarization: str | None, + relative_orbit: int | None, + max_results: int, + status: str = "running", + data_dir: str | None = None, + orbit_dir: str | None = None, + manifest_path: str | None = None, +) -> SearchJobRecord: + with connect(db_path) as connection: + cursor = connection.execute( + """ + INSERT INTO search_jobs( + project_id, aoi_id, source_type, source_value, region_adcode, region_name, + start_time, end_time, processing_level, beam_mode, flight_direction, + polarization, relative_orbit, max_results, status, data_dir, orbit_dir, manifest_path + ) + VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + project_id, + source_type, + source_value, + region_adcode, + region_name, + start_time, + end_time, + processing_level, + beam_mode, + flight_direction, + polarization, + relative_orbit, + max_results, + status, + data_dir, + orbit_dir, + manifest_path, + ), + ) + job_id = int(cursor.lastrowid) + row = connection.execute("SELECT * FROM search_jobs WHERE id = ?", (job_id,)).fetchone() + return row_to_search_job(row) + + +def update_search_job( + db_path: Path, + job_id: int, + *, + status: str, + result_count: int | None = None, + data_dir: str | None = None, + orbit_dir: str | None = None, + manifest_path: str | None = None, + error_message: str | None = None, +) -> None: + final_statuses = {"succeeded", "failed", "partial_failed", "canceled"} + with connect(db_path) as connection: + if status in final_statuses: + connection.execute( + """ + UPDATE search_jobs + SET + status = ?, + result_count = COALESCE(?, result_count), + data_dir = COALESCE(?, data_dir), + orbit_dir = COALESCE(?, orbit_dir), + manifest_path = COALESCE(?, manifest_path), + error_message = ?, + finished_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + ( + status, + result_count, + data_dir, + orbit_dir, + manifest_path, + error_message, + job_id, + ), + ) + else: + connection.execute( + """ + UPDATE search_jobs + SET + status = ?, + result_count = COALESCE(?, result_count), + data_dir = COALESCE(?, data_dir), + orbit_dir = COALESCE(?, orbit_dir), + manifest_path = COALESCE(?, manifest_path), + error_message = ?, + finished_at = NULL + WHERE id = ? + """, + ( + status, + result_count, + data_dir, + orbit_dir, + manifest_path, + error_message, + job_id, + ), + ) + + +def record_job_event(db_path: Path, job_id: int, level: str, message: str) -> None: + with connect(db_path) as connection: + if not job_exists(connection, job_id): + return + connection.execute( + "INSERT INTO job_events(job_id, level, message) VALUES (?, ?, ?)", + (job_id, level, message), + ) + + +def save_query_results(db_path: Path, job_id: int, results) -> None: + with connect(db_path) as connection: + if not job_exists(connection, job_id): + return + for product in results: + scene_id = scene_id_from_product(product) + if scene_id is None: + continue + props = product.properties + geometry_json = json.dumps(product.geometry, ensure_ascii=False, default=str) + properties_json = json.dumps(props, ensure_ascii=False, default=str) + connection.execute( + """ + INSERT INTO scenes( + scene_id, platform, start_time, stop_time, processing_level, beam_mode, + flight_direction, polarization, relative_orbit, size_bytes, size_mb, + download_url, geometry_json, properties_json + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(scene_id) DO UPDATE SET + platform = excluded.platform, + start_time = excluded.start_time, + stop_time = excluded.stop_time, + processing_level = excluded.processing_level, + beam_mode = excluded.beam_mode, + flight_direction = excluded.flight_direction, + polarization = excluded.polarization, + relative_orbit = excluded.relative_orbit, + size_bytes = excluded.size_bytes, + size_mb = excluded.size_mb, + download_url = excluded.download_url, + geometry_json = excluded.geometry_json, + properties_json = excluded.properties_json + """, + ( + scene_id, + platform_from_scene(scene_id), + string_value(props.get("startTime")), + string_value(props.get("stopTime")), + string_value(props.get("processingLevel")), + string_value(props.get("beamModeType")), + string_value(props.get("flightDirection")), + string_value(props.get("polarization")), + int(props["pathNumber"]) if props.get("pathNumber") is not None else None, + float(props["bytes"]) if props.get("bytes") is not None else None, + product_size_mb(props), + string_value(props.get("url")), + geometry_json, + properties_json, + ), + ) + connection.execute( + """ + INSERT INTO job_scenes(job_id, scene_id, selected, product_status, orbit_status) + VALUES (?, ?, 1, 'pending', 'pending') + ON CONFLICT(job_id, scene_id) DO NOTHING + """, + (job_id, scene_id), + ) + + +def record_download_result( + db_path: Path, + *, + job_id: int, + scene_id: str, + kind: str, + status: str, + path: str | None, + url: str | None = None, + bytes_total: float | None = None, + bytes_done: float | None = None, + error_message: str | None = None, +) -> None: + with connect(db_path) as connection: + if not job_exists(connection, job_id): + return + connection.execute( + """ + INSERT INTO downloads( + job_id, scene_id, kind, status, url, path, bytes_total, bytes_done, error_message + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + job_id, + scene_id, + kind, + status, + url, + path, + bytes_total, + bytes_done, + error_message, + ), + ) + if kind == "product": + connection.execute( + """ + UPDATE job_scenes + SET product_status = ?, product_path = COALESCE(?, product_path) + WHERE job_id = ? AND scene_id = ? + """, + (status, path, job_id, scene_id), + ) + elif kind == "orbit": + connection.execute( + """ + UPDATE job_scenes + SET orbit_status = ?, orbit_path = COALESCE(?, orbit_path) + WHERE job_id = ? AND scene_id = ? + """, + (status, path, job_id, scene_id), + ) + + +def delete_job(db_path: Path, job_id: int) -> bool: + with connect(db_path) as connection: + if not job_exists(connection, job_id): + return False + delete_job_rows(connection, job_id) + prune_orphan_scenes(connection) + return True + + +def clear_jobs( + db_path: Path, + project_id: int | None = None, + include_running: bool = False, +) -> list[int]: + sql = "SELECT id FROM search_jobs" + conditions: list[str] = [] + params: list[Any] = [] + + if project_id is not None: + conditions.append("project_id = ?") + params.append(project_id) + if not include_running: + conditions.append("status != 'running'") + + if conditions: + sql += " WHERE " + " AND ".join(conditions) + sql += " ORDER BY id" + + with connect(db_path) as connection: + rows = connection.execute(sql, params).fetchall() + job_ids = [int(row["id"]) for row in rows] + for job_id in job_ids: + delete_job_rows(connection, job_id) + prune_orphan_scenes(connection) + return job_ids + + +def list_jobs(db_path: Path, project_id: int | None = None, limit: int = 20) -> list[SearchJobRecord]: + sql = """ + SELECT * + FROM search_jobs + """ + params: list[Any] = [] + if project_id is not None: + sql += " WHERE project_id = ?" + params.append(project_id) + sql += " ORDER BY id DESC LIMIT ?" + params.append(limit) + + with connect(db_path) as connection: + rows = connection.execute(sql, params).fetchall() + return [row_to_search_job(row) for row in rows] + + +def get_job(db_path: Path, job_id: int) -> SearchJobRecord | None: + with connect(db_path) as connection: + row = connection.execute("SELECT * FROM search_jobs WHERE id = ?", (job_id,)).fetchone() + if row is None: + return None + return row_to_search_job(row) + + +def list_job_scenes(db_path: Path, job_id: int) -> list[dict[str, Any]]: + with connect(db_path) as connection: + rows = connection.execute( + """ + SELECT + js.job_id, + js.scene_id, + js.selected, + js.product_status, + js.orbit_status, + js.product_path, + js.orbit_path, + s.platform, + s.start_time, + s.stop_time, + s.processing_level, + s.flight_direction, + s.relative_orbit, + s.size_mb + FROM job_scenes AS js + JOIN scenes AS s ON s.scene_id = js.scene_id + WHERE js.job_id = ? + ORDER BY s.start_time, js.scene_id + """, + (job_id,), + ).fetchall() + return [dict(row) for row in rows] + + +def list_job_events(db_path: Path, job_id: int) -> list[dict[str, Any]]: + with connect(db_path) as connection: + rows = connection.execute( + "SELECT id, level, message, created_at FROM job_events WHERE job_id = ? ORDER BY id", + (job_id,), + ).fetchall() + return [dict(row) for row in rows] + + +def row_to_search_job(row) -> SearchJobRecord: + return SearchJobRecord( + id=int(row["id"]), + project_id=int(row["project_id"]) if row["project_id"] is not None else None, + aoi_id=int(row["aoi_id"]) if row["aoi_id"] is not None else None, + source_type=str(row["source_type"]), + source_value=row["source_value"], + region_adcode=row["region_adcode"], + region_name=row["region_name"], + start_time=str(row["start_time"]), + end_time=str(row["end_time"]), + processing_level=str(row["processing_level"]), + beam_mode=row["beam_mode"], + flight_direction=row["flight_direction"], + polarization=row["polarization"], + relative_orbit=int(row["relative_orbit"]) if row["relative_orbit"] is not None else None, + max_results=int(row["max_results"]), + status=str(row["status"]), + result_count=int(row["result_count"]) if row["result_count"] is not None else None, + data_dir=row["data_dir"], + orbit_dir=row["orbit_dir"], + manifest_path=row["manifest_path"], + error_message=row["error_message"], + created_at=str(row["created_at"]), + finished_at=row["finished_at"], + ) + + +def scene_id_from_product(product) -> str | None: + from sentinel_orbit_downloader.services.asf_client import scene_name_from_product + + return scene_name_from_product(product) + + +def platform_from_scene(scene_id: str) -> str | None: + parts = scene_id.split("_", 1) + if not parts: + return None + return parts[0] + + +def product_size_mb(props: dict[str, Any]) -> float | None: + if props.get("sizeMB") is not None: + return float(props["sizeMB"]) + if props.get("bytes") is not None: + return float(props["bytes"]) / (1024 * 1024) + return None + + +def string_value(value: Any) -> str | None: + if value is None: + return None + return str(value) + + +def job_exists(connection, job_id: int) -> bool: + row = connection.execute( + "SELECT 1 FROM search_jobs WHERE id = ?", + (job_id,), + ).fetchone() + return row is not None + + +def delete_job_rows(connection, job_id: int) -> None: + connection.execute("DELETE FROM job_events WHERE job_id = ?", (job_id,)) + connection.execute("DELETE FROM downloads WHERE job_id = ?", (job_id,)) + connection.execute("DELETE FROM job_scenes WHERE job_id = ?", (job_id,)) + connection.execute("DELETE FROM search_jobs WHERE id = ?", (job_id,)) + + +def prune_orphan_scenes(connection) -> None: + connection.execute( + """ + DELETE FROM scenes + WHERE NOT EXISTS ( + SELECT 1 + FROM job_scenes + WHERE job_scenes.scene_id = scenes.scene_id + ) + """ + ) diff --git a/src/sentinel_orbit_downloader/services/region_catalog.py b/src/sentinel_orbit_downloader/services/region_catalog.py new file mode 100644 index 0000000..33ffe00 --- /dev/null +++ b/src/sentinel_orbit_downloader/services/region_catalog.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from shapely.geometry import shape + +from sentinel_orbit_downloader.core.provinces import PROVINCES +from sentinel_orbit_downloader.storage.db import connect, set_setting + + +@dataclass(frozen=True) +class RegionImportStats: + source_path: Path + simplify_tolerance: float + total_features: int + imported: int + + +@dataclass(frozen=True) +class RegionRecord: + adcode: str + name: str + level: str + parent_adcode: str | None + parent_name: str | None + province_adcode: str | None + province_name: str | None + center_lon: float | None + center_lat: float | None + centroid_lon: float | None + centroid_lat: float | None + min_lon: float | None + min_lat: float | None + max_lon: float | None + max_lat: float | None + children_num: int | None + tree_id: str | None + source_path: str | None + geometry_json: str + wkt: str + simplified_wkt: str + simplify_tolerance: float + geometry_hash: str + + @property + def bbox(self) -> tuple[float, float, float, float] | None: + values = (self.min_lon, self.min_lat, self.max_lon, self.max_lat) + if any(value is None for value in values): + return None + return values # type: ignore[return-value] + + @property + def label(self) -> str: + parts = [] + if self.province_name and self.province_name != self.name: + parts.append(self.province_name) + if self.parent_name and self.parent_name not in parts and self.parent_name != self.name: + parts.append(self.parent_name) + parts.append(self.name) + return " / ".join(parts) + + +def import_regions( + db_path: Path, + source_path: Path, + simplify_tolerance: float = 0.01, +) -> RegionImportStats: + data = json.loads(source_path.read_text(encoding="utf-8")) + features = data.get("features") or [] + rows = [feature_to_row(feature, source_path, simplify_tolerance) for feature in features] + + with connect(db_path) as connection: + connection.execute("DELETE FROM regions") + connection.executemany( + """ + INSERT INTO regions ( + adcode, name, level, parent_adcode, province_adcode, province_name, + center_lon, center_lat, centroid_lon, centroid_lat, + min_lon, min_lat, max_lon, max_lat, children_num, tree_id, + source_path, geometry_json, wkt, simplified_wkt, simplify_tolerance, geometry_hash + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + rows, + ) + set_setting(connection, "regions.source_path", str(source_path.resolve())) + set_setting(connection, "regions.simplify_tolerance", str(simplify_tolerance)) + set_setting(connection, "regions.feature_count", str(len(features))) + + return RegionImportStats( + source_path=source_path, + simplify_tolerance=simplify_tolerance, + total_features=len(features), + imported=len(rows), + ) + + +def search_regions( + db_path: Path, + query: str, + limit: int = 20, + level: str | None = None, +) -> list[RegionRecord]: + sql = """ + SELECT + r.*, + parent.name AS parent_name + FROM regions AS r + LEFT JOIN regions AS parent ON parent.adcode = r.parent_adcode + WHERE ( + r.name LIKE :like_query + OR r.adcode LIKE :like_query + OR IFNULL(r.province_name, '') LIKE :like_query + ) + """ + params: dict[str, Any] = { + "like_query": f"%{query}%", + "exact_query": query, + "prefix_query": f"{query}%", + "limit": limit, + } + if level: + sql += " AND r.level = :level" + params["level"] = level + + sql += """ + ORDER BY + CASE + WHEN r.name = :exact_query THEN 0 + WHEN r.name LIKE :prefix_query THEN 1 + ELSE 2 + END, + LENGTH(r.name), + r.level, + r.adcode + LIMIT :limit + """ + + with connect(db_path) as connection: + rows = connection.execute(sql, params).fetchall() + return [row_to_region(row) for row in rows] + + +def get_region(db_path: Path, adcode: str) -> RegionRecord | None: + with connect(db_path) as connection: + row = connection.execute( + """ + SELECT + r.*, + parent.name AS parent_name + FROM regions AS r + LEFT JOIN regions AS parent ON parent.adcode = r.parent_adcode + WHERE r.adcode = ? + """, + (str(adcode),), + ).fetchone() + if row is None: + return None + return row_to_region(row) + + +def region_wkt(db_path: Path, adcode: str, simplified: bool = True) -> str: + region = get_region(db_path, adcode) + if region is None: + raise ValueError(f"region not found: {adcode}") + return region.simplified_wkt if simplified else region.wkt + + +def feature_to_row(feature: dict[str, Any], source_path: Path, simplify_tolerance: float) -> tuple[Any, ...]: + properties = feature.get("properties") or {} + geometry = feature.get("geometry") + if geometry is None: + raise ValueError("feature geometry is missing") + + geometry_text = json.dumps(geometry, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + geometry_hash = hashlib.sha256(geometry_text.encode("utf-8")).hexdigest() + geom = shape(geometry) + simplified = geom.simplify(simplify_tolerance, preserve_topology=True) + if simplified.is_empty: + simplified = geom + + adcode = str(properties.get("adcode")) + name = str(properties.get("name")) + level = str(properties.get("level") or "unknown") + parent_adcode = normalize_adcode((properties.get("parent") or {}).get("adcode")) + province_adcode = derive_province_adcode(adcode, level, parent_adcode, properties.get("acroutes")) + province_name = PROVINCES.get(province_adcode) if province_adcode else None + + center = normalize_point(properties.get("center")) + centroid = normalize_point(properties.get("centroid")) + min_lon, min_lat, max_lon, max_lat = geom.bounds + + return ( + adcode, + name, + level, + parent_adcode, + province_adcode, + province_name, + center[0] if center else None, + center[1] if center else None, + centroid[0] if centroid else None, + centroid[1] if centroid else None, + min_lon, + min_lat, + max_lon, + max_lat, + int(properties["childrenNum"]) if properties.get("childrenNum") is not None else None, + properties.get("treeID"), + str(source_path), + geometry_text, + geom.wkt, + simplified.wkt, + simplify_tolerance, + geometry_hash, + ) + + +def derive_province_adcode( + adcode: str, + level: str, + parent_adcode: str | None, + acroutes: Any, +) -> str | None: + if level == "province" and adcode in PROVINCES: + return adcode + + routes = [normalize_adcode(value) for value in acroutes or []] + for route in routes: + if route in PROVINCES: + return route + + if parent_adcode in PROVINCES: + return parent_adcode + + if adcode.isdigit() and len(adcode) == 6: + candidate = f"{adcode[:2]}0000" + if candidate in PROVINCES: + return candidate + return None + + +def normalize_adcode(value: Any) -> str | None: + if value is None: + return None + text = str(value) + return text if text else None + + +def normalize_point(value: Any) -> tuple[float, float] | None: + if not isinstance(value, list) or len(value) < 2: + return None + return (float(value[0]), float(value[1])) + + +def row_to_region(row) -> RegionRecord: + return RegionRecord( + adcode=str(row["adcode"]), + name=str(row["name"]), + level=str(row["level"]), + parent_adcode=row["parent_adcode"], + parent_name=row["parent_name"], + province_adcode=row["province_adcode"], + province_name=row["province_name"], + center_lon=row["center_lon"], + center_lat=row["center_lat"], + centroid_lon=row["centroid_lon"], + centroid_lat=row["centroid_lat"], + min_lon=row["min_lon"], + min_lat=row["min_lat"], + max_lon=row["max_lon"], + max_lat=row["max_lat"], + children_num=row["children_num"], + tree_id=row["tree_id"], + source_path=row["source_path"], + geometry_json=row["geometry_json"], + wkt=row["wkt"], + simplified_wkt=row["simplified_wkt"], + simplify_tolerance=float(row["simplify_tolerance"]), + geometry_hash=str(row["geometry_hash"]), + ) diff --git a/src/sentinel_orbit_downloader/services/settings_store.py b/src/sentinel_orbit_downloader/services/settings_store.py new file mode 100644 index 0000000..30ebbf1 --- /dev/null +++ b/src/sentinel_orbit_downloader/services/settings_store.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import base64 +import ctypes +from dataclasses import dataclass +from pathlib import Path + +from sentinel_orbit_downloader.storage.db import connect, get_setting, set_setting + + +EARTHDATA_USERNAME_KEY = "earthdata.username" +EARTHDATA_PASSWORD_KEY = "earthdata.password_dpapi" + + +@dataclass(frozen=True) +class EarthdataSettings: + username: str | None + has_password: bool + + +def get_earthdata_settings(db_path: Path) -> EarthdataSettings: + with connect(db_path) as connection: + username = get_setting(connection, EARTHDATA_USERNAME_KEY) + encrypted_password = get_setting(connection, EARTHDATA_PASSWORD_KEY) + return EarthdataSettings( + username=username, + has_password=bool(encrypted_password), + ) + + +def get_saved_earthdata_credentials(db_path: Path) -> tuple[str | None, str | None]: + with connect(db_path) as connection: + username = get_setting(connection, EARTHDATA_USERNAME_KEY) + encrypted_password = get_setting(connection, EARTHDATA_PASSWORD_KEY) + if not encrypted_password: + return username, None + return username, dpapi_decrypt(encrypted_password) + + +def save_earthdata_credentials(db_path: Path, username: str, password: str) -> EarthdataSettings: + final_username = username.strip() + if not final_username: + raise ValueError("Earthdata username cannot be empty") + if not password: + raise ValueError("Earthdata password cannot be empty") + + encrypted_password = dpapi_encrypt(password) + with connect(db_path) as connection: + set_setting(connection, EARTHDATA_USERNAME_KEY, final_username) + set_setting(connection, EARTHDATA_PASSWORD_KEY, encrypted_password) + return EarthdataSettings(username=final_username, has_password=True) + + +def clear_earthdata_credentials(db_path: Path) -> None: + with connect(db_path) as connection: + connection.execute( + "DELETE FROM settings WHERE key IN (?, ?)", + (EARTHDATA_USERNAME_KEY, EARTHDATA_PASSWORD_KEY), + ) + + +class DATA_BLOB(ctypes.Structure): + _fields_ = [ + ("cbData", ctypes.c_uint32), + ("pbData", ctypes.POINTER(ctypes.c_ubyte)), + ] + + +try: + kernel32 = ctypes.windll.kernel32 + crypt32 = ctypes.windll.crypt32 +except AttributeError: + kernel32 = None + crypt32 = None + + +CRYPTPROTECT_UI_FORBIDDEN = 0x01 + + +def dpapi_encrypt(value: str) -> str: + ensure_windows_dpapi() + raw = value.encode("utf-8") + in_blob = bytes_to_blob(raw) + out_blob = DATA_BLOB() + if not crypt32.CryptProtectData( + ctypes.byref(in_blob), + "sentinel-orbit-downloader", + None, + None, + None, + CRYPTPROTECT_UI_FORBIDDEN, + ctypes.byref(out_blob), + ): + raise ctypes.WinError() + try: + encrypted = blob_to_bytes(out_blob) + finally: + free_blob(out_blob) + return base64.b64encode(encrypted).decode("ascii") + + +def dpapi_decrypt(encoded_value: str) -> str: + ensure_windows_dpapi() + encrypted = base64.b64decode(encoded_value.encode("ascii")) + in_blob = bytes_to_blob(encrypted) + out_blob = DATA_BLOB() + if not crypt32.CryptUnprotectData( + ctypes.byref(in_blob), + None, + None, + None, + None, + CRYPTPROTECT_UI_FORBIDDEN, + ctypes.byref(out_blob), + ): + raise ctypes.WinError() + try: + decrypted = blob_to_bytes(out_blob) + finally: + free_blob(out_blob) + return decrypted.decode("utf-8") + + +def ensure_windows_dpapi() -> None: + if crypt32 is None or kernel32 is None: + raise RuntimeError("Earthdata local credential storage currently requires Windows") + + +def bytes_to_blob(value: bytes) -> DATA_BLOB: + if not value: + buffer = (ctypes.c_ubyte * 1)() + return DATA_BLOB(0, ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte))) + buffer = (ctypes.c_ubyte * len(value)).from_buffer_copy(value) + return DATA_BLOB(len(value), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte))) + + +def blob_to_bytes(blob: DATA_BLOB) -> bytes: + if not blob.cbData or not blob.pbData: + return b"" + return bytes(ctypes.string_at(blob.pbData, blob.cbData)) + + +def free_blob(blob: DATA_BLOB) -> None: + if blob.pbData: + kernel32.LocalFree(blob.pbData) diff --git a/src/sentinel_orbit_downloader/storage/__init__.py b/src/sentinel_orbit_downloader/storage/__init__.py new file mode 100644 index 0000000..0da8c4f --- /dev/null +++ b/src/sentinel_orbit_downloader/storage/__init__.py @@ -0,0 +1 @@ +"""Storage layer.""" diff --git a/src/sentinel_orbit_downloader/storage/db.py b/src/sentinel_orbit_downloader/storage/db.py new file mode 100644 index 0000000..8acdead --- /dev/null +++ b/src/sentinel_orbit_downloader/storage/db.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import sqlite3 +from pathlib import Path + + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS regions ( + adcode TEXT PRIMARY KEY, + name TEXT NOT NULL, + level TEXT NOT NULL, + parent_adcode TEXT, + province_adcode TEXT, + province_name TEXT, + center_lon REAL, + center_lat REAL, + centroid_lon REAL, + centroid_lat REAL, + min_lon REAL, + min_lat REAL, + max_lon REAL, + max_lat REAL, + children_num INTEGER, + tree_id TEXT, + source_path TEXT, + geometry_json TEXT NOT NULL, + wkt TEXT NOT NULL, + simplified_wkt TEXT NOT NULL, + simplify_tolerance REAL NOT NULL, + geometry_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_regions_name ON regions(name); +CREATE INDEX IF NOT EXISTS idx_regions_level ON regions(level); +CREATE INDEX IF NOT EXISTS idx_regions_parent ON regions(parent_adcode); +CREATE INDEX IF NOT EXISTS idx_regions_province ON regions(province_adcode); + +CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + data_root TEXT NOT NULL, + description TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS aois ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER, + name TEXT NOT NULL, + source_type TEXT NOT NULL, + region_adcode TEXT, + wkt TEXT NOT NULL, + bbox_json TEXT, + geometry_json TEXT, + simplification_tolerance REAL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(project_id) REFERENCES projects(id) +); + +CREATE TABLE IF NOT EXISTS search_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER, + aoi_id INTEGER, + source_type TEXT NOT NULL, + source_value TEXT, + region_adcode TEXT, + region_name TEXT, + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + processing_level TEXT NOT NULL, + beam_mode TEXT, + flight_direction TEXT, + polarization TEXT, + relative_orbit INTEGER, + max_results INTEGER NOT NULL, + status TEXT NOT NULL, + result_count INTEGER, + data_dir TEXT, + orbit_dir TEXT, + manifest_path TEXT, + error_message TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + finished_at TEXT, + FOREIGN KEY(project_id) REFERENCES projects(id), + FOREIGN KEY(aoi_id) REFERENCES aois(id) +); + +CREATE INDEX IF NOT EXISTS idx_search_jobs_project ON search_jobs(project_id); +CREATE INDEX IF NOT EXISTS idx_search_jobs_created ON search_jobs(created_at); +CREATE INDEX IF NOT EXISTS idx_search_jobs_region ON search_jobs(region_adcode); + +CREATE TABLE IF NOT EXISTS scenes ( + scene_id TEXT PRIMARY KEY, + platform TEXT, + start_time TEXT, + stop_time TEXT, + processing_level TEXT, + beam_mode TEXT, + flight_direction TEXT, + polarization TEXT, + relative_orbit INTEGER, + size_bytes REAL, + size_mb REAL, + download_url TEXT, + geometry_json TEXT, + properties_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS job_scenes ( + job_id INTEGER NOT NULL, + scene_id TEXT NOT NULL, + selected INTEGER NOT NULL DEFAULT 1, + product_status TEXT NOT NULL DEFAULT 'pending', + orbit_status TEXT NOT NULL DEFAULT 'pending', + product_path TEXT, + orbit_path TEXT, + PRIMARY KEY(job_id, scene_id), + FOREIGN KEY(job_id) REFERENCES search_jobs(id), + FOREIGN KEY(scene_id) REFERENCES scenes(scene_id) +); + +CREATE INDEX IF NOT EXISTS idx_job_scenes_scene ON job_scenes(scene_id); + +CREATE TABLE IF NOT EXISTS downloads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL, + scene_id TEXT NOT NULL, + kind TEXT NOT NULL, + status TEXT NOT NULL, + url TEXT, + path TEXT, + bytes_total REAL, + bytes_done REAL, + attempt_count INTEGER NOT NULL DEFAULT 1, + error_message TEXT, + started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + finished_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(job_id) REFERENCES search_jobs(id), + FOREIGN KEY(scene_id) REFERENCES scenes(scene_id) +); + +CREATE INDEX IF NOT EXISTS idx_downloads_job ON downloads(job_id); +CREATE INDEX IF NOT EXISTS idx_downloads_scene ON downloads(scene_id); + +CREATE TABLE IF NOT EXISTS job_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL, + level TEXT NOT NULL, + message TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(job_id) REFERENCES search_jobs(id) +); + +CREATE INDEX IF NOT EXISTS idx_job_events_job ON job_events(job_id); +""" + + +def connect(db_path: Path) -> sqlite3.Connection: + db_path.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(str(db_path)) + connection.row_factory = sqlite3.Row + connection.executescript(SCHEMA) + return connection + + +def set_setting(connection: sqlite3.Connection, key: str, value: str) -> None: + connection.execute( + """ + INSERT INTO settings(key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = CURRENT_TIMESTAMP + """, + (key, value), + ) + + +def get_setting(connection: sqlite3.Connection, key: str) -> str | None: + row = connection.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() + if row is None: + return None + return str(row["value"]) diff --git a/src/sentinel_orbit_downloader/web/app.css b/src/sentinel_orbit_downloader/web/app.css new file mode 100644 index 0000000..cb2d2aa --- /dev/null +++ b/src/sentinel_orbit_downloader/web/app.css @@ -0,0 +1,573 @@ +:root { + color-scheme: light; + --bg: #f3f5f9; + --panel: #ffffff; + --panel-soft: #f8fafc; + --line: #d8dee8; + --text: #15253b; + --muted: #5e6c80; + --accent: #2563eb; + --accent-soft: #e7efff; + --success: #157347; + --success-soft: #dbf5e7; + --warning: #9a6700; + --warning-soft: #fff0cf; + --danger: #b42318; + --danger-soft: #ffe3df; + --shadow: 0 1px 2px rgba(16, 24, 40, 0.05); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; + font-family: "Segoe UI", system-ui, sans-serif; + background: var(--bg); + color: var(--text); +} + +button, +input, +select, +textarea { + font: inherit; +} + +button, +input, +select, +textarea, +.item, +.chip, +.status-pill { + transition: border-color 120ms ease, background 120ms ease, color 120ms ease; +} + +.shell { + min-height: 100vh; + display: grid; + grid-template-columns: 320px minmax(0, 1fr); +} + +.sidebar { + padding: 20px; + border-right: 1px solid var(--line); + background: var(--panel); + display: flex; + flex-direction: column; + gap: 16px; +} + +.brand h1, +.topbar h2, +.panel h2 { + margin: 0; +} + +.brand p, +.topbar p { + margin: 4px 0 0; + color: var(--muted); + font-size: 13px; +} + +.content { + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; +} + +.message { + min-height: 38px; + display: inline-flex; + align-items: center; + padding: 8px 12px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel); + box-shadow: var(--shadow); + font-size: 13px; + line-height: 1.4; +} + +.message.info { + color: var(--text); +} + +.message.success { + border-color: #b8e3c9; + background: var(--success-soft); + color: var(--success); +} + +.message.warning { + border-color: #f0d48a; + background: var(--warning-soft); + color: var(--warning); +} + +.message.error { + border-color: #f3b8b1; + background: var(--danger-soft); + color: var(--danger); +} + +.panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); + padding: 16px; + min-width: 0; +} + +.section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.section-head h2 { + font-size: 15px; +} + +.stack { + display: flex; + flex-direction: column; + gap: 12px; +} + +.layout.two-col { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.grid { + display: grid; + gap: 12px; +} + +.grid.two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.grid.three { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.field label, +.field span { + font-size: 12px; + color: var(--muted); +} + +.field input, +.field select, +.field textarea { + width: 100%; + min-height: 38px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 6px; + background: #fff; + color: var(--text); +} + +.field textarea { + min-height: 84px; + resize: vertical; +} + +.field input:focus, +.field select:focus, +.field textarea:focus { + outline: none; + border-color: #94b5ff; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12); +} + +.inline-field input { + max-width: 160px; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +button { + min-height: 38px; + padding: 8px 12px; + border-radius: 6px; + border: 1px solid var(--line); + background: #fff; + color: var(--text); + cursor: pointer; +} + +button:hover { + border-color: #bcc8d9; +} + +button.primary { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +button.primary:hover { + background: #1d4ed8; + border-color: #1d4ed8; +} + +button.ghost { + background: var(--panel-soft); +} + +button.danger { + border-color: #f0c3bc; + background: #fff6f4; + color: var(--danger); +} + +button.danger:hover { + border-color: #e5a59c; + background: #ffeceb; +} + +button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.status-block { + display: flex; + align-items: center; + gap: 10px; +} + +.status-dot { + width: 10px; + height: 10px; + border-radius: 999px; + background: #98a2b3; + flex: none; +} + +.status-dot.ok { + background: var(--success); +} + +.status-dot.error { + background: var(--danger); +} + +.status-title { + font-size: 13px; + font-weight: 600; +} + +.status-subtle { + margin-top: 2px; + color: var(--muted); + font-size: 12px; +} + +.mono { + font-family: ui-monospace, "SFMono-Regular", Consolas, monospace; +} + +.list { + display: flex; + flex-direction: column; + gap: 8px; + min-height: 0; +} + +.list.tall { + max-height: 320px; + overflow: auto; +} + +.item { + width: 100%; + border: 1px solid var(--line); + border-radius: 6px; + background: #fff; + text-align: left; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.item:hover { + border-color: #c1d2fa; +} + +.item.active { + border-color: var(--accent); + background: #f5f8ff; +} + +.item-title { + font-size: 13px; + font-weight: 600; + line-height: 1.45; +} + +.item-subtle { + font-size: 12px; + color: var(--muted); + line-height: 1.45; +} + +.detail-card, +.events { + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel-soft); + padding: 12px; +} + +.detail-card { + min-height: 150px; +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.detail-row { + display: flex; + flex-direction: column; + gap: 3px; +} + +.detail-label { + font-size: 12px; + color: var(--muted); +} + +.detail-value { + font-size: 13px; + line-height: 1.4; +} + +.chip-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.chip { + display: inline-flex; + align-items: center; + min-height: 32px; + padding: 6px 10px; + border-radius: 999px; + background: var(--panel-soft); + border: 1px solid var(--line); + font-size: 12px; + line-height: 1.4; +} + +.status-pill { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + line-height: 1.4; + background: var(--panel-soft); + color: var(--muted); +} + +.status-pill.pending, +.status-pill.queued, +.status-pill.skipped { + background: #edf1f6; + color: var(--muted); +} + +.status-pill.running { + background: var(--accent-soft); + color: var(--accent); +} + +.status-pill.succeeded { + background: var(--success-soft); + color: var(--success); +} + +.status-pill.partial_failed { + background: var(--warning-soft); + color: var(--warning); +} + +.status-pill.failed, +.status-pill.error { + background: var(--danger-soft); + color: var(--danger); +} + +.toggle-row { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.toggle { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 38px; + padding: 0; + color: var(--text); +} + +.toggle input { + width: 16px; + height: 16px; +} + +.note { + font-size: 12px; + line-height: 1.5; + color: var(--muted); + background: var(--panel-soft); + border: 1px solid var(--line); + border-radius: 6px; + padding: 10px 12px; +} + +.detail-layout { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(260px, 0.75fr); + gap: 16px; +} + +.table-wrap { + overflow: auto; + border: 1px solid var(--line); + border-radius: 6px; + background: #fff; +} + +table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + font-size: 12px; +} + +thead { + background: #fafbfd; +} + +th, +td { + border-top: 1px solid var(--line); + text-align: left; + vertical-align: top; + padding: 9px 8px; + word-break: break-word; +} + +thead th { + border-top: 0; + color: var(--muted); + font-weight: 600; +} + +.events { + min-height: 280px; + max-height: 420px; + overflow: auto; +} + +.event { + padding: 8px 0; + border-top: 1px solid #e8edf4; +} + +.event:first-child { + border-top: 0; + padding-top: 0; +} + +.event-title { + font-size: 12px; + font-weight: 600; +} + +.event-meta { + margin-top: 2px; + font-size: 12px; + color: var(--muted); +} + +.event-message { + margin-top: 4px; + font-size: 12px; + line-height: 1.5; +} + +.empty { + color: var(--muted); + font-size: 12px; + padding: 8px 0; +} + +@media (max-width: 1200px) { + .shell { + grid-template-columns: 1fr; + } + + .sidebar { + border-right: 0; + border-bottom: 1px solid var(--line); + } +} + +@media (max-width: 900px) { + .layout.two-col, + .grid.two, + .grid.three, + .detail-layout, + .detail-grid { + grid-template-columns: 1fr; + } + + .topbar { + flex-direction: column; + align-items: stretch; + } +} diff --git a/src/sentinel_orbit_downloader/web/app.js b/src/sentinel_orbit_downloader/web/app.js new file mode 100644 index 0000000..354c28c --- /dev/null +++ b/src/sentinel_orbit_downloader/web/app.js @@ -0,0 +1,864 @@ +const DEFAULT_CATALOG_PATH = "\u5168\u56fd\u884c\u653f\u533a.geojson"; +const FINAL_STATUSES = new Set(["succeeded", "failed", "partial_failed", "canceled"]); + +const api = { + async request(path, options = {}) { + const init = { ...options }; + init.headers = { + ...(options.body ? { "Content-Type": "application/json" } : {}), + ...(options.headers || {}), + }; + const response = await fetch(path, init); + if (!response.ok) { + let detail = response.statusText; + try { + const payload = await response.json(); + detail = payload.detail || JSON.stringify(payload); + } catch (_) {} + throw new Error(detail); + } + if (response.status === 204) { + return null; + } + return response.json(); + }, + health() { + return this.request("/api/health"); + }, + earthdataSettings() { + return this.request("/api/settings/earthdata"); + }, + saveEarthdataSettings(payload) { + return this.request("/api/settings/earthdata", { + method: "PUT", + body: JSON.stringify(payload), + }); + }, + clearEarthdataSettings() { + return this.request("/api/settings/earthdata", { + method: "DELETE", + }); + }, + importRegions(payload) { + return this.request("/api/regions/import", { + method: "POST", + body: JSON.stringify(payload), + }); + }, + searchRegions(query) { + return this.request(`/api/regions/search?q=${encodeURIComponent(query)}`); + }, + regionDetail(adcode) { + return this.request(`/api/regions/${encodeURIComponent(adcode)}`); + }, + projects() { + return this.request("/api/projects"); + }, + createProject(payload) { + return this.request("/api/projects", { + method: "POST", + body: JSON.stringify(payload), + }); + }, + jobs(projectId) { + const suffix = projectId ? `?project_id=${projectId}&limit=50` : "?limit=50"; + return this.request(`/api/jobs${suffix}`); + }, + job(jobId) { + return this.request(`/api/jobs/${jobId}?include_events=true`); + }, + createSearchJob(payload) { + return this.request("/api/search-jobs", { + method: "POST", + body: JSON.stringify(payload), + }); + }, + createDownloadJob(payload) { + return this.request("/api/download-jobs", { + method: "POST", + body: JSON.stringify(payload), + }); + }, + deleteJob(jobId, force = false) { + return this.request(`/api/jobs/${jobId}?force=${force ? "true" : "false"}`, { + method: "DELETE", + }); + }, + clearJobs(projectId, force = false) { + const projectPart = projectId ? `project_id=${projectId}&` : ""; + return this.request(`/api/jobs?${projectPart}force=${force ? "true" : "false"}`, { + method: "DELETE", + }); + }, +}; + +const state = { + projects: [], + selectedProjectId: null, + regions: [], + selectedRegion: null, + selectedRegionDetail: null, + earthdataSettings: null, + jobs: [], + selectedJobId: null, + selectedJobDetail: null, + pollTimer: null, +}; + +const el = { + healthDot: document.getElementById("healthDot"), + healthLabel: document.getElementById("healthLabel"), + healthDetail: document.getElementById("healthDetail"), + catalogPath: document.getElementById("catalogPath"), + simplifyTolerance: document.getElementById("simplifyTolerance"), + projectName: document.getElementById("projectName"), + projectPath: document.getElementById("projectPath"), + projectDesc: document.getElementById("projectDesc"), + projectList: document.getElementById("projectList"), + messageBar: document.getElementById("messageBar"), + regionKeyword: document.getElementById("regionKeyword"), + regionList: document.getElementById("regionList"), + regionDetailCard: document.getElementById("regionDetailCard"), + startDate: document.getElementById("startDate"), + endDate: document.getElementById("endDate"), + maxResults: document.getElementById("maxResults"), + processingLevel: document.getElementById("processingLevel"), + beamMode: document.getElementById("beamMode"), + flightDirection: document.getElementById("flightDirection"), + polarization: document.getElementById("polarization"), + relativeOrbit: document.getElementById("relativeOrbit"), + manifestPath: document.getElementById("manifestPath"), + rawRegion: document.getElementById("rawRegion"), + selectionSummary: document.getElementById("selectionSummary"), + dataDir: document.getElementById("dataDir"), + orbitDir: document.getElementById("orbitDir"), + skipData: document.getElementById("skipData"), + skipOrbits: document.getElementById("skipOrbits"), + downloadProcesses: document.getElementById("downloadProcesses"), + ignoreOrbitErrors: document.getElementById("ignoreOrbitErrors"), + earthdataUser: document.getElementById("earthdataUser"), + earthdataPass: document.getElementById("earthdataPass"), + earthdataSummary: document.getElementById("earthdataSummary"), + jobFilterSummary: document.getElementById("jobFilterSummary"), + jobList: document.getElementById("jobList"), + deleteJobBtn: document.getElementById("deleteJobBtn"), + clearJobsBtn: document.getElementById("clearJobsBtn"), + jobMeta: document.getElementById("jobMeta"), + sceneTableBody: document.getElementById("sceneTableBody"), + eventList: document.getElementById("eventList"), +}; + +function setMessage(text, tone = "info") { + el.messageBar.textContent = text; + el.messageBar.className = `message ${tone}`; +} + +function setHealth(ok, label, detail) { + el.healthDot.className = `status-dot ${ok ? "ok" : "error"}`; + el.healthLabel.textContent = label; + el.healthDetail.textContent = detail; +} + +function makeChip(text) { + const span = document.createElement("span"); + span.className = "chip"; + span.textContent = text; + return span; +} + +function makeStatusPill(value) { + const span = document.createElement("span"); + span.className = `status-pill ${value || ""}`.trim(); + span.textContent = value || "-"; + return span; +} + +function renderEmpty(container, text) { + container.innerHTML = `
${escapeHtml(text)}
`; +} + +function formatMb(value) { + if (value == null || Number.isNaN(Number(value))) { + return ""; + } + return `${Number(value).toFixed(1)} MB`; +} + +function formatRelativeOrbit(value) { + return value == null ? "" : String(value); +} + +function formatBBox(bbox) { + if (!Array.isArray(bbox) || bbox.length !== 4) { + return "-"; + } + return bbox.map((value) => Number(value).toFixed(4)).join(", "); +} + +function formatPoint(point) { + if (!Array.isArray(point) || point.length < 2) { + return "-"; + } + return point.slice(0, 2).map((value) => Number(value).toFixed(4)).join(", "); +} + +function emptyToNull(value) { + const text = (value || "").trim(); + return text ? text : null; +} + +function numberOrNull(value) { + const text = (value || "").trim(); + if (!text) { + return null; + } + const number = Number(text); + return Number.isFinite(number) ? number : null; +} + +function escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text || ""; + return div.innerHTML; +} + +function statusCounts(scenes) { + const product = {}; + const orbit = {}; + for (const scene of scenes) { + product[scene.product_status] = (product[scene.product_status] || 0) + 1; + orbit[scene.orbit_status] = (orbit[scene.orbit_status] || 0) + 1; + } + return { product, orbit }; +} + +function currentProject() { + return state.projects.find((project) => project.id === state.selectedProjectId) || null; +} + +function autoFillProjectPaths(project) { + if (!project || !project.data_root) { + return; + } + el.projectPath.value = project.data_root; + el.dataDir.value = `${project.data_root}\\data`; + el.orbitDir.value = `${project.data_root}\\orbits`; + if (!el.manifestPath.value) { + el.manifestPath.value = `${project.data_root}\\manifest.csv`; + } +} + +function renderProjects() { + el.projectList.innerHTML = ""; + if (!state.projects.length) { + renderEmpty(el.projectList, "暂无项目"); + return; + } + + for (const project of state.projects) { + const button = document.createElement("button"); + button.type = "button"; + button.className = `item ${state.selectedProjectId === project.id ? "active" : ""}`; + button.innerHTML = ` +
${escapeHtml(project.name)}
+
${escapeHtml(project.data_root)}
+
${escapeHtml(project.slug)}
+ `; + button.addEventListener("click", async () => { + state.selectedProjectId = project.id; + autoFillProjectPaths(project); + renderProjects(); + updateSelectionSummary(); + await loadJobs(); + await loadSelectedJobDetail(); + }); + el.projectList.appendChild(button); + } +} + +function renderRegions() { + el.regionList.innerHTML = ""; + if (!state.regions.length) { + renderEmpty(el.regionList, "暂无行政区结果"); + return; + } + + for (const region of state.regions) { + const button = document.createElement("button"); + button.type = "button"; + button.className = `item ${state.selectedRegion && state.selectedRegion.adcode === region.adcode ? "active" : ""}`; + button.innerHTML = ` +
${escapeHtml(region.label)}
+
${escapeHtml(region.adcode)}
+
${escapeHtml(region.level)} | ${escapeHtml(formatBBox(region.bbox))}
+ `; + button.addEventListener("click", async () => { + state.selectedRegion = region; + renderRegions(); + updateSelectionSummary(); + await loadRegionDetail(region.adcode); + }); + el.regionList.appendChild(button); + } +} + +function renderRegionDetail() { + const region = state.selectedRegionDetail; + if (!region) { + el.regionDetailCard.innerHTML = '
未选择行政区
'; + return; + } + + el.regionDetailCard.innerHTML = ` +
+
+ ${escapeHtml(region.level)} + ${escapeHtml(region.adcode)} +
+
+
+
行政区
+
${escapeHtml(region.label)}
+
+
+
简化容差
+
${escapeHtml(String(region.simplify_tolerance))}
+
+
+
范围框
+
${escapeHtml(formatBBox(region.bbox))}
+
+
+
中心点
+
${escapeHtml(formatPoint(region.center))}
+
+
+
+ `; +} + +function renderEarthdataSummary() { + el.earthdataSummary.innerHTML = ""; + const settings = state.earthdataSettings; + if (!settings || (!settings.username && !settings.has_password)) { + el.earthdataSummary.appendChild(makeChip("Earthdata 本地凭据:未保存")); + return; + } + if (settings.username) { + el.earthdataSummary.appendChild(makeChip(`Earthdata 用户:${settings.username}`)); + } + el.earthdataSummary.appendChild( + makeChip(settings.has_password ? "Earthdata 密码:已保存" : "Earthdata 密码:未保存"), + ); +} + +function renderJobFilterSummary() { + el.jobFilterSummary.innerHTML = ""; + const project = currentProject(); + if (project) { + el.jobFilterSummary.appendChild(makeChip(`项目 ${project.name}`)); + } else { + el.jobFilterSummary.appendChild(makeChip("全部项目")); + } + el.jobFilterSummary.appendChild(makeChip(`任务数 ${state.jobs.length}`)); +} + +function renderJobs() { + el.jobList.innerHTML = ""; + if (!state.jobs.length) { + renderEmpty(el.jobList, "暂无任务"); + renderJobFilterSummary(); + return; + } + + for (const job of state.jobs) { + const button = document.createElement("button"); + button.type = "button"; + button.className = `item ${state.selectedJobId === job.id ? "active" : ""}`; + button.appendChild(makeStatusPill(job.status)); + + const title = document.createElement("div"); + title.className = "item-title"; + title.textContent = `#${job.id} ${job.region_name || job.source_value || job.source_type}`; + button.appendChild(title); + + const subtles = [ + `${job.start_time} - ${job.end_time}`, + `${job.processing_level}${job.beam_mode ? ` | ${job.beam_mode}` : ""}`, + `结果 ${job.result_count ?? "-"}`, + ]; + for (const text of subtles) { + const div = document.createElement("div"); + div.className = "item-subtle"; + div.textContent = text; + button.appendChild(div); + } + + button.addEventListener("click", async () => { + state.selectedJobId = job.id; + renderJobs(); + updateSelectionSummary(); + await loadSelectedJobDetail(); + }); + el.jobList.appendChild(button); + } + + renderJobFilterSummary(); +} + +function renderJobDetail() { + const payload = state.selectedJobDetail; + el.jobMeta.innerHTML = ""; + el.sceneTableBody.innerHTML = ""; + el.eventList.innerHTML = ""; + + if (!payload) { + renderEmpty(el.jobMeta, "未选择任务"); + el.sceneTableBody.innerHTML = '暂无场景'; + renderEmpty(el.eventList, "暂无事件"); + return; + } + + const { job, scenes, events } = payload; + const counts = statusCounts(scenes); + const chips = [ + `Job #${job.id}`, + `状态 ${job.status}`, + `时间 ${job.start_time} - ${job.end_time}`, + `产品 ${job.processing_level}`, + `结果 ${job.result_count ?? scenes.length}`, + ]; + + if (job.region_name) { + chips.push(`区域 ${job.region_name}`); + } + if (job.data_dir) { + chips.push(`数据目录 ${job.data_dir}`); + } + if (job.orbit_dir) { + chips.push(`轨道目录 ${job.orbit_dir}`); + } + if (job.error_message) { + chips.push(`错误 ${job.error_message}`); + } + + for (const text of chips) { + el.jobMeta.appendChild(makeChip(text)); + } + for (const [status, count] of Object.entries(counts.product)) { + el.jobMeta.appendChild(makeChip(`产品 ${status} ${count}`)); + } + for (const [status, count] of Object.entries(counts.orbit)) { + el.jobMeta.appendChild(makeChip(`轨道 ${status} ${count}`)); + } + + if (!scenes.length) { + el.sceneTableBody.innerHTML = '暂无场景'; + } else { + for (const scene of scenes) { + const row = document.createElement("tr"); + + const sceneCell = document.createElement("td"); + sceneCell.className = "mono"; + sceneCell.textContent = scene.scene_id; + row.appendChild(sceneCell); + + const startCell = document.createElement("td"); + startCell.textContent = scene.start_time || ""; + row.appendChild(startCell); + + const levelCell = document.createElement("td"); + levelCell.textContent = scene.processing_level || ""; + row.appendChild(levelCell); + + const orbitCell = document.createElement("td"); + orbitCell.textContent = formatRelativeOrbit(scene.relative_orbit); + row.appendChild(orbitCell); + + const productCell = document.createElement("td"); + productCell.appendChild(makeStatusPill(scene.product_status)); + row.appendChild(productCell); + + const eofCell = document.createElement("td"); + eofCell.appendChild(makeStatusPill(scene.orbit_status)); + row.appendChild(eofCell); + + const sizeCell = document.createElement("td"); + sizeCell.textContent = formatMb(scene.size_mb); + row.appendChild(sizeCell); + + el.sceneTableBody.appendChild(row); + } + } + + if (!events.length) { + renderEmpty(el.eventList, "暂无事件"); + } else { + for (const event of events) { + const div = document.createElement("div"); + div.className = "event"; + div.innerHTML = ` +
${escapeHtml(event.level)}
+
${escapeHtml(event.created_at)}
+
${escapeHtml(event.message)}
+ `; + el.eventList.appendChild(div); + } + } + + syncPolling(); +} + +function updateSelectionSummary() { + el.selectionSummary.innerHTML = ""; + const project = currentProject(); + if (project) { + el.selectionSummary.appendChild(makeChip(`项目 ${project.name}`)); + } + 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}`)); + if (el.beamMode.value) { + el.selectionSummary.appendChild(makeChip(`波束 ${el.beamMode.value}`)); + } + if (state.selectedJobId) { + el.selectionSummary.appendChild(makeChip(`当前任务 #${state.selectedJobId}`)); + } +} + +async function loadHealth() { + try { + const health = await api.health(); + setHealth(true, "服务已连接", health.db_path); + } catch (error) { + setHealth(false, "服务不可用", error.message || String(error)); + throw error; + } +} + +async function loadEarthdataSettings() { + state.earthdataSettings = await api.earthdataSettings(); + if (state.earthdataSettings && state.earthdataSettings.username && !el.earthdataUser.value) { + el.earthdataUser.value = state.earthdataSettings.username; + } + renderEarthdataSummary(); +} + +async function loadProjects() { + state.projects = await api.projects(); + if (!state.selectedProjectId && state.projects.length) { + state.selectedProjectId = state.projects[0].id; + autoFillProjectPaths(state.projects[0]); + } else { + const project = currentProject(); + if (project) { + autoFillProjectPaths(project); + } + } + renderProjects(); + updateSelectionSummary(); +} + +async function loadRegions() { + const keyword = el.regionKeyword.value.trim(); + if (!keyword) { + state.regions = []; + state.selectedRegion = null; + state.selectedRegionDetail = null; + renderRegions(); + renderRegionDetail(); + updateSelectionSummary(); + return; + } + + state.regions = await api.searchRegions(keyword); + state.selectedRegion = state.regions[0] || null; + renderRegions(); + if (state.selectedRegion) { + await loadRegionDetail(state.selectedRegion.adcode); + } else { + state.selectedRegionDetail = null; + renderRegionDetail(); + } + updateSelectionSummary(); +} + +async function loadRegionDetail(adcode) { + state.selectedRegionDetail = await api.regionDetail(adcode); + renderRegionDetail(); +} + +async function loadJobs() { + state.jobs = await api.jobs(state.selectedProjectId); + if (state.selectedJobId && !state.jobs.some((job) => job.id === state.selectedJobId)) { + state.selectedJobId = null; + state.selectedJobDetail = null; + } + renderJobs(); + updateSelectionSummary(); +} + +async function loadSelectedJobDetail() { + if (!state.selectedJobId) { + state.selectedJobDetail = null; + renderJobDetail(); + return; + } + state.selectedJobDetail = await api.job(state.selectedJobId); + const updatedJob = state.selectedJobDetail.job; + state.jobs = state.jobs.map((job) => (job.id === updatedJob.id ? updatedJob : job)); + renderJobs(); + renderJobDetail(); + updateSelectionSummary(); +} + +async function importCatalog() { + const payload = { + source_path: el.catalogPath.value.trim() || DEFAULT_CATALOG_PATH, + simplify_tolerance: Number(el.simplifyTolerance.value || 0.01), + }; + const result = await api.importRegions(payload); + setMessage(`行政区已导入:${result.imported}/${result.total_features}`, "success"); + await loadRegions(); +} + +async function saveEarthdataSettings() { + const username = el.earthdataUser.value.trim(); + const password = el.earthdataPass.value; + if (!username || !password) { + throw new Error("保存 Earthdata 凭据时必须填写用户名和密码"); + } + const response = await api.saveEarthdataSettings({ username, password }); + state.earthdataSettings = response; + el.earthdataPass.value = ""; + renderEarthdataSummary(); + setMessage("Earthdata 凭据已保存到本地配置", "success"); +} + +async function clearEarthdataSettings() { + if (!window.confirm("确定清除本地保存的 Earthdata 用户名和密码吗?")) { + return; + } + await api.clearEarthdataSettings(); + state.earthdataSettings = { username: null, has_password: false }; + el.earthdataUser.value = ""; + el.earthdataPass.value = ""; + renderEarthdataSummary(); + setMessage("Earthdata 本地凭据已清除", "success"); +} + +async function createProject() { + const payload = { + name: el.projectName.value.trim(), + data_root: el.projectPath.value.trim(), + description: emptyToNull(el.projectDesc.value), + }; + if (!payload.name || !payload.data_root) { + throw new Error("项目名称和数据根目录不能为空"); + } + const project = await api.createProject(payload); + state.selectedProjectId = project.id; + setMessage(`项目已创建:${project.name}`, "success"); + await loadProjects(); + await loadJobs(); +} + +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, + beam_mode: emptyToNull(el.beamMode.value), + flight_direction: emptyToNull(el.flightDirection.value), + polarization: emptyToNull(el.polarization.value), + relative_orbit: numberOrNull(el.relativeOrbit.value), + max_results: Number(el.maxResults.value || 20), + manifest_path: emptyToNull(el.manifestPath.value), + raw_region: el.rawRegion.checked, + }; + + const response = await api.createSearchJob(payload); + state.selectedJobId = response.job.id; + state.selectedJobDetail = { + job: response.job, + scenes: response.scenes, + events: [], + }; + setMessage(`检索完成:job #${response.job.id},场景 ${response.scenes.length}`, "success"); + await loadJobs(); + await loadSelectedJobDetail(); +} + +async function queueDownloadJob() { + if (!state.selectedJobId) { + throw new Error("请先选择任务"); + } + + const payload = { + job_id: state.selectedJobId, + data_dir: emptyToNull(el.dataDir.value), + orbit_dir: emptyToNull(el.orbitDir.value), + skip_data: el.skipData.value === "true", + skip_orbits: el.skipOrbits.value === "true", + processes: Number(el.downloadProcesses.value || 2), + ignore_orbit_errors: el.ignoreOrbitErrors.checked, + username: emptyToNull(el.earthdataUser.value), + password: emptyToNull(el.earthdataPass.value), + }; + + const response = await api.createDownloadJob(payload); + setMessage(`下载任务已排队:job #${response.job.id}`, "success"); + await loadSelectedJobDetail(); +} + +async function deleteSelectedJob() { + if (!state.selectedJobId) { + throw new Error("请先选择任务"); + } + + const selectedJob = + (state.selectedJobDetail && state.selectedJobDetail.job) || + state.jobs.find((job) => job.id === state.selectedJobId) || + null; + const isRunning = Boolean(selectedJob && selectedJob.status === "running"); + const message = isRunning + ? "当前任务仍显示为运行中。强制删除只会清理记录,不能保证后台下载立刻停止。确定继续删除吗?" + : `确定删除任务 #${state.selectedJobId} 吗?`; + + if (!window.confirm(message)) { + return; + } + + const deletedJobId = state.selectedJobId; + const response = await api.deleteJob(deletedJobId, isRunning); + state.selectedJobId = null; + state.selectedJobDetail = null; + state.jobs = state.jobs.filter((job) => job.id !== deletedJobId); + renderJobs(); + renderJobDetail(); + updateSelectionSummary(); + setMessage(`已删除 ${response.deleted_count} 个任务`, "success"); +} + +async function clearCurrentJobs() { + if (!state.selectedProjectId) { + throw new Error("请先选择项目"); + } + + const project = currentProject(); + const projectName = project ? project.name : `#${state.selectedProjectId}`; + if (!window.confirm(`确定清空项目“${projectName}”下的非运行任务吗?`)) { + return; + } + + const response = await api.clearJobs(state.selectedProjectId, false); + const deletedIds = new Set(response.deleted_job_ids || []); + if (state.selectedJobId && deletedIds.has(state.selectedJobId)) { + state.selectedJobId = null; + state.selectedJobDetail = null; + } + state.jobs = state.jobs.filter((job) => !deletedIds.has(job.id)); + renderJobs(); + renderJobDetail(); + updateSelectionSummary(); + setMessage(`已清空 ${response.deleted_count} 个任务`, "success"); +} + +function stopPolling() { + if (state.pollTimer) { + window.clearInterval(state.pollTimer); + state.pollTimer = null; + } +} + +function syncPolling() { + const activeJob = state.selectedJobDetail && state.selectedJobDetail.job; + if (!activeJob || FINAL_STATUSES.has(activeJob.status)) { + stopPolling(); + return; + } + if (state.pollTimer) { + return; + } + state.pollTimer = window.setInterval(async () => { + if (!state.selectedJobId) { + stopPolling(); + return; + } + try { + await loadSelectedJobDetail(); + } catch (_) {} + }, 5000); +} + +function runAction(fn, pendingText) { + return async () => { + try { + if (pendingText) { + setMessage(pendingText, "info"); + } + await fn(); + } catch (error) { + setMessage(error.message || String(error), "error"); + } + }; +} + +function bindEvents() { + document.getElementById("saveEarthdataBtn").addEventListener("click", runAction(saveEarthdataSettings, "正在保存 Earthdata 凭据")); + document.getElementById("clearEarthdataBtn").addEventListener("click", runAction(clearEarthdataSettings, "正在清除 Earthdata 凭据")); + document.getElementById("importCatalogBtn").addEventListener("click", runAction(importCatalog, "正在导入行政区")); + document.getElementById("createProjectBtn").addEventListener("click", runAction(createProject, "正在创建项目")); + document.getElementById("refreshProjectsBtn").addEventListener("click", runAction(loadProjects, "正在刷新项目")); + document.getElementById("searchRegionsBtn").addEventListener("click", runAction(loadRegions, "正在查询行政区")); + document.getElementById("clearRegionsBtn").addEventListener("click", () => { + state.regions = []; + state.selectedRegion = null; + state.selectedRegionDetail = null; + el.regionKeyword.value = ""; + renderRegions(); + renderRegionDetail(); + updateSelectionSummary(); + setMessage("行政区查询已清空", "info"); + }); + document.getElementById("runSearchBtn").addEventListener("click", runAction(createSearchJob, "正在检索 Sentinel-1")); + document.getElementById("queueDownloadBtn").addEventListener("click", runAction(queueDownloadJob, "正在排队下载任务")); + document.getElementById("deleteJobBtn").addEventListener("click", runAction(deleteSelectedJob, "正在删除任务")); + document.getElementById("clearJobsBtn").addEventListener("click", runAction(clearCurrentJobs, "正在清空任务")); + document.getElementById("refreshJobsBtn").addEventListener("click", runAction(async () => { + await loadJobs(); + await loadSelectedJobDetail(); + }, "正在刷新任务")); + document.getElementById("refreshDetailBtn").addEventListener("click", runAction(loadSelectedJobDetail, "正在刷新任务详情")); +} + +async function bootstrap() { + bindEvents(); + if (!el.catalogPath.value) { + el.catalogPath.value = DEFAULT_CATALOG_PATH; + } + el.healthDetail.textContent = window.location.origin; + setMessage("正在初始化工作台", "info"); + await loadHealth(); + await loadEarthdataSettings(); + await loadProjects(); + await loadRegions(); + await loadJobs(); + await loadSelectedJobDetail(); + setMessage("工作台已就绪", "success"); +} + +bootstrap().catch((error) => { + setMessage(error.message || String(error), "error"); +}); diff --git a/src/sentinel_orbit_downloader/web/index.html b/src/sentinel_orbit_downloader/web/index.html new file mode 100644 index 0000000..4e17097 --- /dev/null +++ b/src/sentinel_orbit_downloader/web/index.html @@ -0,0 +1,271 @@ + + + + + + Sentinel Orbit Downloader + + + +
+ + +
+
+
+

任务工作台

+

按行政区和时间检索 Sentinel-1,并统一管理数据与精密轨道下载。

+
+
等待操作
+
+ +
+
+
+

行政区选择

+
+ + +
+
+
+ + +
+
+
+
未选择行政区
+
+
+ +
+
+

检索参数

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+ +
+
+
+

下载参数

+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+ + +
+
+
+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ 下载原始 Sentinel-1 产品必须提供 Earthdata 用户名和密码。你可以手填后直接下载,也可以先保存到本地配置。当前阶段只下载原始产品包和匹配的 EOF 轨道文件,暂不包含 GeoTIFF 处理。 +
+
+ +
+
+

任务

+
+ + + + +
+
+
+
+
+
+ +
+
+

任务详情

+
+
+
+
+ + + + + + + + + + + + + +
Scene开始时间级别相对轨道产品轨道大小
+
+
+
+
+
+
+ + + + diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..790e06e --- /dev/null +++ b/uv.lock @@ -0,0 +1,612 @@ +version = 1 +revision = 3 +requires-python = ">=3.11, <3.14" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "asf-search" +version = "12.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dateparser" }, + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "requests" }, + { name = "shapely" }, + { name = "tenacity" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/07/874419685c2e79950a8bb2adff2fe079af8a4d77a9a579a291ec6662fd53/asf_search-12.0.7.tar.gz", hash = "sha256:e9ad6e87da7ed000547228308c05653a741eeb7020a3c080d67ae3a91c161e72", size = 1408830, upload-time = "2026-04-13T19:22:34.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/f4/c52dad8cbf90b16e7c891df44a14584559e7ee0ddf906bb357cc415b3a26/asf_search-12.0.7-py3-none-any.whl", hash = "sha256:9e0d644f49e36cb5414982ca5363cba49cb9e6e02ea4efbe040f2f4054ae31f4", size = 121238, upload-time = "2026-04-13T19:22:32.769Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "dateparser" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "regex" }, + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/2d/a0ccdb78788064fa0dc901b8524e50615c42be1d78b78d646d0b28d09180/dateparser-1.4.0.tar.gz", hash = "sha256:97a21840d5ecdf7630c584f673338a5afac5dfe84f647baf4d7e8df98f9354a4", size = 321512, upload-time = "2026-03-26T09:56:10.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/0b/3c3bb7cbe757279e693a0be6049048012f794d01f81099609ecd53b899f0/dateparser-1.4.0-py3-none-any.whl", hash = "sha256:7902b8e85d603494bf70a5a0b1decdddb2270b9c6e6b2bc8a57b93476c0df378", size = 300379, upload-time = "2026-03-26T09:56:08.409Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "idna" +version = "3.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799, upload-time = "2026-03-29T13:18:15.438Z" }, + { url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552, upload-time = "2026-03-29T13:18:18.606Z" }, + { url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566, upload-time = "2026-03-29T13:18:21.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482, upload-time = "2026-03-29T13:18:23.634Z" }, + { url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376, upload-time = "2026-03-29T13:18:26.677Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137, upload-time = "2026-03-29T13:18:30.14Z" }, + { url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414, upload-time = "2026-03-29T13:18:33.733Z" }, + { url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397, upload-time = "2026-03-29T13:18:37.511Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499, upload-time = "2026-03-29T13:18:40.372Z" }, + { url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257, upload-time = "2026-03-29T13:18:42.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775, upload-time = "2026-03-29T13:18:45.835Z" }, + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491, upload-time = "2026-03-29T13:21:38.03Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830, upload-time = "2026-03-29T13:21:41.509Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927, upload-time = "2026-03-29T13:21:44.747Z" }, + { url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557, upload-time = "2026-03-29T13:21:47.406Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253, upload-time = "2026-03-29T13:21:50.753Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552, upload-time = "2026-03-29T13:21:54.344Z" }, + { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a2/1ba90a83e85a3f94c796b184f3efde9c72f2830dcda493eea8d59ba78e6d/pydantic_core-2.46.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ab124d49d0459b2373ecf54118a45c28a1e6d4192a533fbc915e70f556feb8e5", size = 2106740, upload-time = "2026-04-20T14:41:20.932Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f6/99ae893c89a0b9d3daec9f95487aa676709aa83f67643b3f0abaf4ab628a/pydantic_core-2.46.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cca67d52a5c7a16aed2b3999e719c4bcf644074eac304a5d3d62dd70ae7d4b2c", size = 1948293, upload-time = "2026-04-20T14:43:42.115Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b8/2e8e636dc9e3f16c2e16bf0849e24be82c5ee82c603c65fc0326666328fc/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c024e08c0ba23e6fd68c771a521e9d6a792f2ebb0fa734296b36394dc30390e", size = 1973222, upload-time = "2026-04-20T14:41:57.841Z" }, + { url = "https://files.pythonhosted.org/packages/34/36/0e730beec4d83c5306f417afbd82ff237d9a21e83c5edf675f31ed84c1fe/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6645ce7eec4928e29a1e3b3d5c946621d105d3e79f0c9cddf07c2a9770949287", size = 2053852, upload-time = "2026-04-20T14:40:43.077Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f0/3071131f47e39136a17814576e0fada9168569f7f8c0e6ac4d1ede6a4958/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a712c7118e6c5ea96562f7b488435172abb94a3c53c22c9efc1412264a45cbbe", size = 2221134, upload-time = "2026-04-20T14:43:03.349Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a9/a2dc023eec5aa4b02a467874bad32e2446957d2adcab14e107eab502e978/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a868ef3ff206343579021c40faf3b1edc64b1cc508ff243a28b0a514ccb050", size = 2279785, upload-time = "2026-04-20T14:41:19.285Z" }, + { url = "https://files.pythonhosted.org/packages/0a/44/93f489d16fb63fbd41c670441536541f6e8cfa1e5a69f40bc9c5d30d8c90/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc7e8c32db809aa0f6ea1d6869ebc8518a65d5150fdfad8bcae6a49ae32a22e2", size = 2089404, upload-time = "2026-04-20T14:43:10.108Z" }, + { url = "https://files.pythonhosted.org/packages/2a/78/8692e3aa72b2d004f7a5d937f1dfdc8552ba26caf0bec75f342c40f00dec/pydantic_core-2.46.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3481bd1341dc85779ee506bc8e1196a277ace359d89d28588a9468c3ecbe63fa", size = 2114898, upload-time = "2026-04-20T14:44:51.475Z" }, + { url = "https://files.pythonhosted.org/packages/6a/62/e83133f2e7832532060175cebf1f13748f4c7e7e7165cdd1f611f174494b/pydantic_core-2.46.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8690eba565c6d68ffd3a8655525cbdd5246510b44a637ee2c6c03a7ebfe64d3c", size = 2157856, upload-time = "2026-04-20T14:43:46.64Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ec/6a500e3ad7718ee50583fae79c8651f5d37e3abce1fa9ae177ae65842c53/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4de88889d7e88d50d40ee5b39d5dac0bcaef9ba91f7e536ac064e6b2834ecccf", size = 2180168, upload-time = "2026-04-20T14:42:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/d8/53/8267811054b1aa7fc1dc7ded93812372ef79a839f5e23558136a6afbfde1/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:e480080975c1ef7f780b8f99ed72337e7cc5efea2e518a20a692e8e7b278eb8b", size = 2322885, upload-time = "2026-04-20T14:41:05.253Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c1/1c0acdb3aa0856ddc4ecc55214578f896f2de16f400cf51627eb3c26c1c4/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de3a5c376f8cd94da9a1b8fd3dd1c16c7a7b216ed31dc8ce9fd7a22bf13b836e", size = 2360328, upload-time = "2026-04-20T14:41:43.991Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/ef39cd0f4a926814f360e71c1adeab48ad214d9727e4deb48eedfb5bce1a/pydantic_core-2.46.3-cp311-cp311-win32.whl", hash = "sha256:fc331a5314ffddd5385b9ee9d0d2fee0b13c27e0e02dad71b1ae5d6561f51eeb", size = 1979464, upload-time = "2026-04-20T14:43:12.215Z" }, + { url = "https://files.pythonhosted.org/packages/18/9c/f41951b0d858e343f1cf09398b2a7b3014013799744f2c4a8ad6a3eec4f2/pydantic_core-2.46.3-cp311-cp311-win_amd64.whl", hash = "sha256:b5b9c6cf08a8a5e502698f5e153056d12c34b8fb30317e0c5fd06f45162a6346", size = 2070837, upload-time = "2026-04-20T14:41:47.707Z" }, + { url = "https://files.pythonhosted.org/packages/9f/1e/264a17cd582f6ed50950d4d03dd5fefd84e570e238afe1cb3e25cf238769/pydantic_core-2.46.3-cp311-cp311-win_arm64.whl", hash = "sha256:5dfd51cf457482f04ec49491811a2b8fd5b843b64b11eecd2d7a1ee596ea78a6", size = 2053647, upload-time = "2026-04-20T14:42:27.535Z" }, + { url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" }, + { url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" }, + { url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" }, + { url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, + { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, + { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, + { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, + { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, + { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, + { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, + { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/66/7f/03dbad45cd3aa9083fbc93c210ae8b005af67e4136a14186950a747c6874/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:9715525891ed524a0a1eb6d053c74d4d4ad5017677fb00af0b7c2644a31bae46", size = 2105683, upload-time = "2026-04-20T14:42:19.779Z" }, + { url = "https://files.pythonhosted.org/packages/26/22/4dc186ac8ea6b257e9855031f51b62a9637beac4d68ac06bee02f046f836/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:9d2f400712a99a013aff420ef1eb9be077f8189a36c1e3ef87660b4e1088a874", size = 1940052, upload-time = "2026-04-20T14:43:59.274Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/d376391a5aff1f2e8188960d7873543608130a870961c2b6b5236627c116/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd2aab0e2e9dc2daf36bd2686c982535d5e7b1d930a1344a7bb6e82baab42a76", size = 1988172, upload-time = "2026-04-20T14:41:17.469Z" }, + { url = "https://files.pythonhosted.org/packages/0e/6b/523b9f85c23788755d6ab949329de692a2e3a584bc6beb67fef5e035aa9d/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e9d76736da5f362fabfeea6a69b13b7f2be405c6d6966f06b2f6bfff7e64531", size = 2128596, upload-time = "2026-04-20T14:40:41.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, + { url = "https://files.pythonhosted.org/packages/1f/da/99d40830684f81dec901cac521b5b91c095394cc1084b9433393cde1c2df/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:13afdd885f3d71280cf286b13b310ee0f7ccfefd1dbbb661514a474b726e2f25", size = 2107973, upload-time = "2026-04-20T14:42:06.175Z" }, + { url = "https://files.pythonhosted.org/packages/99/a5/87024121818d75bbb2a98ddbaf638e40e7a18b5e0f5492c9ca4b1b316107/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f91c0aff3e3ee0928edd1232c57f643a7a003e6edf1860bc3afcdc749cb513f3", size = 1947191, upload-time = "2026-04-20T14:43:14.319Z" }, + { url = "https://files.pythonhosted.org/packages/60/62/0c1acfe10945b83a6a59d19fbaa92f48825381509e5701b855c08f13db76/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6529d1d128321a58d30afcc97b49e98836542f68dd41b33c2e972bb9e5290536", size = 2123791, upload-time = "2026-04-20T14:43:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/3b2393b4c8f44285561dc30b00cf307a56a2eff7c483a824db3b8221ca51/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:975c267cff4f7e7272eacbe50f6cc03ca9a3da4c4fbd66fffd89c94c1e311aa1", size = 2153197, upload-time = "2026-04-20T14:44:27.932Z" }, + { url = "https://files.pythonhosted.org/packages/ba/75/5af02fb35505051eee727c061f2881c555ab4f8ddb2d42da715a42c9731b/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2b8e4f2bbdf71415c544b4b1138b8060db7b6611bc927e8064c769f64bed651c", size = 2181073, upload-time = "2026-04-20T14:43:20.729Z" }, + { url = "https://files.pythonhosted.org/packages/10/92/7e0e1bd9ca3c68305db037560ca2876f89b2647deb2f8b6319005de37505/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e61ea8e9fff9606d09178f577ff8ccdd7206ff73d6552bcec18e1033c4254b85", size = 2315886, upload-time = "2026-04-20T14:44:04.826Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d8/101655f27eaf3e44558ead736b2795d12500598beed4683f279396fa186e/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b504bda01bafc69b6d3c7a0c7f039dcf60f47fab70e06fe23f57b5c75bdc82b8", size = 2360528, upload-time = "2026-04-20T14:40:47.431Z" }, + { 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 = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +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 = "pytz" +version = "2026.1.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, +] + +[[package]] +name = "regex" +version = "2026.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/3a246dbf05666918bd3664d9d787f84a9108f6f43cc953a077e4a7dfdb7e/regex-2026.4.4.tar.gz", hash = "sha256:e08270659717f6973523ce3afbafa53515c4dc5dcad637dc215b6fd50f689423", size = 416000, upload-time = "2026-04-03T20:56:28.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/7a/617356cbecdb452812a5d42f720d6d5096b360d4a4c1073af700ea140ad2/regex-2026.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b4c36a85b00fadb85db9d9e90144af0a980e1a3d2ef9cd0f8a5bef88054657c6", size = 489415, upload-time = "2026-04-03T20:53:11.645Z" }, + { url = "https://files.pythonhosted.org/packages/20/e6/bf057227144d02e3ba758b66649e87531d744dda5f3254f48660f18ae9d8/regex-2026.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dcb5453ecf9cd58b562967badd1edbf092b0588a3af9e32ee3d05c985077ce87", size = 291205, upload-time = "2026-04-03T20:53:13.289Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3b/637181b787dd1a820ba1c712cee2b4144cd84a32dc776ca067b12b2d70c8/regex-2026.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6aa809ed4dc3706cc38594d67e641601bd2f36d5555b2780ff074edfcb136cf8", size = 289225, upload-time = "2026-04-03T20:53:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/05/21/bac05d806ed02cd4b39d9c8e5b5f9a2998c94c3a351b7792e80671fa5315/regex-2026.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33424f5188a7db12958246a54f59a435b6cb62c5cf9c8d71f7cc49475a5fdada", size = 792434, upload-time = "2026-04-03T20:53:17.414Z" }, + { url = "https://files.pythonhosted.org/packages/d9/17/c65d1d8ae90b772d5758eb4014e1e011bb2db353fc4455432e6cc9100df7/regex-2026.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d346fccdde28abba117cc9edc696b9518c3307fbfcb689e549d9b5979018c6d", size = 861730, upload-time = "2026-04-03T20:53:18.903Z" }, + { url = "https://files.pythonhosted.org/packages/ad/64/933321aa082a2c6ee2785f22776143ba89840189c20d3b6b1d12b6aae16b/regex-2026.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:415a994b536440f5011aa77e50a4274d15da3245e876e5c7f19da349caaedd87", size = 906495, upload-time = "2026-04-03T20:53:20.561Z" }, + { url = "https://files.pythonhosted.org/packages/01/ea/4c8d306e9c36ac22417336b1e02e7b358152c34dc379673f2d331143725f/regex-2026.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21e5eb86179b4c67b5759d452ea7c48eb135cd93308e7a260aa489ed2eb423a4", size = 799810, upload-time = "2026-04-03T20:53:22.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/ce/7605048f00e1379eba89d610c7d644d8f695dc9b26d3b6ecfa3132b872ff/regex-2026.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:312ec9dd1ae7d96abd8c5a36a552b2139931914407d26fba723f9e53c8186f86", size = 774242, upload-time = "2026-04-03T20:53:25.015Z" }, + { url = "https://files.pythonhosted.org/packages/e9/77/283e0d5023fde22cd9e86190d6d9beb21590a452b195ffe00274de470691/regex-2026.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0d2b28aa1354c7cd7f71b7658c4326f7facac106edd7f40eda984424229fd59", size = 781257, upload-time = "2026-04-03T20:53:26.918Z" }, + { url = "https://files.pythonhosted.org/packages/8b/fb/7f3b772be101373c8626ed34c5d727dcbb8abd42a7b1219bc25fd9a3cc04/regex-2026.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:349d7310eddff40429a099c08d995c6d4a4bfaf3ff40bd3b5e5cb5a5a3c7d453", size = 854490, upload-time = "2026-04-03T20:53:29.065Z" }, + { url = "https://files.pythonhosted.org/packages/85/30/56547b80f34f4dd2986e1cdd63b1712932f63b6c4ce2f79c50a6cd79d1c2/regex-2026.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e7ab63e9fe45a9ec3417509e18116b367e89c9ceb6219222a3396fa30b147f80", size = 763544, upload-time = "2026-04-03T20:53:30.917Z" }, + { url = "https://files.pythonhosted.org/packages/ac/2f/ce060fdfea8eff34a8997603532e44cdb7d1f35e3bc253612a8707a90538/regex-2026.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fe896e07a5a2462308297e515c0054e9ec2dd18dfdc9427b19900b37dfe6f40b", size = 844442, upload-time = "2026-04-03T20:53:32.463Z" }, + { url = "https://files.pythonhosted.org/packages/e5/44/810cb113096a1dacbe82789fbfab2823f79d19b7f1271acecb7009ba9b88/regex-2026.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:eb59c65069498dbae3c0ef07bbe224e1eaa079825a437fb47a479f0af11f774f", size = 789162, upload-time = "2026-04-03T20:53:34.039Z" }, + { url = "https://files.pythonhosted.org/packages/20/96/9647dd7f2ecf6d9ce1fb04dfdb66910d094e10d8fe53e9c15096d8aa0bd2/regex-2026.4.4-cp311-cp311-win32.whl", hash = "sha256:2a5d273181b560ef8397c8825f2b9d57013de744da9e8257b8467e5da8599351", size = 266227, upload-time = "2026-04-03T20:53:35.601Z" }, + { url = "https://files.pythonhosted.org/packages/33/80/74e13262460530c3097ff343a17de9a34d040a5dc4de9cf3a8241faab51c/regex-2026.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:9542ccc1e689e752594309444081582f7be2fdb2df75acafea8a075108566735", size = 278399, upload-time = "2026-04-03T20:53:37.021Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/39f19f47f19dcefa3403f09d13562ca1c0fd07ab54db2bc03148f3f6b46a/regex-2026.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:b5f9fb784824a042be3455b53d0b112655686fdb7a91f88f095f3fee1e2a2a54", size = 270473, upload-time = "2026-04-03T20:53:38.633Z" }, + { url = "https://files.pythonhosted.org/packages/e5/28/b972a4d3df61e1d7bcf1b59fdb3cddef22f88b6be43f161bb41ebc0e4081/regex-2026.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c07ab8794fa929e58d97a0e1796b8b76f70943fa39df225ac9964615cf1f9d52", size = 490434, upload-time = "2026-04-03T20:53:40.219Z" }, + { url = "https://files.pythonhosted.org/packages/84/20/30041446cf6dc3e0eab344fc62770e84c23b6b68a3b657821f9f80cb69b4/regex-2026.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c785939dc023a1ce4ec09599c032cc9933d258a998d16ca6f2b596c010940eb", size = 292061, upload-time = "2026-04-03T20:53:41.862Z" }, + { url = "https://files.pythonhosted.org/packages/62/c8/3baa06d75c98c46d4cc4262b71fd2edb9062b5665e868bca57859dadf93a/regex-2026.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b1ce5c81c9114f1ce2f9288a51a8fd3aeea33a0cc440c415bf02da323aa0a76", size = 289628, upload-time = "2026-04-03T20:53:43.701Z" }, + { url = "https://files.pythonhosted.org/packages/31/87/3accf55634caad8c0acab23f5135ef7d4a21c39f28c55c816ae012931408/regex-2026.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:760ef21c17d8e6a4fe8cf406a97cf2806a4df93416ccc82fc98d25b1c20425be", size = 796651, upload-time = "2026-04-03T20:53:45.379Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0c/aaa2c83f34efedbf06f61cb1942c25f6cf1ee3b200f832c4d05f28306c2e/regex-2026.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7088fcdcb604a4417c208e2169715800d28838fefd7455fbe40416231d1d47c1", size = 865916, upload-time = "2026-04-03T20:53:47.064Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f6/8c6924c865124643e8f37823eca845dc27ac509b2ee58123685e71cd0279/regex-2026.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07edca1ba687998968f7db5bc355288d0c6505caa7374f013d27356d93976d13", size = 912287, upload-time = "2026-04-03T20:53:49.422Z" }, + { url = "https://files.pythonhosted.org/packages/11/0e/a9f6f81013e0deaf559b25711623864970fe6a098314e374ccb1540a4152/regex-2026.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f657a7c1c6ec51b5e0ba97c9817d06b84ea5fa8d82e43b9405de0defdc2b9", size = 801126, upload-time = "2026-04-03T20:53:51.096Z" }, + { url = "https://files.pythonhosted.org/packages/71/61/3a0cc8af2dc0c8deb48e644dd2521f173f7e6513c6e195aad9aa8dd77ac5/regex-2026.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b69102a743e7569ebee67e634a69c4cb7e59d6fa2e1aa7d3bdbf3f61435f62d", size = 776788, upload-time = "2026-04-03T20:53:52.889Z" }, + { url = "https://files.pythonhosted.org/packages/64/0b/8bb9cbf21ef7dee58e49b0fdb066a7aded146c823202e16494a36777594f/regex-2026.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dac006c8b6dda72d86ea3d1333d45147de79a3a3f26f10c1cf9287ca4ca0ac3", size = 785184, upload-time = "2026-04-03T20:53:55.627Z" }, + { url = "https://files.pythonhosted.org/packages/99/c2/d3e80e8137b25ee06c92627de4e4d98b94830e02b3e6f81f3d2e3f504cf5/regex-2026.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:50a766ee2010d504554bfb5f578ed2e066898aa26411d57e6296230627cdefa0", size = 859913, upload-time = "2026-04-03T20:53:57.249Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/9d5d876157d969c804622456ef250017ac7a8f83e0e14f903b9e6df5ce95/regex-2026.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9e2f5217648f68e3028c823df58663587c1507a5ba8419f4fdfc8a461be76043", size = 765732, upload-time = "2026-04-03T20:53:59.428Z" }, + { url = "https://files.pythonhosted.org/packages/82/80/b568935b4421388561c8ed42aff77247285d3ae3bb2a6ca22af63bae805e/regex-2026.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39d8de85a08e32632974151ba59c6e9140646dcc36c80423962b1c5c0a92e244", size = 852152, upload-time = "2026-04-03T20:54:01.505Z" }, + { url = "https://files.pythonhosted.org/packages/39/29/f0f81217e21cd998245da047405366385d5c6072048038a3d33b37a79dc0/regex-2026.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:55d9304e0e7178dfb1e106c33edf834097ddf4a890e2f676f6c5118f84390f73", size = 789076, upload-time = "2026-04-03T20:54:03.323Z" }, + { url = "https://files.pythonhosted.org/packages/49/1d/1d957a61976ab9d4e767dd4f9d04b66cc0c41c5e36cf40e2d43688b5ae6f/regex-2026.4.4-cp312-cp312-win32.whl", hash = "sha256:04bb679bc0bde8a7bfb71e991493d47314e7b98380b083df2447cda4b6edb60f", size = 266700, upload-time = "2026-04-03T20:54:05.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/bf575d396aeb58ea13b06ef2adf624f65b70fafef6950a80fc3da9cae3bc/regex-2026.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:db0ac18435a40a2543dbb3d21e161a6c78e33e8159bd2e009343d224bb03bb1b", size = 277768, upload-time = "2026-04-03T20:54:07.312Z" }, + { url = "https://files.pythonhosted.org/packages/c9/27/049df16ec6a6828ccd72add3c7f54b4df029669bea8e9817df6fff58be90/regex-2026.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:4ce255cc05c1947a12989c6db801c96461947adb7a59990f1360b5983fab4983", size = 270568, upload-time = "2026-04-03T20:54:09.484Z" }, + { url = "https://files.pythonhosted.org/packages/9d/83/c4373bc5f31f2cf4b66f9b7c31005bd87fe66f0dce17701f7db4ee79ee29/regex-2026.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:62f5519042c101762509b1d717b45a69c0139d60414b3c604b81328c01bd1943", size = 490273, upload-time = "2026-04-03T20:54:11.202Z" }, + { url = "https://files.pythonhosted.org/packages/46/f8/fe62afbcc3cf4ad4ac9adeaafd98aa747869ae12d3e8e2ac293d0593c435/regex-2026.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3790ba9fb5dd76715a7afe34dbe603ba03f8820764b1dc929dd08106214ed031", size = 291954, upload-time = "2026-04-03T20:54:13.412Z" }, + { url = "https://files.pythonhosted.org/packages/5a/92/4712b9fe6a33d232eeb1c189484b80c6c4b8422b90e766e1195d6e758207/regex-2026.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fae3c6e795d7678963f2170152b0d892cf6aee9ee8afc8c45e6be38d5107fe7", size = 289487, upload-time = "2026-04-03T20:54:15.824Z" }, + { url = "https://files.pythonhosted.org/packages/88/2c/f83b93f85e01168f1070f045a42d4c937b69fdb8dd7ae82d307253f7e36e/regex-2026.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:298c3ec2d53225b3bf91142eb9691025bab610e0c0c51592dde149db679b3d17", size = 796646, upload-time = "2026-04-03T20:54:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/df/55/61a2e17bf0c4dc57e11caf8dd11771280d8aaa361785f9e3bc40d653f4a7/regex-2026.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9638791082eaf5b3ac112c587518ee78e083a11c4b28012d8fe2a0f536dfb17", size = 865904, upload-time = "2026-04-03T20:54:20.019Z" }, + { url = "https://files.pythonhosted.org/packages/45/32/1ac8ed1b5a346b5993a3d256abe0a0f03b0b73c8cc88d928537368ac65b6/regex-2026.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae3e764bd4c5ff55035dc82a8d49acceb42a5298edf6eb2fc4d328ee5dd7afae", size = 912304, upload-time = "2026-04-03T20:54:22.403Z" }, + { url = "https://files.pythonhosted.org/packages/26/47/2ee5c613ab546f0eddebf9905d23e07beb933416b1246c2d8791d01979b4/regex-2026.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffa81f81b80047ba89a3c69ae6a0f78d06f4a42ce5126b0eb2a0a10ad44e0b2e", size = 801126, upload-time = "2026-04-03T20:54:24.308Z" }, + { url = "https://files.pythonhosted.org/packages/75/cd/41dacd129ca9fd20bd7d02f83e0fad83e034ac8a084ec369c90f55ef37e2/regex-2026.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f56ebf9d70305307a707911b88469213630aba821e77de7d603f9d2f0730687d", size = 776772, upload-time = "2026-04-03T20:54:26.319Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5af0b588174cb5f46041fa7dd64d3fd5cd2fe51f18766703d1edc387f324/regex-2026.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:773d1dfd652bbffb09336abf890bfd64785c7463716bf766d0eb3bc19c8b7f27", size = 785228, upload-time = "2026-04-03T20:54:28.387Z" }, + { url = "https://files.pythonhosted.org/packages/b7/3b/f5a72b7045bd59575fc33bf1345f156fcfd5a8484aea6ad84b12c5a82114/regex-2026.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d51d20befd5275d092cdffba57ded05f3c436317ee56466c8928ac32d960edaf", size = 860032, upload-time = "2026-04-03T20:54:30.641Z" }, + { url = "https://files.pythonhosted.org/packages/39/a4/72a317003d6fcd7a573584a85f59f525dfe8f67e355ca74eb6b53d66a5e2/regex-2026.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0a51cdb3c1e9161154f976cb2bef9894bc063ac82f31b733087ffb8e880137d0", size = 765714, upload-time = "2026-04-03T20:54:32.789Z" }, + { url = "https://files.pythonhosted.org/packages/25/1e/5672e16f34dbbcb2560cc7e6a2fbb26dfa8b270711e730101da4423d3973/regex-2026.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ae5266a82596114e41fb5302140e9630204c1b5f325c770bec654b95dd54b0aa", size = 852078, upload-time = "2026-04-03T20:54:34.546Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0d/c813f0af7c6cc7ed7b9558bac2e5120b60ad0fa48f813e4d4bd55446f214/regex-2026.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c882cd92ec68585e9c1cf36c447ec846c0d94edd706fe59e0c198e65822fd23b", size = 789181, upload-time = "2026-04-03T20:54:36.642Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6d/a344608d1adbd2a95090ddd906cec09a11be0e6517e878d02a5123e0917f/regex-2026.4.4-cp313-cp313-win32.whl", hash = "sha256:05568c4fbf3cb4fa9e28e3af198c40d3237cf6041608a9022285fe567ec3ad62", size = 266690, upload-time = "2026-04-03T20:54:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/31/07/54049f89b46235ca6f45cd6c88668a7050e77d4a15555e47dd40fde75263/regex-2026.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:3384df51ed52db0bea967e21458ab0a414f67cdddfd94401688274e55147bb81", size = 277733, upload-time = "2026-04-03T20:54:40.11Z" }, + { url = "https://files.pythonhosted.org/packages/0e/21/61366a8e20f4d43fb597708cac7f0e2baadb491ecc9549b4980b2be27d16/regex-2026.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:acd38177bd2c8e69a411d6521760806042e244d0ef94e2dd03ecdaa8a3c99427", size = 270565, upload-time = "2026-04-03T20:54:41.883Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1e/3a2b9672433bef02f5d39aa1143ca2c08f311c1d041c464a42be9ae648dc/regex-2026.4.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f94a11a9d05afcfcfa640e096319720a19cc0c9f7768e1a61fceee6a3afc6c7c", size = 494126, upload-time = "2026-04-03T20:54:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/4e/4b/c132a4f4fe18ad3340d89fcb56235132b69559136036b845be3c073142ed/regex-2026.4.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:36bcb9d6d1307ab629edc553775baada2aefa5c50ccc0215fbfd2afcfff43141", size = 293882, upload-time = "2026-04-03T20:54:45.41Z" }, + { url = "https://files.pythonhosted.org/packages/f4/5f/eaa38092ce7a023656280f2341dbbd4ad5f05d780a70abba7bb4f4bea54c/regex-2026.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261c015b3e2ed0919157046d768774ecde57f03d8fa4ba78d29793447f70e717", size = 292334, upload-time = "2026-04-03T20:54:47.051Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f6/dd38146af1392dac33db7074ab331cec23cced3759167735c42c5460a243/regex-2026.4.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c228cf65b4a54583763645dcd73819b3b381ca8b4bb1b349dee1c135f4112c07", size = 811691, upload-time = "2026-04-03T20:54:49.074Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f0/dc54c2e69f5eeec50601054998ec3690d5344277e782bd717e49867c1d29/regex-2026.4.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd2630faeb6876fb0c287f664d93ddce4d50cd46c6e88e60378c05c9047e08ca", size = 871227, upload-time = "2026-04-03T20:54:51.035Z" }, + { url = "https://files.pythonhosted.org/packages/a1/af/cb16bd5dc61621e27df919a4449bbb7e5a1034c34d307e0a706e9cc0f3e3/regex-2026.4.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a50ab11b7779b849472337191f3a043e27e17f71555f98d0092fa6d73364520", size = 917435, upload-time = "2026-04-03T20:54:52.994Z" }, + { url = "https://files.pythonhosted.org/packages/5c/71/8b260897f22996b666edd9402861668f45a2ca259f665ac029e6104a2d7d/regex-2026.4.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0734f63afe785138549fbe822a8cfeaccd1bae814c5057cc0ed5b9f2de4fc883", size = 816358, upload-time = "2026-04-03T20:54:54.884Z" }, + { url = "https://files.pythonhosted.org/packages/1c/60/775f7f72a510ef238254906c2f3d737fc80b16ca85f07d20e318d2eea894/regex-2026.4.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4ee50606cb1967db7e523224e05f32089101945f859928e65657a2cbb3d278b", size = 785549, upload-time = "2026-04-03T20:54:57.01Z" }, + { url = "https://files.pythonhosted.org/packages/58/42/34d289b3627c03cf381e44da534a0021664188fa49ba41513da0b4ec6776/regex-2026.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6c1818f37be3ca02dcb76d63f2c7aaba4b0dc171b579796c6fbe00148dfec6b1", size = 801364, upload-time = "2026-04-03T20:54:58.981Z" }, + { url = "https://files.pythonhosted.org/packages/fc/20/f6ecf319b382a8f1ab529e898b222c3f30600fcede7834733c26279e7465/regex-2026.4.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f5bfc2741d150d0be3e4a0401a5c22b06e60acb9aa4daa46d9e79a6dcd0f135b", size = 866221, upload-time = "2026-04-03T20:55:00.88Z" }, + { url = "https://files.pythonhosted.org/packages/92/6a/9f16d3609d549bd96d7a0b2aee1625d7512ba6a03efc01652149ef88e74d/regex-2026.4.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:504ffa8a03609a087cad81277a629b6ce884b51a24bd388a7980ad61748618ff", size = 772530, upload-time = "2026-04-03T20:55:03.213Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f6/aa9768bc96a4c361ac96419fbaf2dcdc33970bb813df3ba9b09d5d7b6d96/regex-2026.4.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70aadc6ff12e4b444586e57fc30771f86253f9f0045b29016b9605b4be5f7dfb", size = 856989, upload-time = "2026-04-03T20:55:05.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b4/c671db3556be2473ae3e4bb7a297c518d281452871501221251ea4ecba57/regex-2026.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f4f83781191007b6ef43b03debc35435f10cad9b96e16d147efe84a1d48bdde4", size = 803241, upload-time = "2026-04-03T20:55:07.162Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5c/83e3b1d89fa4f6e5a1bc97b4abd4a9a97b3c1ac7854164f694f5f0ba98a0/regex-2026.4.4-cp313-cp313t-win32.whl", hash = "sha256:e014a797de43d1847df957c0a2a8e861d1c17547ee08467d1db2c370b7568baa", size = 269921, upload-time = "2026-04-03T20:55:09.62Z" }, + { url = "https://files.pythonhosted.org/packages/28/07/077c387121f42cdb4d92b1301133c0d93b5709d096d1669ab847dda9fe2e/regex-2026.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:b15b88b0d52b179712632832c1d6e58e5774f93717849a41096880442da41ab0", size = 281240, upload-time = "2026-04-03T20:55:11.521Z" }, + { url = "https://files.pythonhosted.org/packages/9d/22/ead4a4abc7c59a4d882662aa292ca02c8b617f30b6e163bc1728879e9353/regex-2026.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:586b89cdadf7d67bf86ae3342a4dcd2b8d70a832d90c18a0ae955105caf34dbe", size = 272440, upload-time = "2026-04-03T20:55:13.365Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "s1-orbits" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/8b/3ba0f9f8d5bf760e8a649516dfde46183b3f4649fabe1d71c1a5596cdc87/s1_orbits-0.2.0.tar.gz", hash = "sha256:9ff7561c7fb76490b6a0c232e61f64226899c8424f49ddd05825eb166d0daeb3", size = 8953, upload-time = "2025-07-22T18:34:12.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/0a/0e7d9741406a044f01a918894b850067c9c7b7e92e17c533cf5fdcac4fb4/s1_orbits-0.2.0-py3-none-any.whl", hash = "sha256:c1d2c42f8d99628efd21b5445fa8d0c9f9d2f575c28d30a29dd42ff305df99c5", size = 4989, upload-time = "2025-07-22T18:34:12.005Z" }, +] + +[[package]] +name = "sentinel-orbit-downloader" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "asf-search" }, + { name = "fastapi" }, + { name = "s1-orbits" }, + { name = "shapely" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "asf-search", specifier = ">=12.0.7" }, + { name = "fastapi", specifier = ">=0.116.1" }, + { name = "s1-orbits", specifier = ">=0.2.0" }, + { name = "shapely", specifier = ">=2.1.2" }, + { name = "uvicorn", specifier = ">=0.35.0" }, +] + +[[package]] +name = "shapely" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/8d/1ff672dea9ec6a7b5d422eb6d095ed886e2e523733329f75fdcb14ee1149/shapely-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:91121757b0a36c9aac3427a651a7e6567110a4a67c97edf04f8d55d4765f6618", size = 1820038, upload-time = "2025-09-24T13:50:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ce/28fab8c772ce5db23a0d86bf0adaee0c4c79d5ad1db766055fa3dab442e2/shapely-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:16a9c722ba774cf50b5d4541242b4cce05aafd44a015290c82ba8a16931ff63d", size = 1626039, upload-time = "2025-09-24T13:50:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/70/8b/868b7e3f4982f5006e9395c1e12343c66a8155c0374fdc07c0e6a1ab547d/shapely-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cc4f7397459b12c0b196c9efe1f9d7e92463cbba142632b4cc6d8bbbbd3e2b09", size = 3001519, upload-time = "2025-09-24T13:50:18.606Z" }, + { url = "https://files.pythonhosted.org/packages/13/02/58b0b8d9c17c93ab6340edd8b7308c0c5a5b81f94ce65705819b7416dba5/shapely-2.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:136ab87b17e733e22f0961504d05e77e7be8c9b5a8184f685b4a91a84efe3c26", size = 3110842, upload-time = "2025-09-24T13:50:21.77Z" }, + { url = "https://files.pythonhosted.org/packages/af/61/8e389c97994d5f331dcffb25e2fa761aeedfb52b3ad9bcdd7b8671f4810a/shapely-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:16c5d0fc45d3aa0a69074979f4f1928ca2734fb2e0dde8af9611e134e46774e7", size = 4021316, upload-time = "2025-09-24T13:50:23.626Z" }, + { url = "https://files.pythonhosted.org/packages/d3/d4/9b2a9fe6039f9e42ccf2cb3e84f219fd8364b0c3b8e7bbc857b5fbe9c14c/shapely-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ddc759f72b5b2b0f54a7e7cde44acef680a55019eb52ac63a7af2cf17cb9cd2", size = 4178586, upload-time = "2025-09-24T13:50:25.443Z" }, + { url = "https://files.pythonhosted.org/packages/16/f6/9840f6963ed4decf76b08fd6d7fed14f8779fb7a62cb45c5617fa8ac6eab/shapely-2.1.2-cp311-cp311-win32.whl", hash = "sha256:2fa78b49485391224755a856ed3b3bd91c8455f6121fee0db0e71cefb07d0ef6", size = 1543961, upload-time = "2025-09-24T13:50:26.968Z" }, + { url = "https://files.pythonhosted.org/packages/38/1e/3f8ea46353c2a33c1669eb7327f9665103aa3a8dfe7f2e4ef714c210b2c2/shapely-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:c64d5c97b2f47e3cd9b712eaced3b061f2b71234b3fc263e0fcf7d889c6559dc", size = 1722856, upload-time = "2025-09-24T13:50:28.497Z" }, + { url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" }, + { url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" }, + { url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644, upload-time = "2025-09-24T13:50:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887, upload-time = "2025-09-24T13:50:46.735Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931, upload-time = "2025-09-24T13:50:48.374Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855, upload-time = "2025-09-24T13:50:50.037Z" }, + { url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960, upload-time = "2025-09-24T13:50:51.74Z" }, + { url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851, upload-time = "2025-09-24T13:50:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890, upload-time = "2025-09-24T13:50:55.337Z" }, + { url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151, upload-time = "2025-09-24T13:50:57.153Z" }, + { url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130, upload-time = "2025-09-24T13:50:58.49Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802, upload-time = "2025-09-24T13:50:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460, upload-time = "2025-09-24T13:51:02.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223, upload-time = "2025-09-24T13:51:04.472Z" }, + { url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760, upload-time = "2025-09-24T13:51:06.455Z" }, + { url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078, upload-time = "2025-09-24T13:51:08.584Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178, upload-time = "2025-09-24T13:51:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, +]