chore: 合并 sentinel-orbit-downloader 历史

This commit is contained in:
Harmon
2026-10-16 18:59:38 +08:00
41 changed files with 8401 additions and 0 deletions
+23
View File
@@ -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/
@@ -0,0 +1 @@
3.12
+401
View File
@@ -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”
- “下载原始数据”
- “下载精密轨道”
- “管理任务记录”
这四件事先稳定做好。
@@ -0,0 +1,5 @@
@echo off
setlocal
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Start-SentinelUI.ps1"
if errorlevel 1 pause
endlocal
@@ -0,0 +1,338 @@
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 Get-ListeningProcessId {
param([int]$Port)
try {
$connection = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction Stop |
Select-Object -First 1
if ($connection) {
return [int]$connection.OwningProcess
}
} catch {
}
$lines = netstat -ano -p tcp | Select-String "LISTENING"
foreach ($line in $lines) {
$parts = ($line.Line.Trim() -split "\s+")
if ($parts.Count -ge 5 -and $parts[1].EndsWith(":$Port")) {
return [int]$parts[4]
}
}
return 0
}
function Invoke-UiShutdown {
param(
[string]$Url,
[string]$ShutdownToken
)
if ([string]::IsNullOrWhiteSpace($Url) -or [string]::IsNullOrWhiteSpace($ShutdownToken)) {
return $false
}
try {
$headers = @{
"X-S1DL-Shutdown-Token" = $ShutdownToken
}
$shutdownUrl = $Url.TrimEnd("/") + "/api/shutdown"
$null = Invoke-RestMethod -Uri $shutdownUrl -Method Post -Headers $headers -TimeoutSec 5 -ErrorAction Stop
return $true
} catch {
return $false
}
}
function Wait-ForUiShutdown {
param(
[int]$ProcessId,
[int]$Port,
[int]$TimeoutSeconds = 10
)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
$running = Get-RunningProcess -Id $ProcessId
$listenerProcessId = 0
if ($Port -gt 0) {
$listenerProcessId = Get-ListeningProcessId -Port $Port
}
if (-not $running -and $listenerProcessId -le 0) {
return $true
}
Start-Sleep -Milliseconds 500
}
return $false
}
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,
[string]$ShutdownToken
)
$payload = [ordered]@{
pid = $ProcessId
port = $Port
url = $Url
shutdown_token = $ShutdownToken
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
}
}
if ($ForceRestart -and $state) {
$existingStopped = $false
if ($state.url -and $state.shutdown_token) {
Write-Info "Requesting shutdown for previous tracked UI"
if (Invoke-UiShutdown -Url $state.url -ShutdownToken $state.shutdown_token) {
$existingStopped = Wait-ForUiShutdown -ProcessId ([int]$state.pid) -Port ([int]$state.port)
}
}
if (-not $existingStopped) {
$oldProcess = Get-RunningProcess -Id ([int]$state.pid)
if ($oldProcess) {
Write-Info "Stopping previous tracked process $($oldProcess.Id)"
Stop-Process -Id $oldProcess.Id -Force
Start-Sleep -Seconds 1
}
}
Remove-State -Path $statePath
}
if ($ForceRestart) {
foreach ($port in $PreferredPorts) {
if (Test-Health -Port $port) {
$listenerProcessId = Get-ListeningProcessId -Port $port
if ($listenerProcessId -gt 0) {
Write-Info "Stopping existing UI listener $listenerProcessId on port $port"
Stop-Process -Id $listenerProcessId -Force
Start-Sleep -Seconds 1
}
}
}
}
foreach ($port in $PreferredPorts) {
if (Test-Health -Port $port) {
$url = "http://127.0.0.1:$port/"
Write-Info "Found an existing running UI at $url"
Save-State -Path $statePath -ProcessId 0 -Port $port -Url $url -ShutdownToken ""
if (-not $NoBrowser) {
Start-Process $url | Out-Null
}
exit 0
}
}
$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/"
$shutdownToken = [guid]::NewGuid().ToString("N")
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"
try {
$env:S1DL_UI_SHUTDOWN_TOKEN = $shutdownToken
$process = Start-Process `
-FilePath $pythonPath `
-ArgumentList $arguments `
-WorkingDirectory $projectRoot `
-WindowStyle Hidden `
-RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath `
-PassThru
} finally {
Remove-Item Env:S1DL_UI_SHUTDOWN_TOKEN -ErrorAction SilentlyContinue
}
$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 -ShutdownToken $shutdownToken
$listenerProcessId = Get-ListeningProcessId -Port $portToUse
if ($listenerProcessId -gt 0) {
Save-State -Path $statePath -ProcessId $listenerProcessId -Port $portToUse -Url $url -ShutdownToken $shutdownToken
} else {
Save-State -Path $statePath -ProcessId $process.Id -Port $portToUse -Url $url -ShutdownToken $shutdownToken
}
Write-Info "UI is ready: $url"
if (-not $NoBrowser) {
Start-Process $url | Out-Null
}
@@ -0,0 +1,5 @@
@echo off
setlocal
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Stop-SentinelUI.ps1"
if errorlevel 1 pause
endlocal
@@ -0,0 +1,146 @@
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
}
}
function Get-ListeningProcessId {
param([int]$Port)
try {
$connection = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction Stop |
Select-Object -First 1
if ($connection) {
return [int]$connection.OwningProcess
}
} catch {
}
$lines = netstat -ano -p tcp | Select-String "LISTENING"
foreach ($line in $lines) {
$parts = ($line.Line.Trim() -split "\s+")
if ($parts.Count -ge 5 -and $parts[1].EndsWith(":$Port")) {
return [int]$parts[4]
}
}
return 0
}
function Invoke-UiShutdown {
param(
[string]$Url,
[string]$ShutdownToken
)
if ([string]::IsNullOrWhiteSpace($Url) -or [string]::IsNullOrWhiteSpace($ShutdownToken)) {
return $false
}
try {
$headers = @{
"X-S1DL-Shutdown-Token" = $ShutdownToken
}
$shutdownUrl = $Url.TrimEnd("/") + "/api/shutdown"
$null = Invoke-RestMethod -Uri $shutdownUrl -Method Post -Headers $headers -TimeoutSec 5 -ErrorAction Stop
return $true
} catch {
return $false
}
}
function Wait-ForUiShutdown {
param(
[int]$ProcessId,
[int]$Port,
[int]$TimeoutSeconds = 10
)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
$running = Get-RunningProcess -Id $ProcessId
$listenerProcessId = 0
if ($Port -gt 0) {
$listenerProcessId = Get-ListeningProcessId -Port $Port
}
if (-not $running -and $listenerProcessId -le 0) {
return $true
}
Start-Sleep -Milliseconds 500
}
return $false
}
$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
$stopped = $false
if ($state.url -and $state.shutdown_token) {
Write-Info "Requesting UI shutdown via API"
if (Invoke-UiShutdown -Url $state.url -ShutdownToken $state.shutdown_token) {
$stopped = Wait-ForUiShutdown -ProcessId ([int]$state.pid) -Port ([int]$state.port)
}
}
if (-not $stopped) {
$process = Get-RunningProcess -Id ([int]$state.pid)
if ($process) {
Write-Info "Stopping UI process $($process.Id)"
try {
Stop-Process -Id $process.Id -Force -ErrorAction Stop
$stopped = $true
} catch {
throw "Failed to stop UI process $($process.Id). It may have been started from an elevated shell. Start or stop it with the same privilege level, or restart it with the updated launcher once so it can use API shutdown. $($_.Exception.Message)"
}
} else {
$listenerProcessId = 0
if ($state.port) {
$listenerProcessId = Get-ListeningProcessId -Port ([int]$state.port)
}
if ($listenerProcessId -gt 0) {
Write-Info "Stopping listener process $listenerProcessId"
try {
Stop-Process -Id $listenerProcessId -Force -ErrorAction Stop
$stopped = $true
} catch {
throw "Failed to stop UI listener process $listenerProcessId. It may have been started from an elevated shell. Start or stop it with the same privilege level, or restart it with the updated launcher once so it can use API shutdown. $($_.Exception.Message)"
}
} else {
Write-Info "Tracked process is not running."
$stopped = $true
}
}
}
if (-not $stopped) {
throw "UI shutdown did not complete."
}
Remove-Item -LiteralPath $statePath -Force
Write-Info "Done."
@@ -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
<data_root>\
projects\
<project_slug>\
jobs\
<job_id>_<area>_<start>_<end>\
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
<data_root>\
app.db
app.log
regions\
regions_index_version.json
projects\
<project_slug>\
project.json
aois\
jobs\
<job_id>_<area_slug>_<date_range>\
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。
- 场景覆盖图层和轨道号统计。
- 下载完成后的数据质量检查。
+28
View File
@@ -0,0 +1,28 @@
[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",
"pyproj>=3.7.0",
"python-multipart>=0.0.20",
"pyshp>=2.3.1",
"s1-orbits>=0.2.0",
"shapely>=2.1.2",
"uvicorn>=0.35.0",
]
[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"
@@ -0,0 +1,3 @@
from .cli import main
__all__ = ["main"]
@@ -0,0 +1,5 @@
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1 @@
"""FastAPI application package."""
@@ -0,0 +1,62 @@
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,
configured_ui_shutdown_token,
)
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.",
)
app.state.shutdown_token = configured_ui_shutdown_token()
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,
)
@@ -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()
@@ -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),
)
@@ -0,0 +1,42 @@
from __future__ import annotations
import os
import signal
import time
from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request, status
from sentinel_orbit_downloader.api.deps import get_db_path
from sentinel_orbit_downloader.api.schemas import HealthResponse, MessageResponse
router = APIRouter(tags=["health"])
def _terminate_current_process() -> None:
time.sleep(0.2)
try:
os.kill(os.getpid(), signal.SIGTERM)
except Exception:
os._exit(0)
@router.get("/health", response_model=HealthResponse)
def health(db_path=Depends(get_db_path)) -> HealthResponse:
return HealthResponse(status="ok", db_path=str(db_path))
@router.post("/shutdown", response_model=MessageResponse, include_in_schema=False)
def shutdown(
background_tasks: BackgroundTasks,
request: Request,
x_s1dl_shutdown_token: str | None = Header(default=None, alias="X-S1DL-Shutdown-Token"),
) -> MessageResponse:
expected_token = getattr(request.app.state, "shutdown_token", None)
if not expected_token:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Shutdown is disabled.")
if x_s1dl_shutdown_token != expected_token:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid shutdown token.")
background_tasks.add_task(_terminate_current_process)
return MessageResponse(message="Shutdown requested.")
@@ -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],
)
@@ -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)
@@ -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)
@@ -0,0 +1,153 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from sentinel_orbit_downloader.api.deps import get_db_path
from sentinel_orbit_downloader.api.schemas import (
JobSceneResponse,
JobSummary,
SearchJobCreateRequest,
SearchJobCreateResponse,
UploadedAoiResponse,
)
from sentinel_orbit_downloader.services.aoi import bbox_to_wkt, parse_uploaded_aoi
from sentinel_orbit_downloader.services.asf_client import SearchParams, query_scenes
from sentinel_orbit_downloader.services.project_store import (
create_search_job,
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("/upload-aoi", response_model=UploadedAoiResponse)
async def upload_search_aoi_endpoint(
files: list[UploadFile] = File(...),
) -> UploadedAoiResponse:
uploads: list[tuple[str, bytes]] = []
for file in files:
if not file.filename:
continue
content = await file.read()
uploads.append((file.filename, content))
try:
parsed = parse_uploaded_aoi(uploads)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return UploadedAoiResponse(
name=parsed.name,
source_format=parsed.source_format,
geometry_type=parsed.geometry_type,
feature_count=parsed.feature_count,
bbox=parsed.bbox,
wkt=parsed.wkt,
)
@router.post("", response_model=SearchJobCreateResponse)
def create_search_job_endpoint(
request: SearchJobCreateRequest,
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": "aoi_file" if request.source_name else "wkt",
"source_value": request.source_name.strip() if request.source_name else request.wkt.strip(),
"region_adcode": None,
"region_name": None,
}
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")
@@ -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 凭据已清除")
@@ -0,0 +1,205 @@
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 UploadedAoiResponse(BaseModel):
name: str
source_format: str
geometry_type: str
feature_count: int
bbox: list[float] = Field(default_factory=list)
wkt: str
class SearchJobCreateRequest(BaseModel):
project_id: int | None = None
bbox: str | None = None
wkt: str | None = None
region_adcode: str | None = None
source_name: str | None = None
start: str
end: str
processing_level: str = "SLC"
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=3, 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)
@@ -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,
)
@@ -0,0 +1,808 @@
from __future__ import annotations
import argparse
import getpass
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,
product_size_mb,
query_scenes,
scene_name_from_product,
write_manifest,
)
from sentinel_orbit_downloader.services.orbit_client import (
collect_orbit_scenes,
download_orbits,
)
from sentinel_orbit_downloader.services.download_manager import (
DownloadJobRequest,
execute_download_job,
queue_download_job,
)
from sentinel_orbit_downloader.services.project_store import (
create_project,
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 <out>/data",
)
download_parser.add_argument(
"--orbit-dir",
type=Path,
help="override orbit download directory; default is <out>/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=3,
help="retry attempts for each product download",
)
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)")
update_search_job(
args.db,
job.id,
status="running",
result_count=len(results),
data_dir=str(data_dir),
orbit_dir=str(orbit_dir),
manifest_path=str(manifest),
)
print_results(results)
write_manifest(results, manifest)
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
password = args.password
if args.ask_password and not args.skip_data:
password = getpass.getpass("Earthdata password: ")
manager_request = DownloadJobRequest(
job_id=job.id,
data_dir=data_dir,
orbit_dir=orbit_dir,
manifest_path=manifest,
skip_data=args.skip_data,
skip_orbits=args.skip_orbits,
processes=args.processes,
ignore_orbit_errors=args.ignore_orbit_errors,
username=args.username,
password=password,
)
queue_download_job(args.db, manager_request)
print(f"Downloading {len(results)} scene(s) with resume and validation support ...")
execute_download_job(args.db, manager_request)
finished_job = get_job(args.db, job.id)
final_status = finished_job.status if finished_job is not None else "failed"
print(f"Download job finished: {final_status}")
print(f"Saved job: {job.id}")
if final_status in {"failed", "partial_failed"}:
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 "<unknown>"
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
@@ -0,0 +1 @@
"""Core configuration and constants."""
@@ -0,0 +1,34 @@
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)
def configured_ui_shutdown_token() -> str | None:
raw = os.getenv("S1DL_UI_SHUTDOWN_TOKEN")
if not raw:
return None
return raw
@@ -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": "澳门特别行政区",
}
@@ -0,0 +1 @@
"""Application services."""
@@ -0,0 +1,332 @@
from __future__ import annotations
from dataclasses import dataclass
from io import BytesIO
import json
from pathlib import Path
from typing import Any, Iterable, Sequence
import zipfile
import shapefile
from pyproj import CRS, Transformer
from shapely import make_valid
from shapely.geometry import shape as shapely_shape
from shapely.geometry.base import BaseGeometry
from shapely.ops import unary_union
from shapely.ops import transform as shapely_transform
GEOJSON_SUFFIXES = {".geojson", ".json"}
TEXT_WKT_SUFFIXES = {".wkt", ".txt"}
SHAPEFILE_PART_SUFFIXES = {".shp", ".shx", ".dbf", ".prj", ".cpg"}
@dataclass(frozen=True)
class ParsedAOI:
name: str
wkt: str
source_format: str
geometry_type: str
feature_count: int
bbox: list[float]
def bbox_to_wkt(value: str) -> str:
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:
suffix = path.suffix.lower()
if suffix in GEOJSON_SUFFIXES:
return parse_geojson_bytes(path.name, path.read_bytes()).wkt
if suffix == ".zip":
return parse_zip_bytes(path.name, path.read_bytes()).wkt
if suffix == ".shp":
return parse_shapefile_paths(path).wkt
text = decode_text_bytes(path.read_bytes()).strip()
if not text:
raise ValueError(f"AOI file is empty: {path}")
if suffix in TEXT_WKT_SUFFIXES:
return text
if text[0] in "{[":
return parse_geojson_text(path.name, text).wkt
return text
def parse_uploaded_aoi(files: Sequence[tuple[str, bytes]]) -> ParsedAOI:
cleaned = [(Path(name).name or name, content) for name, content in files if content]
if not cleaned:
raise ValueError("No AOI files were provided")
suffixes = {Path(name).suffix.lower() for name, _ in cleaned}
if len(cleaned) == 1:
name, content = cleaned[0]
suffix = Path(name).suffix.lower()
if suffix in GEOJSON_SUFFIXES:
return parse_geojson_bytes(name, content)
if suffix == ".zip":
return parse_zip_bytes(name, content)
if suffix == ".shp":
return parse_shapefile_uploads(cleaned)
if suffix in TEXT_WKT_SUFFIXES:
return parse_wkt_text(name, content)
if suffixes & SHAPEFILE_PART_SUFFIXES:
return parse_shapefile_uploads(cleaned)
raise ValueError(
"Unsupported AOI upload. Use .geojson/.json, .zip shapefile, "
"or shapefile parts (.shp/.shx/.dbf)."
)
def parse_wkt_text(name: str, content: bytes) -> ParsedAOI:
text = decode_text_bytes(content).strip()
if not text:
raise ValueError(f"AOI file is empty: {name}")
return ParsedAOI(
name=Path(name).stem or name,
wkt=text,
source_format="wkt",
geometry_type="WKT",
feature_count=1,
bbox=[],
)
def parse_geojson_bytes(name: str, content: bytes) -> ParsedAOI:
text = decode_text_bytes(content)
return parse_geojson_text(name, text)
def parse_geojson_text(name: str, text: str) -> ParsedAOI:
stripped = text.strip()
if not stripped:
raise ValueError(f"AOI file is empty: {name}")
try:
data = json.loads(stripped)
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid GeoJSON: {exc}") from exc
geometry, feature_count = geojson_to_geometry(data)
return build_parsed_aoi(
name=Path(name).stem or name,
source_format="geojson",
geometry=geometry,
feature_count=feature_count,
)
def parse_zip_bytes(name: str, content: bytes) -> ParsedAOI:
try:
archive = zipfile.ZipFile(BytesIO(content))
except zipfile.BadZipFile as exc:
raise ValueError(f"Invalid zip file: {name}") from exc
with archive:
file_names = [member for member in archive.namelist() if not member.endswith("/")]
geojson_members = [member for member in file_names if Path(member).suffix.lower() in GEOJSON_SUFFIXES]
if geojson_members:
member = geojson_members[0]
parsed = parse_geojson_bytes(Path(member).name, archive.read(member))
return ParsedAOI(
name=Path(name).stem or parsed.name,
wkt=parsed.wkt,
source_format="geojson-zip",
geometry_type=parsed.geometry_type,
feature_count=parsed.feature_count,
bbox=parsed.bbox,
)
shape_members = [
(Path(member).name, archive.read(member))
for member in file_names
if Path(member).suffix.lower() in SHAPEFILE_PART_SUFFIXES
]
if shape_members:
parsed = parse_shapefile_uploads(shape_members)
return ParsedAOI(
name=Path(name).stem or parsed.name,
wkt=parsed.wkt,
source_format="shapefile-zip",
geometry_type=parsed.geometry_type,
feature_count=parsed.feature_count,
bbox=parsed.bbox,
)
raise ValueError("Zip file does not contain GeoJSON or shapefile content")
def parse_shapefile_paths(shp_path: Path) -> ParsedAOI:
base = shp_path.with_suffix("")
uploads: list[tuple[str, bytes]] = [(shp_path.name, shp_path.read_bytes())]
for suffix in (".shx", ".dbf", ".prj", ".cpg"):
candidate = Path(f"{base}{suffix}")
if candidate.exists():
uploads.append((candidate.name, candidate.read_bytes()))
return parse_shapefile_uploads(uploads)
def parse_shapefile_uploads(files: Sequence[tuple[str, bytes]]) -> ParsedAOI:
groups: dict[str, dict[str, bytes]] = {}
display_names: dict[str, str] = {}
for name, content in files:
suffix = Path(name).suffix.lower()
if suffix not in SHAPEFILE_PART_SUFFIXES:
continue
stem = Path(name).stem
key = stem.lower()
groups.setdefault(key, {})[suffix] = content
display_names[key] = stem
shp_groups = [key for key, parts in groups.items() if ".shp" in parts]
if not shp_groups:
raise ValueError("Shapefile upload requires at least a .shp file")
if len(shp_groups) > 1:
names = ", ".join(display_names[key] for key in shp_groups)
raise ValueError(f"Multiple shapefiles were uploaded at once: {names}")
key = shp_groups[0]
parts = groups[key]
reader = shapefile.Reader(
shp=BytesIO(parts[".shp"]),
shx=BytesIO(parts[".shx"]) if ".shx" in parts else None,
dbf=BytesIO(parts[".dbf"]) if ".dbf" in parts else None,
)
try:
geometries = [shapely_shape(shape.__geo_interface__) for shape in reader.shapes()]
finally:
close = getattr(reader, "close", None)
if callable(close):
close()
if not geometries:
raise ValueError("Shapefile has no geometry records")
geometry = combine_geometries(geometries)
geometry = reproject_shapefile_geometry(geometry, parts.get(".prj"))
return build_parsed_aoi(
name=display_names[key],
source_format="shapefile",
geometry=geometry,
feature_count=len(geometries),
)
def geojson_to_geometry(data: dict[str, Any]) -> tuple[BaseGeometry, int]:
kind = data.get("type")
if kind == "FeatureCollection":
features = data.get("features") or []
geometries = []
for feature in features:
geometry = feature.get("geometry") if isinstance(feature, dict) else None
if geometry:
geometries.append(shapely_shape(geometry))
if not geometries:
raise ValueError("GeoJSON FeatureCollection has no geometry")
return combine_geometries(geometries), len(geometries)
if kind == "Feature":
geometry = data.get("geometry")
if not geometry:
raise ValueError("GeoJSON Feature has no geometry")
return normalize_geometry(shapely_shape(geometry)), 1
if kind:
return normalize_geometry(shapely_shape(data)), 1
raise ValueError("Invalid GeoJSON: missing type")
def combine_geometries(geometries: Iterable[BaseGeometry]) -> BaseGeometry:
items = [normalize_geometry(geometry) for geometry in geometries if not geometry.is_empty]
if not items:
raise ValueError("AOI has no valid geometry")
geometry = items[0] if len(items) == 1 else unary_union(items)
return normalize_geometry(geometry)
def normalize_geometry(geometry: BaseGeometry) -> BaseGeometry:
if geometry.is_empty:
raise ValueError("AOI geometry is empty")
if not geometry.is_valid:
repaired = make_valid(geometry)
if not repaired.is_empty:
geometry = repaired
if geometry.is_empty:
raise ValueError("AOI geometry is empty")
return geometry
def reproject_shapefile_geometry(geometry: BaseGeometry, prj_content: bytes | None) -> BaseGeometry:
if not prj_content:
validate_lon_lat_bounds(geometry)
return geometry
prj_text = decode_text_bytes(prj_content).strip()
if not prj_text:
validate_lon_lat_bounds(geometry)
return geometry
try:
source_crs = CRS.from_wkt(prj_text)
except Exception as exc:
raise ValueError(f"Unable to read shapefile .prj CRS: {exc}") from exc
target_crs = CRS.from_epsg(4326)
if source_crs.equals(target_crs):
validate_lon_lat_bounds(geometry)
return geometry
transformer = Transformer.from_crs(source_crs, target_crs, always_xy=True)
transformed = shapely_transform(transformer.transform, geometry)
validate_lon_lat_bounds(transformed)
return normalize_geometry(transformed)
def validate_lon_lat_bounds(geometry: BaseGeometry) -> None:
min_x, min_y, max_x, max_y = geometry.bounds
if min_x < -180 or max_x > 180 or min_y < -90 or max_y > 90:
raise ValueError(
"AOI coordinates are outside longitude/latitude bounds. "
"For shapefiles, include a valid .prj file or convert the data to WGS84/EPSG:4326 first."
)
def build_parsed_aoi(
*,
name: str,
source_format: str,
geometry: BaseGeometry,
feature_count: int,
) -> ParsedAOI:
geometry = normalize_geometry(geometry)
bounds = list(geometry.bounds) if geometry.bounds else []
return ParsedAOI(
name=name,
wkt=geometry.wkt,
source_format=source_format,
geometry_type=geometry.geom_type,
feature_count=feature_count,
bbox=[float(value) for value in bounds],
)
def decode_text_bytes(content: bytes) -> str:
for encoding in ("utf-8-sig", "utf-8", "gb18030"):
try:
return content.decode(encoding)
except UnicodeDecodeError:
continue
return content.decode("latin-1")
@@ -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)
@@ -0,0 +1,669 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import time
from typing import Sequence
import zipfile
import asf_search as asf
from asf_search.download.download import strip_auth_if_aws
from sentinel_orbit_downloader.services.asf_client import build_session, credentials_available, scene_name_from_product
from sentinel_orbit_downloader.services.orbit_client import download_orbits
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
@dataclass(frozen=True)
class ProductDownloadResult:
scene_id: str
status: str
path: Path | None
url: str | None
bytes_total: float | None
bytes_done: float | None
error_message: str | None = None
def resolve_download_paths(db_path: Path, request: DownloadJobRequest) -> DownloadJobPaths:
job = get_job(db_path, request.job_id)
if job is None:
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 = select_data_products(results, scene_ids, job.processing_level)
ordered_products = [products[scene_id] for scene_id in scene_ids if scene_id in products]
missing_scene_ids = [scene_id for scene_id in scene_ids if scene_id not in products]
if missing_scene_ids:
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)
session = build_session(username=request.username, password=request.password)
product_attempts = max(1, min(5, request.processes))
product_results: list[ProductDownloadResult] = []
for index, product in enumerate(ordered_products, start=1):
scene_id = scene_name_from_product(product) or f"scene #{index}"
record_job_event(
db_path,
request.job_id,
"info",
f"Product download {index}/{len(ordered_products)} started: {scene_id}",
)
result = download_product_with_retries(
product,
paths.data_dir,
session,
attempts=product_attempts,
)
product_results.append(result)
record_product_download_result(db_path, request.job_id, result)
if result.status == "succeeded":
record_job_event(
db_path,
request.job_id,
"info",
f"Product download {index}/{len(ordered_products)} succeeded: {scene_id}",
)
else:
record_job_event(
db_path,
request.job_id,
"warning",
f"Product download {index}/{len(ordered_products)} failed: {scene_id}; {result.error_message}",
)
product_failures = sum(1 for result in product_results if result.status == "failed") + len(
missing_scene_ids
)
product_successes = sum(1 for result in product_results if result.status == "succeeded")
if product_failures:
product_error = f"Product download failed for {product_failures} of {len(scene_ids)} scene(s)"
record_job_event(
db_path,
request.job_id,
"info",
f"Product download finished: {product_successes} ok, {product_failures} failed",
)
orbit_failures = 0
if request.skip_orbits:
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")
product_successes = count_download_status(db_path, request.job_id, "product", "succeeded")
effective_orbit_failures = 0 if request.ignore_orbit_errors else orbit_failures
final_status = "succeeded"
if product_failures or effective_orbit_failures:
final_status = "partial_failed"
if not request.skip_data and product_failures and product_successes == 0:
final_status = "failed"
elif request.skip_data and not request.skip_orbits and effective_orbit_failures == len(scene_ids):
final_status = "failed"
error_message = None
if final_status != "succeeded":
error_message = summarize_failure(
product_failures=product_failures if not request.skip_data else 0,
orbit_failures=effective_orbit_failures if not request.skip_orbits else 0,
scene_count=len(scene_ids),
fallback=product_error,
)
update_search_job(
db_path,
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=error_message,
)
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_download_result(db_path: Path, job_id: int, result: ProductDownloadResult) -> None:
record_download_result(
db_path,
job_id=job_id,
scene_id=result.scene_id,
kind="product",
status=result.status,
path=str(result.path) if result.path is not None else None,
url=result.url,
bytes_total=result.bytes_total,
bytes_done=result.bytes_done,
error_message=result.error_message,
)
def record_product_skips(db_path: Path, job_id: int, scene_ids: Sequence[str]) -> None:
for scene_id in scene_ids:
record_download_result(
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 select_data_products(results, scene_ids: Sequence[str], processing_level: str) -> dict[str, object]:
wanted = set(scene_ids)
selected: dict[str, object] = {}
selected_rank: dict[str, int] = {}
for product in results:
scene_id = scene_name_from_product(product)
if scene_id not in wanted:
continue
rank = data_product_rank(product, processing_level)
if rank is None:
continue
current_rank = selected_rank.get(scene_id)
if current_rank is None or rank < current_rank:
selected[scene_id] = product
selected_rank[scene_id] = rank
return selected
def data_product_rank(product, processing_level: str) -> int | None:
props = product.properties
product_level = string_value(props.get("processingLevel"))
file_name = string_value(props.get("fileName")) or ""
url = string_value(props.get("url")) or ""
lower_name = file_name.lower()
lower_url = url.lower()
if product_level and product_level.upper().startswith("METADATA"):
return None
if lower_name.endswith(".iso.xml") or lower_url.endswith(".iso.xml"):
return None
if processing_level and product_level and product_level.upper() == processing_level.upper():
return 0
if lower_name.endswith(".zip") or lower_url.endswith(".zip"):
return 1
return None
def download_product_with_retries(product, data_dir: Path, session, attempts: int = 3) -> ProductDownloadResult:
scene_id = scene_name_from_product(product) or "unknown_scene"
props = product.properties
expected = expected_product_path(product, data_dir)
bytes_total = float(props["bytes"]) if props.get("bytes") is not None else None
expected_size = int(props["bytes"]) if props.get("bytes") is not None else None
url = product_download_url(product)
if file_complete(expected, expected_size):
return ProductDownloadResult(
scene_id=scene_id,
status="succeeded",
path=expected,
url=url,
bytes_total=bytes_total,
bytes_done=float(expected.stat().st_size),
)
last_error = "Downloaded file not found at expected path"
for attempt in range(1, max(1, attempts) + 1):
try:
if url is None:
raise ValueError("Product has no downloadable URL")
download_product_file(url, expected, session, expected_size)
if file_complete(expected, expected_size):
return ProductDownloadResult(
scene_id=scene_id,
status="succeeded",
path=expected,
url=url,
bytes_total=bytes_total,
bytes_done=float(expected.stat().st_size),
)
if expected.exists():
last_error = (
f"Downloaded file size mismatch: got {expected.stat().st_size}, expected {expected_size}"
)
else:
last_error = "Downloaded file not found at expected path"
except Exception as exc:
last_error = str(exc) or exc.__class__.__name__
if attempt < attempts:
time.sleep(min(15, attempt * 3))
part_path = partial_product_path(expected)
return ProductDownloadResult(
scene_id=scene_id,
status="failed",
path=None,
url=url,
bytes_total=bytes_total,
bytes_done=partial_size(expected, part_path),
error_message=last_error,
)
def product_download_url(product) -> str | None:
try:
urls = product.get_urls()
except Exception:
urls = []
if urls:
return str(urls[0])
return string_value(product.properties.get("url"))
def download_product_file(url: str, final_path: Path, session, expected_size: int | None) -> None:
final_path.parent.mkdir(parents=True, exist_ok=True)
if file_complete(final_path, expected_size):
return
part_path = prepare_partial_file(final_path, expected_size)
if validate_product_file(part_path, expected_size) is None:
part_path.replace(final_path)
return
resume_from = part_path.stat().st_size if part_path.exists() else 0
headers = {"Range": f"bytes={resume_from}-"} if resume_from else {}
response = session.get(
url,
stream=True,
headers=headers,
hooks={"response": strip_auth_if_aws},
timeout=(30, 120),
)
try:
response.raise_for_status()
if resume_from and response.status_code != 206:
part_path.unlink(missing_ok=True)
resume_from = 0
elif resume_from:
validate_content_range(response.headers.get("Content-Range"), resume_from)
mode = "ab" if resume_from else "wb"
with part_path.open(mode) as handle:
for chunk in response.iter_content(chunk_size=1024 * 1024):
if chunk:
handle.write(chunk)
finally:
response.close()
validation_error = validate_product_file(part_path, expected_size)
if validation_error is not None:
raise IOError(validation_error)
part_path.replace(final_path)
def prepare_partial_file(final_path: Path, expected_size: int | None) -> Path:
part_path = partial_product_path(final_path)
if final_path.exists() and not file_complete(final_path, expected_size):
final_size = final_path.stat().st_size
if expected_size is not None and final_size < expected_size and not part_path.exists():
final_path.replace(part_path)
else:
final_path.unlink()
if part_path.exists() and expected_size is not None:
part_size = part_path.stat().st_size
if part_size > expected_size or (
part_size == expected_size and validate_product_file(part_path, expected_size)
):
part_path.unlink()
return part_path
def partial_product_path(final_path: Path) -> Path:
return final_path.with_name(f"{final_path.name}.part")
def file_complete(path: Path, expected_size: int | None) -> bool:
return validate_product_file(path, expected_size) is None
def validate_product_file(path: Path, expected_size: int | None) -> str | None:
if not path.exists() or not path.is_file():
return "Downloaded file not found at expected path"
actual_size = path.stat().st_size
if expected_size is not None and actual_size != expected_size:
return f"Downloaded file size mismatch: got {actual_size}, expected {expected_size}"
lower_name = path.name.lower()
if (lower_name.endswith(".zip") or lower_name.endswith(".zip.part")) and not zipfile.is_zipfile(path):
return "Downloaded ZIP failed integrity check"
return None
def validate_content_range(content_range: str | None, expected_start: int) -> None:
if not content_range:
return
prefix = f"bytes {expected_start}-"
if not content_range.startswith(prefix):
raise IOError(f"Unexpected resume range from server: {content_range}")
def partial_size(final_path: Path, part_path: Path) -> float | None:
if final_path.exists():
return float(final_path.stat().st_size)
if part_path.exists():
return float(part_path.stat().st_size)
return None
def string_value(value) -> str | None:
if value is None:
return None
return str(value)
def count_download_failures(db_path: Path, job_id: int, kind: str) -> int:
return count_download_status(db_path, job_id, kind, "failed")
def count_download_status(db_path: Path, job_id: int, kind: str, status: str) -> int:
from sentinel_orbit_downloader.storage.db import connect
if kind == "product":
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 status_count
FROM job_scenes
WHERE job_id = ? AND {column} = ?
""",
(job_id, status),
).fetchone()
return int(row["status_count"])
def summarize_failure(
*,
product_failures: int,
orbit_failures: int,
scene_count: int,
fallback: str | None,
) -> str:
parts = []
if product_failures:
parts.append(f"Product failed for {product_failures}/{scene_count} scene(s)")
if orbit_failures:
parts.append(f"Orbit failed for {orbit_failures}/{scene_count} scene(s)")
if parts:
return "; ".join(parts)
return fallback or "Download finished with failures"
@@ -0,0 +1,94 @@
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))
except Exception as exc:
message = str(exc) or exc.__class__.__name__
results.append(OrbitResult(scene=scene, path=None, error=message))
return results
@@ -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
)
"""
)
@@ -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"]),
)
@@ -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)
@@ -0,0 +1 @@
"""Storage layer."""
@@ -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"])
@@ -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;
}
}
@@ -0,0 +1,950 @@
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),
});
},
async uploadSearchAoi(files) {
const formData = new FormData();
for (const file of files) {
formData.append("files", file);
}
const response = await fetch("/api/search-jobs/upload-aoi", {
method: "POST",
body: formData,
});
if (!response.ok) {
let detail = response.statusText;
try {
const payload = await response.json();
detail = payload.detail || JSON.stringify(payload);
} catch (_) {}
throw new Error(detail);
}
return response.json();
},
createDownloadJob(payload) {
return this.request("/api/download-jobs", {
method: "POST",
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,
uploadedAoi: 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"),
aoiMode: document.getElementById("aoiMode"),
uploadAoiFiles: document.getElementById("uploadAoiFiles"),
uploadAoiSummary: document.getElementById("uploadAoiSummary"),
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 = `<div class="empty">${escapeHtml(text)}</div>`;
}
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 currentAoiMode() {
return el.aoiMode.value || "region";
}
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 = `
<div class="item-title">${escapeHtml(project.name)}</div>
<div class="item-subtle mono">${escapeHtml(project.data_root)}</div>
<div class="item-subtle">${escapeHtml(project.slug)}</div>
`;
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 = `
<div class="item-title">${escapeHtml(region.label)}</div>
<div class="item-subtle mono">${escapeHtml(region.adcode)}</div>
<div class="item-subtle">${escapeHtml(region.level)} | ${escapeHtml(formatBBox(region.bbox))}</div>
`;
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 = '<div class="empty">未选择行政区</div>';
return;
}
el.regionDetailCard.innerHTML = `
<div class="stack">
<div class="chip-row">
<span class="status-pill succeeded">${escapeHtml(region.level)}</span>
<span class="chip mono">${escapeHtml(region.adcode)}</span>
</div>
<div class="detail-grid">
<div class="detail-row">
<div class="detail-label">行政区</div>
<div class="detail-value">${escapeHtml(region.label)}</div>
</div>
<div class="detail-row">
<div class="detail-label">简化容差</div>
<div class="detail-value">${escapeHtml(String(region.simplify_tolerance))}</div>
</div>
<div class="detail-row">
<div class="detail-label">范围框</div>
<div class="detail-value mono">${escapeHtml(formatBBox(region.bbox))}</div>
</div>
<div class="detail-row">
<div class="detail-label">中心点</div>
<div class="detail-value mono">${escapeHtml(formatPoint(region.center))}</div>
</div>
</div>
</div>
`;
}
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 renderUploadedAoiSummary() {
el.uploadAoiSummary.innerHTML = "";
const uploadedAoi = state.uploadedAoi;
if (!uploadedAoi) {
el.uploadAoiSummary.appendChild(makeChip("上传 AOI:未解析"));
return;
}
el.uploadAoiSummary.appendChild(makeChip(`AOI ${uploadedAoi.name}`));
el.uploadAoiSummary.appendChild(makeChip(`格式 ${uploadedAoi.source_format}`));
el.uploadAoiSummary.appendChild(makeChip(`几何 ${uploadedAoi.geometry_type}`));
el.uploadAoiSummary.appendChild(makeChip(`要素 ${uploadedAoi.feature_count}`));
if (Array.isArray(uploadedAoi.bbox) && uploadedAoi.bbox.length === 4) {
el.uploadAoiSummary.appendChild(makeChip(`范围 ${formatBBox(uploadedAoi.bbox)}`));
}
}
function renderJobFilterSummary() {
el.jobFilterSummary.innerHTML = "";
const project = currentProject();
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 = '<tr><td colspan="7" class="empty">暂无场景</td></tr>';
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 = '<tr><td colspan="7" class="empty">暂无场景</td></tr>';
} 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 = `
<div class="event-title">${escapeHtml(event.level)}</div>
<div class="event-meta">${escapeHtml(event.created_at)}</div>
<div class="event-message">${escapeHtml(event.message)}</div>
`;
el.eventList.appendChild(div);
}
}
syncPolling();
}
function updateSelectionSummary() {
el.selectionSummary.innerHTML = "";
const project = currentProject();
if (project) {
el.selectionSummary.appendChild(makeChip(`项目 ${project.name}`));
}
if (currentAoiMode() === "upload") {
el.selectionSummary.appendChild(makeChip("AOI 来源 上传文件"));
if (state.uploadedAoi) {
el.selectionSummary.appendChild(makeChip(`上传 AOI ${state.uploadedAoi.name}`));
}
} else {
el.selectionSummary.appendChild(makeChip("AOI 来源 行政区"));
if (state.selectedRegion) {
el.selectionSummary.appendChild(makeChip(`行政区 ${state.selectedRegion.label}`));
}
}
el.selectionSummary.appendChild(makeChip(`时间 ${el.startDate.value} - ${el.endDate.value}`));
el.selectionSummary.appendChild(makeChip(`产品 ${el.processingLevel.value}`));
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 uploadAoi() {
const files = Array.from(el.uploadAoiFiles.files || []);
if (!files.length) {
throw new Error("请先选择 AOI 文件");
}
state.uploadedAoi = await api.uploadSearchAoi(files);
renderUploadedAoiSummary();
updateSelectionSummary();
setMessage(`AOI 已解析:${state.uploadedAoi.name}`, "success");
}
function clearUploadedAoi() {
state.uploadedAoi = null;
el.uploadAoiFiles.value = "";
renderUploadedAoiSummary();
updateSelectionSummary();
setMessage("上传 AOI 已清空", "info");
}
async function importCatalog() {
const payload = {
source_path: el.catalogPath.value.trim() || DEFAULT_CATALOG_PATH,
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("请先选择项目");
}
const payload = {
project_id: state.selectedProjectId,
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),
};
if (currentAoiMode() === "upload") {
if (!state.uploadedAoi) {
throw new Error("请先上传并解析 AOI 文件");
}
payload.wkt = state.uploadedAoi.wkt;
payload.source_name = state.uploadedAoi.name;
payload.raw_region = false;
} else {
if (!state.selectedRegion) {
throw new Error("请先选择行政区");
}
payload.region_adcode = state.selectedRegion.adcode;
payload.raw_region = el.rawRegion.checked;
}
const response = await api.createSearchJob(payload);
state.selectedJobId = response.job.id;
state.selectedJobDetail = {
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("uploadAoiBtn").addEventListener("click", runAction(uploadAoi, "正在解析上传 AOI"));
document.getElementById("clearAoiBtn").addEventListener("click", clearUploadedAoi);
document.getElementById("aoiMode").addEventListener("change", () => {
updateSelectionSummary();
});
document.getElementById("clearRegionsBtn").addEventListener("click", () => {
state.regions = [];
state.selectedRegion = null;
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;
}
renderUploadedAoiSummary();
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");
});
@@ -0,0 +1,292 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sentinel Orbit Downloader</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="shell">
<aside class="sidebar">
<div class="brand">
<h1>Sentinel Orbit Downloader</h1>
<p>本地查询、下载与精密轨道管理</p>
</div>
<section class="panel stack">
<div class="section-head">
<h2>环境配置</h2>
</div>
<div class="status-block">
<span class="status-dot" id="healthDot"></span>
<div>
<div class="status-title" id="healthLabel">正在连接服务</div>
<div class="status-subtle mono" id="healthDetail">http://127.0.0.1:8011</div>
</div>
</div>
<div class="field">
<label for="catalogPath">行政区 GeoJSON</label>
<input id="catalogPath" value="">
</div>
<div class="field inline-field">
<label for="simplifyTolerance">简化容差</label>
<input id="simplifyTolerance" type="number" min="0.0001" step="0.001" value="0.01">
</div>
<div class="toolbar">
<button class="primary" id="importCatalogBtn" type="button">导入行政区</button>
</div>
</section>
<section class="panel stack">
<div class="section-head">
<h2>项目</h2>
<button class="ghost" id="refreshProjectsBtn" type="button">刷新</button>
</div>
<div class="field">
<label for="projectName">项目名称</label>
<input id="projectName" value="牡丹江 Sentinel-1 数据">
</div>
<div class="field">
<label for="projectPath">数据根目录</label>
<input id="projectPath" value=".local-data\\mudanjiang">
</div>
<div class="field">
<label for="projectDesc">说明</label>
<textarea id="projectDesc">牡丹江 Sentinel-1 场景、精密轨道与任务记录。</textarea>
</div>
<div class="toolbar">
<button class="primary" id="createProjectBtn" type="button">新建项目</button>
</div>
<div id="projectList" class="list"></div>
</section>
</aside>
<main class="content">
<header class="topbar">
<div>
<h2>任务工作台</h2>
<p>按行政区和时间检索 Sentinel-1,并统一管理数据与精密轨道下载。</p>
</div>
<div class="message" id="messageBar">等待操作</div>
</header>
<section class="layout two-col">
<section class="panel stack">
<div class="section-head">
<h2>行政区选择</h2>
<div class="toolbar">
<button class="ghost" id="searchRegionsBtn" type="button">查询</button>
<button class="ghost" id="clearRegionsBtn" type="button">清空</button>
</div>
</div>
<div class="field">
<label for="regionKeyword">名称或 adcode</label>
<input id="regionKeyword" value="牡丹江">
</div>
<div id="regionList" class="list tall"></div>
<div class="detail-card" id="regionDetailCard">
<div class="empty">未选择行政区</div>
</div>
</section>
<section class="panel stack">
<div class="section-head">
<h2>检索参数</h2>
<button class="primary" id="runSearchBtn" type="button">发起检索</button>
</div>
<div class="grid two">
<div class="field">
<label for="aoiMode">AOI 来源</label>
<select id="aoiMode">
<option value="region" selected>行政区</option>
<option value="upload">上传 AOI 文件</option>
</select>
</div>
<div class="field">
<label for="uploadAoiFiles">AOI 文件</label>
<input id="uploadAoiFiles" type="file" multiple accept=".geojson,.json,.zip,.shp,.shx,.dbf,.prj,.wkt,.txt">
</div>
</div>
<div class="toolbar">
<button class="ghost" id="uploadAoiBtn" type="button">解析上传 AOI</button>
<button class="ghost" id="clearAoiBtn" type="button">清除上传 AOI</button>
</div>
<div class="chip-row" id="uploadAoiSummary"></div>
<div class="note">
支持 `.geojson`、`.json`、`.wkt`、`.txt`、zip shapefile,或同时选择 `.shp`、`.shx`、`.dbf`、`.prj` 等文件。带 `.prj` 的 shapefile 会自动转为 WGS84,经纬度 GeoJSON 会自动合并成一个 AOI 范围。
</div>
<div class="grid three">
<div class="field">
<label for="startDate">开始日期</label>
<input id="startDate" type="date" value="2024-01-01">
</div>
<div class="field">
<label for="endDate">结束日期</label>
<input id="endDate" type="date" value="2024-03-01">
</div>
<div class="field">
<label for="maxResults">最大结果数</label>
<input id="maxResults" type="number" min="1" value="20">
</div>
</div>
<div class="grid three">
<div class="field">
<label for="processingLevel">产品级别</label>
<select id="processingLevel">
<option value="SLC" selected>SLC</option>
<option value="GRD_HD">GRD_HD</option>
<option value="GRD_MD">GRD_MD</option>
<option value="GRD_MS">GRD_MS</option>
<option value="OCN">OCN</option>
</select>
</div>
<div class="field">
<label for="beamMode">波束模式</label>
<select id="beamMode">
<option value="IW" selected>IW</option>
<option value="EW">EW</option>
<option value="WV">WV</option>
</select>
</div>
<div class="field">
<label for="flightDirection">轨道方向</label>
<select id="flightDirection">
<option value="">不限</option>
<option value="ASCENDING">ASCENDING</option>
<option value="DESCENDING">DESCENDING</option>
</select>
</div>
</div>
<div class="grid three">
<div class="field">
<label for="polarization">极化</label>
<input id="polarization" placeholder="VV+VH">
</div>
<div class="field">
<label for="relativeOrbit">相对轨道号</label>
<input id="relativeOrbit" type="number" min="1" placeholder="可留空">
</div>
<div class="field">
<label for="manifestPath">清单 CSV</label>
<input id="manifestPath" placeholder=".local-data\\mudanjiang\\manifest.csv">
</div>
</div>
<div class="toggle-row">
<label class="toggle">
<input id="rawRegion" type="checkbox">
<span>使用原始行政区边界</span>
</label>
</div>
<div class="chip-row" id="selectionSummary"></div>
</section>
</section>
<section class="layout two-col">
<section class="panel stack">
<div class="section-head">
<h2>下载参数</h2>
<button class="primary" id="queueDownloadBtn" type="button">启动下载</button>
</div>
<div class="grid two">
<div class="field">
<label for="dataDir">数据目录</label>
<input id="dataDir" value=".local-data\\mudanjiang\\data">
</div>
<div class="field">
<label for="orbitDir">轨道目录</label>
<input id="orbitDir" value=".local-data\\mudanjiang\\orbits">
</div>
</div>
<div class="grid three">
<label class="field">
<span>跳过产品</span>
<select id="skipData">
<option value="false" selected>否</option>
<option value="true">是</option>
</select>
</label>
<label class="field">
<span>跳过轨道</span>
<select id="skipOrbits">
<option value="false" selected>否</option>
<option value="true">是</option>
</select>
</label>
<div class="field">
<label for="downloadProcesses">重试次数</label>
<input id="downloadProcesses" type="number" min="1" max="5" value="3">
</div>
</div>
<div class="toggle-row">
<label class="toggle">
<input id="ignoreOrbitErrors" type="checkbox">
<span>忽略轨道失败并允许任务成功结束</span>
</label>
</div>
<div class="grid two">
<div class="field">
<label for="earthdataUser">Earthdata 用户名</label>
<input id="earthdataUser" autocomplete="username" placeholder="可留空">
</div>
<div class="field">
<label for="earthdataPass">Earthdata 密码</label>
<input id="earthdataPass" type="password" autocomplete="current-password" placeholder="可留空">
</div>
</div>
<div class="toolbar">
<button class="ghost" id="saveEarthdataBtn" type="button">保存凭据</button>
<button class="ghost danger" id="clearEarthdataBtn" type="button">清除凭据</button>
</div>
<div class="chip-row" id="earthdataSummary"></div>
<div class="note">
下载原始 Sentinel-1 产品必须提供 Earthdata 用户名和密码。你可以手填后直接下载,也可以先保存到本地配置。产品下载支持完整文件跳过、.part 断点续传和下载后校验。当前阶段只下载原始产品包和匹配的 EOF 轨道文件,暂不包含 GeoTIFF 处理。
</div>
</section>
<section class="panel stack">
<div class="section-head">
<h2>任务</h2>
<div class="toolbar">
<button class="ghost" id="refreshJobsBtn" type="button">刷新任务</button>
<button class="ghost" id="refreshDetailBtn" type="button">刷新详情</button>
<button class="ghost danger" id="deleteJobBtn" type="button">删除选中</button>
<button class="ghost danger" id="clearJobsBtn" type="button">清空当前项目</button>
</div>
</div>
<div class="chip-row" id="jobFilterSummary"></div>
<div id="jobList" class="list tall"></div>
</section>
</section>
<section class="panel stack">
<div class="section-head">
<h2>任务详情</h2>
</div>
<div id="jobMeta" class="chip-row"></div>
<div class="detail-layout">
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width: 32%;">Scene</th>
<th style="width: 16%;">开始时间</th>
<th style="width: 10%;">级别</th>
<th style="width: 10%;">相对轨道</th>
<th style="width: 12%;">产品</th>
<th style="width: 12%;">轨道</th>
<th style="width: 8%;">大小</th>
</tr>
</thead>
<tbody id="sceneTableBody"></tbody>
</table>
</div>
<div class="events" id="eventList"></div>
</div>
</section>
</main>
</div>
<script src="/static/app.js" defer></script>
</body>
</html>
+683
View File
@@ -0,0 +1,683 @@
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 = "pyproj"
version = "3.7.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/90/67bd7260b4ea9b8b20b4f58afef6c223ecb3abf368eb4ec5bc2cdef81b49/pyproj-3.7.2.tar.gz", hash = "sha256:39a0cf1ecc7e282d1d30f36594ebd55c9fae1fda8a2622cee5d100430628f88c", size = 226279, upload-time = "2025-08-14T12:05:42.18Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/bd/f205552cd1713b08f93b09e39a3ec99edef0b3ebbbca67b486fdf1abe2de/pyproj-3.7.2-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:2514d61f24c4e0bb9913e2c51487ecdaeca5f8748d8313c933693416ca41d4d5", size = 6227022, upload-time = "2025-08-14T12:03:51.474Z" },
{ url = "https://files.pythonhosted.org/packages/75/4c/9a937e659b8b418ab573c6d340d27e68716928953273e0837e7922fcac34/pyproj-3.7.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:8693ca3892d82e70de077701ee76dd13d7bca4ae1c9d1e739d72004df015923a", size = 4625810, upload-time = "2025-08-14T12:03:53.808Z" },
{ url = "https://files.pythonhosted.org/packages/c0/7d/a9f41e814dc4d1dc54e95b2ccaf0b3ebe3eb18b1740df05fe334724c3d89/pyproj-3.7.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5e26484d80fea56273ed1555abaea161e9661d81a6c07815d54b8e883d4ceb25", size = 9638694, upload-time = "2025-08-14T12:03:55.669Z" },
{ url = "https://files.pythonhosted.org/packages/ad/ab/9bdb4a6216b712a1f9aab1c0fcbee5d3726f34a366f29c3e8c08a78d6b70/pyproj-3.7.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:281cb92847814e8018010c48b4069ff858a30236638631c1a91dd7bfa68f8a8a", size = 9493977, upload-time = "2025-08-14T12:03:57.937Z" },
{ url = "https://files.pythonhosted.org/packages/c9/db/2db75b1b6190f1137b1c4e8ef6a22e1c338e46320f6329bfac819143e063/pyproj-3.7.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9c8577f0b7bb09118ec2e57e3babdc977127dd66326d6c5d755c76b063e6d9dc", size = 10841151, upload-time = "2025-08-14T12:04:00.271Z" },
{ url = "https://files.pythonhosted.org/packages/89/f7/989643394ba23a286e9b7b3f09981496172f9e0d4512457ffea7dc47ffc7/pyproj-3.7.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a23f59904fac3a5e7364b3aa44d288234af267ca041adb2c2b14a903cd5d3ac5", size = 10751585, upload-time = "2025-08-14T12:04:02.228Z" },
{ url = "https://files.pythonhosted.org/packages/53/6d/ad928fe975a6c14a093c92e6a319ca18f479f3336bb353a740bdba335681/pyproj-3.7.2-cp311-cp311-win32.whl", hash = "sha256:f2af4ed34b2cf3e031a2d85b067a3ecbd38df073c567e04b52fa7a0202afde8a", size = 5908533, upload-time = "2025-08-14T12:04:04.821Z" },
{ url = "https://files.pythonhosted.org/packages/79/e0/b95584605cec9ed50b7ebaf7975d1c4ddeec5a86b7a20554ed8b60042bd7/pyproj-3.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:0b7cb633565129677b2a183c4d807c727d1c736fcb0568a12299383056e67433", size = 6320742, upload-time = "2025-08-14T12:04:06.357Z" },
{ url = "https://files.pythonhosted.org/packages/b7/4d/536e8f93bca808175c2d0a5ac9fdf69b960d8ab6b14f25030dccb07464d7/pyproj-3.7.2-cp311-cp311-win_arm64.whl", hash = "sha256:38b08d85e3a38e455625b80e9eb9f78027c8e2649a21dec4df1f9c3525460c71", size = 6245772, upload-time = "2025-08-14T12:04:08.365Z" },
{ url = "https://files.pythonhosted.org/packages/8d/ab/9893ea9fb066be70ed9074ae543914a618c131ed8dff2da1e08b3a4df4db/pyproj-3.7.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:0a9bb26a6356fb5b033433a6d1b4542158fb71e3c51de49b4c318a1dff3aeaab", size = 6219832, upload-time = "2025-08-14T12:04:10.264Z" },
{ url = "https://files.pythonhosted.org/packages/53/78/4c64199146eed7184eb0e85bedec60a4aa8853b6ffe1ab1f3a8b962e70a0/pyproj-3.7.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:567caa03021178861fad27fabde87500ec6d2ee173dd32f3e2d9871e40eebd68", size = 4620650, upload-time = "2025-08-14T12:04:11.978Z" },
{ url = "https://files.pythonhosted.org/packages/b6/ac/14a78d17943898a93ef4f8c6a9d4169911c994e3161e54a7cedeba9d8dde/pyproj-3.7.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c203101d1dc3c038a56cff0447acc515dd29d6e14811406ac539c21eed422b2a", size = 9667087, upload-time = "2025-08-14T12:04:13.964Z" },
{ url = "https://files.pythonhosted.org/packages/b8/be/212882c450bba74fc8d7d35cbd57e4af84792f0a56194819d98106b075af/pyproj-3.7.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1edc34266c0c23ced85f95a1ee8b47c9035eae6aca5b6b340327250e8e281630", size = 9552797, upload-time = "2025-08-14T12:04:16.624Z" },
{ url = "https://files.pythonhosted.org/packages/ba/c0/c0f25c87b5d2a8686341c53c1792a222a480d6c9caf60311fec12c99ec26/pyproj-3.7.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa9f26c21bc0e2dc3d224cb1eb4020cf23e76af179a7c66fea49b828611e4260", size = 10837036, upload-time = "2025-08-14T12:04:18.733Z" },
{ url = "https://files.pythonhosted.org/packages/5d/37/5cbd6772addde2090c91113332623a86e8c7d583eccb2ad02ea634c4a89f/pyproj-3.7.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9428b318530625cb389b9ddc9c51251e172808a4af79b82809376daaeabe5e9", size = 10775952, upload-time = "2025-08-14T12:04:20.709Z" },
{ url = "https://files.pythonhosted.org/packages/69/a1/dc250e3cf83eb4b3b9a2cf86fdb5e25288bd40037ae449695550f9e96b2f/pyproj-3.7.2-cp312-cp312-win32.whl", hash = "sha256:b3d99ed57d319da042f175f4554fc7038aa4bcecc4ac89e217e350346b742c9d", size = 5898872, upload-time = "2025-08-14T12:04:22.485Z" },
{ url = "https://files.pythonhosted.org/packages/4a/a6/6fe724b72b70f2b00152d77282e14964d60ab092ec225e67c196c9b463e5/pyproj-3.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:11614a054cd86a2ed968a657d00987a86eeb91fdcbd9ad3310478685dc14a128", size = 6312176, upload-time = "2025-08-14T12:04:24.736Z" },
{ url = "https://files.pythonhosted.org/packages/5d/68/915cc32c02a91e76d02c8f55d5a138d6ef9e47a0d96d259df98f4842e558/pyproj-3.7.2-cp312-cp312-win_arm64.whl", hash = "sha256:509a146d1398bafe4f53273398c3bb0b4732535065fa995270e52a9d3676bca3", size = 6233452, upload-time = "2025-08-14T12:04:27.287Z" },
{ url = "https://files.pythonhosted.org/packages/be/14/faf1b90d267cea68d7e70662e7f88cefdb1bc890bd596c74b959e0517a72/pyproj-3.7.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:19466e529b1b15eeefdf8ff26b06fa745856c044f2f77bf0edbae94078c1dfa1", size = 6214580, upload-time = "2025-08-14T12:04:28.804Z" },
{ url = "https://files.pythonhosted.org/packages/35/48/da9a45b184d375f62667f62eba0ca68569b0bd980a0bb7ffcc1d50440520/pyproj-3.7.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:c79b9b84c4a626c5dc324c0d666be0bfcebd99f7538d66e8898c2444221b3da7", size = 4615388, upload-time = "2025-08-14T12:04:30.553Z" },
{ url = "https://files.pythonhosted.org/packages/5e/e7/d2b459a4a64bca328b712c1b544e109df88e5c800f7c143cfbc404d39bfb/pyproj-3.7.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ceecf374cacca317bc09e165db38ac548ee3cad07c3609442bd70311c59c21aa", size = 9628455, upload-time = "2025-08-14T12:04:32.435Z" },
{ url = "https://files.pythonhosted.org/packages/f8/85/c2b1706e51942de19076eff082f8495e57d5151364e78b5bef4af4a1d94a/pyproj-3.7.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5141a538ffdbe4bfd157421828bb2e07123a90a7a2d6f30fa1462abcfb5ce681", size = 9514269, upload-time = "2025-08-14T12:04:34.599Z" },
{ url = "https://files.pythonhosted.org/packages/34/38/07a9b89ae7467872f9a476883a5bad9e4f4d1219d31060f0f2b282276cbe/pyproj-3.7.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f000841e98ea99acbb7b8ca168d67773b0191de95187228a16110245c5d954d5", size = 10808437, upload-time = "2025-08-14T12:04:36.485Z" },
{ url = "https://files.pythonhosted.org/packages/12/56/fda1daeabbd39dec5b07f67233d09f31facb762587b498e6fc4572be9837/pyproj-3.7.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8115faf2597f281a42ab608ceac346b4eb1383d3b45ab474fd37341c4bf82a67", size = 10745540, upload-time = "2025-08-14T12:04:38.568Z" },
{ url = "https://files.pythonhosted.org/packages/0d/90/c793182cbba65a39a11db2ac6b479fe76c59e6509ae75e5744c344a0da9d/pyproj-3.7.2-cp313-cp313-win32.whl", hash = "sha256:f18c0579dd6be00b970cb1a6719197fceecc407515bab37da0066f0184aafdf3", size = 5896506, upload-time = "2025-08-14T12:04:41.059Z" },
{ url = "https://files.pythonhosted.org/packages/be/0f/747974129cf0d800906f81cd25efd098c96509026e454d4b66868779ab04/pyproj-3.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:bb41c29d5f60854b1075853fe80c58950b398d4ebb404eb532536ac8d2834ed7", size = 6310195, upload-time = "2025-08-14T12:04:42.974Z" },
{ url = "https://files.pythonhosted.org/packages/82/64/fc7598a53172c4931ec6edf5228280663063150625d3f6423b4c20f9daff/pyproj-3.7.2-cp313-cp313-win_arm64.whl", hash = "sha256:2b617d573be4118c11cd96b8891a0b7f65778fa7733ed8ecdb297a447d439100", size = 6230748, upload-time = "2025-08-14T12:04:44.491Z" },
{ url = "https://files.pythonhosted.org/packages/aa/f0/611dd5cddb0d277f94b7af12981f56e1441bf8d22695065d4f0df5218498/pyproj-3.7.2-cp313-cp313t-macosx_13_0_x86_64.whl", hash = "sha256:d27b48f0e81beeaa2b4d60c516c3a1cfbb0c7ff6ef71256d8e9c07792f735279", size = 6241729, upload-time = "2025-08-14T12:04:46.274Z" },
{ url = "https://files.pythonhosted.org/packages/15/93/40bd4a6c523ff9965e480870611aed7eda5aa2c6128c6537345a2b77b542/pyproj-3.7.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:55a3610d75023c7b1c6e583e48ef8f62918e85a2ae81300569d9f104d6684bb6", size = 4652497, upload-time = "2025-08-14T12:04:48.203Z" },
{ url = "https://files.pythonhosted.org/packages/1b/ae/7150ead53c117880b35e0d37960d3138fe640a235feb9605cb9386f50bb0/pyproj-3.7.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:8d7349182fa622696787cc9e195508d2a41a64765da9b8a6bee846702b9e6220", size = 9942610, upload-time = "2025-08-14T12:04:49.652Z" },
{ url = "https://files.pythonhosted.org/packages/d8/17/7a4a7eafecf2b46ab64e5c08176c20ceb5844b503eaa551bf12ccac77322/pyproj-3.7.2-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:d230b186eb876ed4f29a7c5ee310144c3a0e44e89e55f65fb3607e13f6db337c", size = 9692390, upload-time = "2025-08-14T12:04:51.731Z" },
{ url = "https://files.pythonhosted.org/packages/c3/55/ae18f040f6410f0ea547a21ada7ef3e26e6c82befa125b303b02759c0e9d/pyproj-3.7.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:237499c7862c578d0369e2b8ac56eec550e391a025ff70e2af8417139dabb41c", size = 11047596, upload-time = "2025-08-14T12:04:53.748Z" },
{ url = "https://files.pythonhosted.org/packages/e6/2e/d3fff4d2909473f26ae799f9dda04caa322c417a51ff3b25763f7d03b233/pyproj-3.7.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8c225f5978abd506fd9a78eaaf794435e823c9156091cabaab5374efb29d7f69", size = 10896975, upload-time = "2025-08-14T12:04:55.875Z" },
{ url = "https://files.pythonhosted.org/packages/f2/bc/8fc7d3963d87057b7b51ebe68c1e7c51c23129eee5072ba6b86558544a46/pyproj-3.7.2-cp313-cp313t-win32.whl", hash = "sha256:2da731876d27639ff9d2d81c151f6ab90a1546455fabd93368e753047be344a2", size = 5953057, upload-time = "2025-08-14T12:04:58.466Z" },
{ url = "https://files.pythonhosted.org/packages/cc/27/ea9809966cc47d2d51e6d5ae631ea895f7c7c7b9b3c29718f900a8f7d197/pyproj-3.7.2-cp313-cp313t-win_amd64.whl", hash = "sha256:f54d91ae18dd23b6c0ab48126d446820e725419da10617d86a1b69ada6d881d3", size = 6375414, upload-time = "2025-08-14T12:04:59.861Z" },
{ url = "https://files.pythonhosted.org/packages/5b/f8/1ef0129fba9a555c658e22af68989f35e7ba7b9136f25758809efec0cd6e/pyproj-3.7.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fc52ba896cfc3214dc9f9ca3c0677a623e8fdd096b257c14a31e719d21ff3fdd", size = 6262501, upload-time = "2025-08-14T12:05:01.39Z" },
]
[[package]]
name = "pyshp"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/20/8b07bae73aaa0c3f5a2683ba6e23b46e977e2d33a88126d56bbcc2d135cd/pyshp-3.0.3.tar.gz", hash = "sha256:bf4678b13dd53578ed87669676a2fffeccbcded1ec8ff9cafb36d1b660f4b305", size = 2192568, upload-time = "2025-11-28T17:47:31.616Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/06/cad54e8ce758bd836ee5411691cbd49efeb9cc611b374670fce299519334/pyshp-3.0.3-py3-none-any.whl", hash = "sha256:28c8fac8c0c25bb0fecbbfd10ead7f319c2ff2f3b0b44a94f22bd2c93510ad42", size = 58465, upload-time = "2025-11-28T17:47:30.328Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
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 = "python-multipart"
version = "0.0.27"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" },
]
[[package]]
name = "pytz"
version = "2026.1.post1"
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 = "pyproj" },
{ name = "pyshp" },
{ name = "python-multipart" },
{ 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 = "pyproj", specifier = ">=3.7.0" },
{ name = "pyshp", specifier = ">=2.3.1" },
{ name = "python-multipart", specifier = ">=0.0.20" },
{ name = "s1-orbits", specifier = ">=0.2.0" },
{ name = "shapely", specifier = ">=2.1.2" },
{ name = "uvicorn", specifier = ">=0.35.0" },
]
[[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" },
]