Initial project implementation
This commit is contained in:
+23
@@ -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
|
||||
@@ -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,233 @@
|
||||
param(
|
||||
[int[]]$PreferredPorts = @(8011, 8010, 8012, 8013, 8014),
|
||||
[switch]$NoBrowser,
|
||||
[switch]$ForceRestart
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Write-Info {
|
||||
param([string]$Message)
|
||||
Write-Host "[launcher] $Message"
|
||||
}
|
||||
|
||||
function Get-UvPath {
|
||||
$command = Get-Command uv -ErrorAction SilentlyContinue
|
||||
if ($command -and $command.Source) {
|
||||
return $command.Source
|
||||
}
|
||||
|
||||
$fallback = Join-Path $HOME ".local\bin\uv.exe"
|
||||
if (Test-Path -LiteralPath $fallback) {
|
||||
return $fallback
|
||||
}
|
||||
|
||||
throw "uv.exe was not found. Install uv first or add it to PATH."
|
||||
}
|
||||
|
||||
function Test-Health {
|
||||
param([int]$Port)
|
||||
|
||||
try {
|
||||
$null = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/api/health" -TimeoutSec 2
|
||||
return $true
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Get-FreePort {
|
||||
param([int[]]$Candidates)
|
||||
|
||||
foreach ($port in $Candidates) {
|
||||
$listener = $null
|
||||
try {
|
||||
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $port)
|
||||
$listener.Start()
|
||||
return $port
|
||||
} catch {
|
||||
continue
|
||||
} finally {
|
||||
if ($listener) {
|
||||
$listener.Stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw "No free port was found in: $($Candidates -join ', ')"
|
||||
}
|
||||
|
||||
function Get-RunningProcess {
|
||||
param([int]$Id)
|
||||
|
||||
if ($Id -le 0) {
|
||||
return $null
|
||||
}
|
||||
|
||||
try {
|
||||
return Get-Process -Id $Id -ErrorAction Stop
|
||||
} catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
function Load-State {
|
||||
param([string]$Path)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
try {
|
||||
return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
|
||||
} catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
function Save-State {
|
||||
param(
|
||||
[string]$Path,
|
||||
[int]$ProcessId,
|
||||
[int]$Port,
|
||||
[string]$Url
|
||||
)
|
||||
|
||||
$payload = [ordered]@{
|
||||
pid = $ProcessId
|
||||
port = $Port
|
||||
url = $Url
|
||||
started_at = (Get-Date).ToString("s")
|
||||
}
|
||||
|
||||
$payload | ConvertTo-Json | Set-Content -LiteralPath $Path -Encoding UTF8
|
||||
}
|
||||
|
||||
function Remove-State {
|
||||
param([string]$Path)
|
||||
|
||||
if (Test-Path -LiteralPath $Path) {
|
||||
Remove-Item -LiteralPath $Path -Force
|
||||
}
|
||||
}
|
||||
|
||||
$projectRoot = Split-Path -Parent $PSCommandPath
|
||||
$runtimeDir = Join-Path $projectRoot ".s1dl"
|
||||
$statePath = Join-Path $runtimeDir "web-ui-state.json"
|
||||
$stdoutPath = Join-Path $runtimeDir "web-ui.stdout.log"
|
||||
$stderrPath = Join-Path $runtimeDir "web-ui.stderr.log"
|
||||
|
||||
New-Item -ItemType Directory -Path $runtimeDir -Force | Out-Null
|
||||
|
||||
$state = Load-State -Path $statePath
|
||||
|
||||
if ($state -and -not $ForceRestart) {
|
||||
$existingProcess = Get-RunningProcess -Id ([int]$state.pid)
|
||||
if ($existingProcess -and (Test-Health -Port ([int]$state.port))) {
|
||||
$existingUrl = "http://127.0.0.1:$($state.port)/"
|
||||
Write-Info "UI is already running at $existingUrl"
|
||||
if (-not $NoBrowser) {
|
||||
Start-Process $existingUrl | Out-Null
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($port in $PreferredPorts) {
|
||||
if (Test-Health -Port $port) {
|
||||
$url = "http://127.0.0.1:$port/"
|
||||
Write-Info "Found an existing running UI at $url"
|
||||
Save-State -Path $statePath -ProcessId 0 -Port $port -Url $url
|
||||
if (-not $NoBrowser) {
|
||||
Start-Process $url | Out-Null
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
if ($ForceRestart -and $state) {
|
||||
$oldProcess = Get-RunningProcess -Id ([int]$state.pid)
|
||||
if ($oldProcess) {
|
||||
Write-Info "Stopping previous tracked process $($oldProcess.Id)"
|
||||
Stop-Process -Id $oldProcess.Id -Force
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
Remove-State -Path $statePath
|
||||
}
|
||||
|
||||
$uvPath = Get-UvPath
|
||||
Write-Info "Using uv at $uvPath"
|
||||
Write-Info "Syncing project environment"
|
||||
& $uvPath sync --directory $projectRoot
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "uv sync failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
$pythonPath = Join-Path $projectRoot ".venv\Scripts\python.exe"
|
||||
if (-not (Test-Path -LiteralPath $pythonPath)) {
|
||||
throw "Python executable was not found at $pythonPath"
|
||||
}
|
||||
|
||||
$portToUse = Get-FreePort -Candidates $PreferredPorts
|
||||
$url = "http://127.0.0.1:$portToUse/"
|
||||
|
||||
if (Test-Path -LiteralPath $stdoutPath) {
|
||||
Remove-Item -LiteralPath $stdoutPath -Force
|
||||
}
|
||||
if (Test-Path -LiteralPath $stderrPath) {
|
||||
Remove-Item -LiteralPath $stderrPath -Force
|
||||
}
|
||||
|
||||
$arguments = @(
|
||||
"-m",
|
||||
"uvicorn",
|
||||
"sentinel_orbit_downloader.api.app:app",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"$portToUse"
|
||||
)
|
||||
|
||||
Write-Info "Starting UI at $url"
|
||||
$process = Start-Process `
|
||||
-FilePath $pythonPath `
|
||||
-ArgumentList $arguments `
|
||||
-WorkingDirectory $projectRoot `
|
||||
-WindowStyle Hidden `
|
||||
-RedirectStandardOutput $stdoutPath `
|
||||
-RedirectStandardError $stderrPath `
|
||||
-PassThru
|
||||
|
||||
$started = $false
|
||||
for ($i = 0; $i -lt 30; $i++) {
|
||||
Start-Sleep -Seconds 1
|
||||
if (Test-Health -Port $portToUse) {
|
||||
$started = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $started) {
|
||||
$running = Get-RunningProcess -Id $process.Id
|
||||
if ($running) {
|
||||
Stop-Process -Id $running.Id -Force
|
||||
}
|
||||
|
||||
$stderrTail = ""
|
||||
if (Test-Path -LiteralPath $stderrPath) {
|
||||
$stderrTail = (Get-Content -LiteralPath $stderrPath -Tail 20) -join [Environment]::NewLine
|
||||
}
|
||||
|
||||
if ($stderrTail) {
|
||||
throw "UI failed to start.`n$stderrTail"
|
||||
}
|
||||
|
||||
throw "UI failed to start."
|
||||
}
|
||||
|
||||
Save-State -Path $statePath -ProcessId $process.Id -Port $portToUse -Url $url
|
||||
Write-Info "UI is ready: $url"
|
||||
|
||||
if (-not $NoBrowser) {
|
||||
Start-Process $url | Out-Null
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Stop-SentinelUI.ps1"
|
||||
if errorlevel 1 pause
|
||||
endlocal
|
||||
@@ -0,0 +1,43 @@
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Write-Info {
|
||||
param([string]$Message)
|
||||
Write-Host "[launcher] $Message"
|
||||
}
|
||||
|
||||
function Get-RunningProcess {
|
||||
param([int]$Id)
|
||||
|
||||
if ($Id -le 0) {
|
||||
return $null
|
||||
}
|
||||
|
||||
try {
|
||||
return Get-Process -Id $Id -ErrorAction Stop
|
||||
} catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
$projectRoot = Split-Path -Parent $PSCommandPath
|
||||
$statePath = Join-Path $projectRoot ".s1dl\web-ui-state.json"
|
||||
|
||||
if (-not (Test-Path -LiteralPath $statePath)) {
|
||||
Write-Info "No tracked UI process was found."
|
||||
exit 0
|
||||
}
|
||||
|
||||
$state = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json
|
||||
$process = Get-RunningProcess -Id ([int]$state.pid)
|
||||
|
||||
if ($process) {
|
||||
Write-Info "Stopping UI process $($process.Id)"
|
||||
Stop-Process -Id $process.Id -Force
|
||||
} else {
|
||||
Write-Info "Tracked process is not running."
|
||||
}
|
||||
|
||||
Remove-Item -LiteralPath $statePath -Force
|
||||
Write-Info "Done."
|
||||
@@ -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。
|
||||
- 场景覆盖图层和轨道号统计。
|
||||
- 下载完成后的数据质量检查。
|
||||
@@ -0,0 +1,25 @@
|
||||
[project]
|
||||
name = "sentinel-orbit-downloader"
|
||||
version = "0.1.0"
|
||||
description = "Query and download Sentinel-1 scenes and precise orbit files"
|
||||
readme = "README.md"
|
||||
authors = [
|
||||
{ name = "Harmon", email = "rs_bhm@163.com" }
|
||||
]
|
||||
requires-python = ">=3.11,<3.14"
|
||||
dependencies = [
|
||||
"asf-search>=12.0.7",
|
||||
"fastapi>=0.116.1",
|
||||
"s1-orbits>=0.2.0",
|
||||
"shapely>=2.1.2",
|
||||
"uvicorn>=0.35.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
sentinel-orbit-downloader = "sentinel_orbit_downloader:main"
|
||||
s1-dl = "sentinel_orbit_downloader:main"
|
||||
s1-web = "sentinel_orbit_downloader.api.app:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.11.8,<0.12.0"]
|
||||
build-backend = "uv_build"
|
||||
@@ -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,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from sentinel_orbit_downloader.api.routes_downloads import router as downloads_router
|
||||
from sentinel_orbit_downloader.api.routes_health import router as health_router
|
||||
from sentinel_orbit_downloader.api.routes_jobs import router as jobs_router
|
||||
from sentinel_orbit_downloader.api.routes_projects import router as projects_router
|
||||
from sentinel_orbit_downloader.api.routes_regions import router as regions_router
|
||||
from sentinel_orbit_downloader.api.routes_search import router as search_router
|
||||
from sentinel_orbit_downloader.api.routes_settings import router as settings_router
|
||||
from sentinel_orbit_downloader.core.config import configured_api_host, configured_api_port
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="Sentinel Orbit Downloader API",
|
||||
version="0.1.0",
|
||||
description="Local API for Sentinel-1 search, orbit matching, and task management.",
|
||||
)
|
||||
|
||||
web_root = Path(__file__).resolve().parents[1] / "web"
|
||||
app.mount("/static", StaticFiles(directory=web_root), name="static")
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
def index() -> FileResponse:
|
||||
return FileResponse(web_root / "index.html")
|
||||
|
||||
@app.get("/app", include_in_schema=False)
|
||||
def app_index() -> FileResponse:
|
||||
return FileResponse(web_root / "index.html")
|
||||
|
||||
app.include_router(health_router, prefix="/api")
|
||||
app.include_router(regions_router, prefix="/api")
|
||||
app.include_router(projects_router, prefix="/api")
|
||||
app.include_router(jobs_router, prefix="/api")
|
||||
app.include_router(search_router, prefix="/api")
|
||||
app.include_router(downloads_router, prefix="/api")
|
||||
app.include_router(settings_router, prefix="/api")
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
uvicorn.run(
|
||||
"sentinel_orbit_downloader.api.app:app",
|
||||
host=configured_api_host(),
|
||||
port=configured_api_port(),
|
||||
reload=False,
|
||||
)
|
||||
@@ -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,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from sentinel_orbit_downloader.api.deps import get_db_path
|
||||
from sentinel_orbit_downloader.api.schemas import HealthResponse
|
||||
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
def health(db_path=Depends(get_db_path)) -> HealthResponse:
|
||||
return HealthResponse(status="ok", db_path=str(db_path))
|
||||
@@ -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,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from sentinel_orbit_downloader.api.deps import get_db_path
|
||||
from sentinel_orbit_downloader.api.schemas import JobSceneResponse, JobSummary, SearchJobCreateRequest, SearchJobCreateResponse
|
||||
from sentinel_orbit_downloader.services.aoi import bbox_to_wkt
|
||||
from sentinel_orbit_downloader.services.asf_client import SearchParams, query_scenes
|
||||
from sentinel_orbit_downloader.services.project_store import (
|
||||
create_search_job,
|
||||
get_job,
|
||||
get_project,
|
||||
list_job_scenes,
|
||||
record_job_event,
|
||||
save_query_results,
|
||||
update_search_job,
|
||||
)
|
||||
from sentinel_orbit_downloader.services.region_catalog import get_region, region_wkt
|
||||
|
||||
|
||||
router = APIRouter(prefix="/search-jobs", tags=["search-jobs"])
|
||||
|
||||
|
||||
@router.post("", response_model=SearchJobCreateResponse)
|
||||
def create_search_job_endpoint(
|
||||
request: SearchJobCreateRequest,
|
||||
db_path=Depends(get_db_path),
|
||||
) -> SearchJobCreateResponse:
|
||||
if request.project_id is not None and get_project(db_path, request.project_id) is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project not found: {request.project_id}")
|
||||
|
||||
try:
|
||||
aoi_meta = resolve_search_aoi(request, db_path)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
job = create_search_job(
|
||||
db_path,
|
||||
project_id=request.project_id,
|
||||
source_type=aoi_meta["source_type"],
|
||||
source_value=aoi_meta["source_value"],
|
||||
region_adcode=aoi_meta["region_adcode"],
|
||||
region_name=aoi_meta["region_name"],
|
||||
start_time=request.start,
|
||||
end_time=request.end,
|
||||
processing_level=request.processing_level,
|
||||
beam_mode=request.beam_mode,
|
||||
flight_direction=request.flight_direction.upper() if request.flight_direction else None,
|
||||
polarization=request.polarization,
|
||||
relative_orbit=request.relative_orbit,
|
||||
max_results=request.max_results,
|
||||
manifest_path=request.manifest_path,
|
||||
)
|
||||
record_job_event(db_path, job.id, "info", "API search job created")
|
||||
|
||||
try:
|
||||
params = SearchParams(
|
||||
aoi_wkt=aoi_meta["aoi_wkt"],
|
||||
start=request.start,
|
||||
end=request.end,
|
||||
processing_level=request.processing_level,
|
||||
beam_mode=request.beam_mode,
|
||||
flight_direction=request.flight_direction,
|
||||
polarization=request.polarization,
|
||||
relative_orbit=request.relative_orbit,
|
||||
max_results=request.max_results,
|
||||
)
|
||||
results = query_scenes(params)
|
||||
save_query_results(db_path, job.id, results)
|
||||
update_search_job(
|
||||
db_path,
|
||||
job.id,
|
||||
status="succeeded",
|
||||
result_count=len(results),
|
||||
manifest_path=request.manifest_path,
|
||||
)
|
||||
record_job_event(db_path, job.id, "info", f"API query returned {len(results)} scene(s)")
|
||||
except Exception as exc:
|
||||
update_search_job(db_path, job.id, status="failed", error_message=str(exc))
|
||||
record_job_event(db_path, job.id, "error", f"API search failed: {exc}")
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
final_job = get_job(db_path, job.id)
|
||||
if final_job is None:
|
||||
raise HTTPException(status_code=500, detail=f"Job disappeared after creation: {job.id}")
|
||||
scenes = [JobSceneResponse(**scene) for scene in list_job_scenes(db_path, job.id)]
|
||||
return SearchJobCreateResponse(
|
||||
job=JobSummary.model_validate(final_job),
|
||||
scenes=scenes,
|
||||
)
|
||||
|
||||
|
||||
def resolve_search_aoi(request: SearchJobCreateRequest, db_path) -> dict[str, str | None]:
|
||||
if request.bbox:
|
||||
return {
|
||||
"aoi_wkt": bbox_to_wkt(request.bbox),
|
||||
"source_type": "bbox",
|
||||
"source_value": request.bbox,
|
||||
"region_adcode": None,
|
||||
"region_name": None,
|
||||
}
|
||||
if request.wkt:
|
||||
return {
|
||||
"aoi_wkt": request.wkt.strip(),
|
||||
"source_type": "wkt",
|
||||
"source_value": request.wkt.strip(),
|
||||
"region_adcode": None,
|
||||
"region_name": None,
|
||||
}
|
||||
if request.region_adcode:
|
||||
region = get_region(db_path, request.region_adcode)
|
||||
if region is None:
|
||||
raise ValueError(f"Region not found: {request.region_adcode}")
|
||||
return {
|
||||
"aoi_wkt": region_wkt(db_path, region.adcode, simplified=not request.raw_region),
|
||||
"source_type": "region",
|
||||
"source_value": region.label,
|
||||
"region_adcode": region.adcode,
|
||||
"region_name": region.label,
|
||||
}
|
||||
raise ValueError("one AOI input is required")
|
||||
@@ -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,195 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
db_path: str
|
||||
|
||||
|
||||
class RegionSummary(BaseModel):
|
||||
adcode: str
|
||||
name: str
|
||||
level: str
|
||||
parent_adcode: str | None = None
|
||||
parent_name: str | None = None
|
||||
province_adcode: str | None = None
|
||||
province_name: str | None = None
|
||||
label: str
|
||||
bbox: list[float] | None = None
|
||||
simplify_tolerance: float
|
||||
|
||||
|
||||
class RegionDetail(RegionSummary):
|
||||
center: list[float] | None = None
|
||||
centroid: list[float] | None = None
|
||||
geometry_json: dict[str, Any]
|
||||
wkt: str
|
||||
simplified_wkt: str
|
||||
children_num: int | None = None
|
||||
tree_id: str | None = None
|
||||
|
||||
|
||||
class RegionImportRequest(BaseModel):
|
||||
source_path: str = Field(default="\u5168\u56fd\u884c\u653f\u533a.geojson")
|
||||
simplify_tolerance: float = Field(default=0.01, gt=0)
|
||||
|
||||
|
||||
class RegionImportResponse(BaseModel):
|
||||
source_path: str
|
||||
simplify_tolerance: float
|
||||
total_features: int
|
||||
imported: int
|
||||
|
||||
|
||||
class ProjectCreateRequest(BaseModel):
|
||||
name: str
|
||||
data_root: str
|
||||
description: str | None = None
|
||||
slug: str | None = None
|
||||
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
data_root: str
|
||||
description: str | None = None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class JobSummary(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
project_id: int | None = None
|
||||
source_type: str
|
||||
source_value: str | None = None
|
||||
region_adcode: str | None = None
|
||||
region_name: str | None = None
|
||||
start_time: str
|
||||
end_time: str
|
||||
processing_level: str
|
||||
beam_mode: str | None = None
|
||||
flight_direction: str | None = None
|
||||
polarization: str | None = None
|
||||
relative_orbit: int | None = None
|
||||
max_results: int
|
||||
status: str
|
||||
result_count: int | None = None
|
||||
data_dir: str | None = None
|
||||
orbit_dir: str | None = None
|
||||
manifest_path: str | None = None
|
||||
error_message: str | None = None
|
||||
created_at: str
|
||||
finished_at: str | None = None
|
||||
|
||||
|
||||
class JobSceneResponse(BaseModel):
|
||||
job_id: int
|
||||
scene_id: str
|
||||
selected: int
|
||||
product_status: str
|
||||
orbit_status: str
|
||||
product_path: str | None = None
|
||||
orbit_path: str | None = None
|
||||
platform: str | None = None
|
||||
start_time: str | None = None
|
||||
stop_time: str | None = None
|
||||
processing_level: str | None = None
|
||||
flight_direction: str | None = None
|
||||
relative_orbit: int | None = None
|
||||
size_mb: float | None = None
|
||||
|
||||
|
||||
class JobEventResponse(BaseModel):
|
||||
id: int
|
||||
level: str
|
||||
message: str
|
||||
created_at: str
|
||||
|
||||
|
||||
class JobDetailResponse(BaseModel):
|
||||
job: JobSummary
|
||||
scenes: list[JobSceneResponse]
|
||||
events: list[JobEventResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SearchJobCreateRequest(BaseModel):
|
||||
project_id: int | None = None
|
||||
bbox: str | None = None
|
||||
wkt: str | None = None
|
||||
region_adcode: str | None = None
|
||||
start: str
|
||||
end: str
|
||||
processing_level: str = "SLC"
|
||||
beam_mode: str | None = "IW"
|
||||
flight_direction: str | None = None
|
||||
polarization: str | None = None
|
||||
relative_orbit: int | None = None
|
||||
max_results: int = Field(default=50, ge=1)
|
||||
manifest_path: str | None = None
|
||||
raw_region: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_aoi(self) -> "SearchJobCreateRequest":
|
||||
provided = [self.bbox, self.wkt, self.region_adcode]
|
||||
count = sum(1 for value in provided if value)
|
||||
if count != 1:
|
||||
raise ValueError("provide exactly one of bbox, wkt, or region_adcode")
|
||||
return self
|
||||
|
||||
|
||||
class SearchJobCreateResponse(BaseModel):
|
||||
job: JobSummary
|
||||
scenes: list[JobSceneResponse]
|
||||
|
||||
|
||||
class DownloadJobCreateRequest(BaseModel):
|
||||
job_id: int = Field(ge=1)
|
||||
data_dir: str | None = None
|
||||
orbit_dir: str | None = None
|
||||
manifest_path: str | None = None
|
||||
skip_data: bool = False
|
||||
skip_orbits: bool = False
|
||||
processes: int = Field(default=2, ge=1)
|
||||
ignore_orbit_errors: bool = False
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_download_flags(self) -> "DownloadJobCreateRequest":
|
||||
if self.skip_data and self.skip_orbits:
|
||||
raise ValueError("skip_data and skip_orbits cannot both be true")
|
||||
return self
|
||||
|
||||
|
||||
class DownloadJobCreateResponse(BaseModel):
|
||||
message: str
|
||||
job: JobSummary
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class EarthdataSettingsRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class EarthdataSettingsResponse(BaseModel):
|
||||
username: str | None = None
|
||||
has_password: bool = False
|
||||
|
||||
|
||||
class JobDeleteResponse(BaseModel):
|
||||
message: str
|
||||
deleted_count: int
|
||||
deleted_job_ids: list[int] = Field(default_factory=list)
|
||||
@@ -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,815 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Sequence
|
||||
|
||||
from sentinel_orbit_downloader.core.config import DEFAULT_DB_PATH
|
||||
from sentinel_orbit_downloader.services.aoi import aoi_file_to_wkt, bbox_to_wkt
|
||||
from sentinel_orbit_downloader.services.asf_client import (
|
||||
SearchParams,
|
||||
build_session,
|
||||
product_size_mb,
|
||||
query_scenes,
|
||||
scene_name_from_product,
|
||||
write_manifest,
|
||||
)
|
||||
from sentinel_orbit_downloader.services.orbit_client import (
|
||||
collect_orbit_scenes,
|
||||
download_orbits,
|
||||
scene_names_from_results,
|
||||
)
|
||||
from sentinel_orbit_downloader.services.project_store import (
|
||||
create_project,
|
||||
create_search_job,
|
||||
get_job,
|
||||
get_project,
|
||||
list_job_events,
|
||||
list_job_scenes,
|
||||
list_jobs,
|
||||
list_projects,
|
||||
record_download_result,
|
||||
record_job_event,
|
||||
save_query_results,
|
||||
update_search_job,
|
||||
)
|
||||
from sentinel_orbit_downloader.services.region_catalog import (
|
||||
get_region,
|
||||
import_regions,
|
||||
region_wkt,
|
||||
search_regions,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_REGION_FILE = Path("\u5168\u56fd\u884c\u653f\u533a.geojson")
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
return args.func(args)
|
||||
except KeyboardInterrupt:
|
||||
print("Interrupted.", file=sys.stderr)
|
||||
return 130
|
||||
except ValueError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="s1-dl",
|
||||
description="Query and download Sentinel-1 scenes plus matching orbit files.",
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
search_parser = subparsers.add_parser("search", help="query Sentinel-1 scenes")
|
||||
add_database_arg(search_parser)
|
||||
add_project_arg(search_parser)
|
||||
add_query_args(search_parser)
|
||||
add_manifest_arg(search_parser)
|
||||
search_parser.set_defaults(func=cmd_search)
|
||||
|
||||
download_parser = subparsers.add_parser(
|
||||
"download",
|
||||
help="query scenes, download products, and fetch matching orbit files",
|
||||
)
|
||||
add_database_arg(download_parser)
|
||||
add_project_arg(download_parser)
|
||||
add_query_args(download_parser)
|
||||
download_parser.add_argument(
|
||||
"--out",
|
||||
type=Path,
|
||||
default=Path("downloads"),
|
||||
help="output root directory; data and orbits subdirectories are created here",
|
||||
)
|
||||
download_parser.add_argument(
|
||||
"--data-dir",
|
||||
type=Path,
|
||||
help="override product download directory; default is <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=2,
|
||||
help="parallel ASF download processes",
|
||||
)
|
||||
download_parser.add_argument(
|
||||
"--skip-data",
|
||||
action="store_true",
|
||||
help="do not download product zip files; useful for orbit-only runs from query results",
|
||||
)
|
||||
download_parser.add_argument(
|
||||
"--skip-orbits",
|
||||
action="store_true",
|
||||
help="do not download matching orbit files",
|
||||
)
|
||||
download_parser.add_argument(
|
||||
"--ignore-orbit-errors",
|
||||
action="store_true",
|
||||
help="return success even if some orbit files cannot be found",
|
||||
)
|
||||
add_manifest_arg(download_parser)
|
||||
download_parser.set_defaults(func=cmd_download)
|
||||
|
||||
orbit_parser = subparsers.add_parser(
|
||||
"orbits",
|
||||
help="download orbit files for existing scenes or local .zip/.SAFE products",
|
||||
)
|
||||
scene_group = orbit_parser.add_mutually_exclusive_group(required=True)
|
||||
scene_group.add_argument(
|
||||
"--scene",
|
||||
action="append",
|
||||
help="Sentinel-1 scene name; may be repeated",
|
||||
)
|
||||
scene_group.add_argument(
|
||||
"--scene-file",
|
||||
type=Path,
|
||||
help="text file containing one scene name per line",
|
||||
)
|
||||
scene_group.add_argument(
|
||||
"--data-dir",
|
||||
type=Path,
|
||||
help="directory containing Sentinel-1 .zip files or .SAFE folders",
|
||||
)
|
||||
orbit_parser.add_argument(
|
||||
"--out",
|
||||
type=Path,
|
||||
default=Path("orbits"),
|
||||
help="orbit output directory",
|
||||
)
|
||||
orbit_parser.add_argument(
|
||||
"--ignore-errors",
|
||||
action="store_true",
|
||||
help="return success even if some orbit files cannot be found",
|
||||
)
|
||||
orbit_parser.set_defaults(func=cmd_orbits)
|
||||
|
||||
regions_parser = subparsers.add_parser(
|
||||
"regions",
|
||||
help="import and query administrative boundary regions",
|
||||
)
|
||||
add_database_arg(regions_parser)
|
||||
region_subparsers = regions_parser.add_subparsers(dest="regions_command", required=True)
|
||||
|
||||
regions_import = region_subparsers.add_parser("import", help="import a GeoJSON region catalog")
|
||||
add_database_arg(regions_import)
|
||||
regions_import.add_argument(
|
||||
"source",
|
||||
nargs="?",
|
||||
type=Path,
|
||||
default=DEFAULT_REGION_FILE,
|
||||
help=f"GeoJSON source file; default is .\\{DEFAULT_REGION_FILE.name}",
|
||||
)
|
||||
regions_import.add_argument(
|
||||
"--simplify-tolerance",
|
||||
type=float,
|
||||
default=0.01,
|
||||
help="Shapely simplify tolerance used to derive query WKT",
|
||||
)
|
||||
regions_import.set_defaults(func=cmd_regions_import)
|
||||
|
||||
regions_search_parser = region_subparsers.add_parser("search", help="search regions by name or adcode")
|
||||
add_database_arg(regions_search_parser)
|
||||
regions_search_parser.add_argument("query", help="query text, for example mudanjiang or 231000")
|
||||
regions_search_parser.add_argument(
|
||||
"--limit",
|
||||
type=positive_int,
|
||||
default=20,
|
||||
help="maximum number of matches to return",
|
||||
)
|
||||
regions_search_parser.add_argument(
|
||||
"--level",
|
||||
choices=("country", "province", "city", "district"),
|
||||
help="optional level filter",
|
||||
)
|
||||
regions_search_parser.set_defaults(func=cmd_regions_search)
|
||||
|
||||
regions_export_parser = region_subparsers.add_parser("export", help="export region geometry or metadata")
|
||||
add_database_arg(regions_export_parser)
|
||||
regions_export_parser.add_argument("adcode", help="region adcode")
|
||||
regions_export_parser.add_argument(
|
||||
"--format",
|
||||
choices=("wkt", "geojson", "bbox", "json"),
|
||||
default="wkt",
|
||||
help="output format",
|
||||
)
|
||||
regions_export_parser.add_argument(
|
||||
"--raw",
|
||||
action="store_true",
|
||||
help="use the original geometry instead of simplified WKT/json",
|
||||
)
|
||||
regions_export_parser.add_argument(
|
||||
"--out",
|
||||
type=Path,
|
||||
help="optional file to write the exported value to",
|
||||
)
|
||||
regions_export_parser.set_defaults(func=cmd_regions_export)
|
||||
|
||||
projects_parser = subparsers.add_parser("projects", help="manage local projects")
|
||||
add_database_arg(projects_parser)
|
||||
project_subparsers = projects_parser.add_subparsers(dest="projects_command", required=True)
|
||||
|
||||
projects_create = project_subparsers.add_parser("create", help="create a project")
|
||||
add_database_arg(projects_create)
|
||||
projects_create.add_argument("--name", required=True, help="project name")
|
||||
projects_create.add_argument("--data-root", type=Path, required=True, help="project data root")
|
||||
projects_create.add_argument("--description", help="optional description")
|
||||
projects_create.add_argument("--slug", help="optional directory-friendly slug")
|
||||
projects_create.set_defaults(func=cmd_projects_create)
|
||||
|
||||
projects_list_parser = project_subparsers.add_parser("list", help="list projects")
|
||||
add_database_arg(projects_list_parser)
|
||||
projects_list_parser.set_defaults(func=cmd_projects_list)
|
||||
|
||||
projects_show_parser = project_subparsers.add_parser("show", help="show one project")
|
||||
add_database_arg(projects_show_parser)
|
||||
projects_show_parser.add_argument("project_id", type=positive_int, help="project id")
|
||||
projects_show_parser.set_defaults(func=cmd_projects_show)
|
||||
|
||||
jobs_parser = subparsers.add_parser("jobs", help="inspect saved query and download jobs")
|
||||
add_database_arg(jobs_parser)
|
||||
jobs_subparsers = jobs_parser.add_subparsers(dest="jobs_command", required=True)
|
||||
|
||||
jobs_list_parser = jobs_subparsers.add_parser("list", help="list recent jobs")
|
||||
add_database_arg(jobs_list_parser)
|
||||
jobs_list_parser.add_argument("--project-id", type=positive_int, help="optional project filter")
|
||||
jobs_list_parser.add_argument("--limit", type=positive_int, default=20, help="maximum jobs to list")
|
||||
jobs_list_parser.set_defaults(func=cmd_jobs_list)
|
||||
|
||||
jobs_show_parser = jobs_subparsers.add_parser("show", help="show one job and its scenes")
|
||||
add_database_arg(jobs_show_parser)
|
||||
jobs_show_parser.add_argument("job_id", type=positive_int, help="job id")
|
||||
jobs_show_parser.add_argument(
|
||||
"--events",
|
||||
action="store_true",
|
||||
help="include stored job events",
|
||||
)
|
||||
jobs_show_parser.set_defaults(func=cmd_jobs_show)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def add_database_arg(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"--db",
|
||||
type=Path,
|
||||
default=DEFAULT_DB_PATH,
|
||||
help=f"SQLite database path; default is {DEFAULT_DB_PATH}",
|
||||
)
|
||||
|
||||
|
||||
def add_project_arg(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"--project-id",
|
||||
type=positive_int,
|
||||
help="optional project id used to group saved jobs",
|
||||
)
|
||||
|
||||
|
||||
def add_query_args(parser: argparse.ArgumentParser) -> None:
|
||||
aoi = parser.add_mutually_exclusive_group(required=True)
|
||||
aoi.add_argument("--bbox", help="AOI bounding box as min_lon,min_lat,max_lon,max_lat")
|
||||
aoi.add_argument("--wkt", help="AOI as WKT, for example POLYGON((...))")
|
||||
aoi.add_argument(
|
||||
"--aoi-file",
|
||||
type=Path,
|
||||
help="AOI file containing WKT, GeoJSON Feature, or GeoJSON geometry",
|
||||
)
|
||||
aoi.add_argument(
|
||||
"--region-adcode",
|
||||
help="AOI from imported region catalog; use `s1-dl regions search` first",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--raw-region",
|
||||
action="store_true",
|
||||
help="when using --region-adcode, use the original geometry instead of simplified WKT",
|
||||
)
|
||||
parser.add_argument("--start", required=True, help="start date/time, e.g. 2024-01-01")
|
||||
parser.add_argument("--end", required=True, help="end date/time, e.g. 2024-02-01")
|
||||
parser.add_argument(
|
||||
"--processing-level",
|
||||
default="SLC",
|
||||
help="ASF processing level, e.g. SLC, GRD_HD, GRD_MD, OCN",
|
||||
)
|
||||
parser.add_argument("--beam-mode", default="IW", help="beam mode, e.g. IW, EW, WV")
|
||||
parser.add_argument(
|
||||
"--flight-direction",
|
||||
choices=("ASCENDING", "DESCENDING", "ascending", "descending"),
|
||||
help="optional orbit direction filter",
|
||||
)
|
||||
parser.add_argument("--polarization", help="optional polarization filter, e.g. VV+VH")
|
||||
parser.add_argument(
|
||||
"--relative-orbit",
|
||||
type=positive_int,
|
||||
help="optional Sentinel-1 relative orbit/track number",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-results",
|
||||
type=positive_int,
|
||||
default=50,
|
||||
help="maximum scenes to return",
|
||||
)
|
||||
|
||||
|
||||
def add_manifest_arg(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--manifest", type=Path, help="write search results to .csv or .json")
|
||||
|
||||
|
||||
def cmd_search(args: argparse.Namespace) -> int:
|
||||
ensure_project_exists(args.db, args.project_id)
|
||||
aoi_meta = resolve_aoi(args)
|
||||
job = create_search_job(
|
||||
args.db,
|
||||
project_id=args.project_id,
|
||||
source_type=aoi_meta["source_type"],
|
||||
source_value=aoi_meta["source_value"],
|
||||
region_adcode=aoi_meta["region_adcode"],
|
||||
region_name=aoi_meta["region_name"],
|
||||
start_time=args.start,
|
||||
end_time=args.end,
|
||||
processing_level=args.processing_level,
|
||||
beam_mode=args.beam_mode,
|
||||
flight_direction=args.flight_direction.upper() if args.flight_direction else None,
|
||||
polarization=args.polarization,
|
||||
relative_orbit=args.relative_orbit,
|
||||
max_results=args.max_results,
|
||||
manifest_path=str(args.manifest) if args.manifest else None,
|
||||
)
|
||||
record_job_event(args.db, job.id, "info", "Query created")
|
||||
|
||||
try:
|
||||
results = perform_query(args, aoi_meta["aoi_wkt"])
|
||||
save_query_results(args.db, job.id, results)
|
||||
print_results(results)
|
||||
if args.manifest:
|
||||
write_manifest(results, args.manifest)
|
||||
print(f"Wrote manifest: {args.manifest}")
|
||||
update_search_job(
|
||||
args.db,
|
||||
job.id,
|
||||
status="succeeded",
|
||||
result_count=len(results),
|
||||
manifest_path=str(args.manifest) if args.manifest else None,
|
||||
)
|
||||
record_job_event(args.db, job.id, "info", f"Query returned {len(results)} scene(s)")
|
||||
except Exception as exc:
|
||||
update_search_job(args.db, job.id, status="failed", error_message=str(exc))
|
||||
record_job_event(args.db, job.id, "error", f"Query failed: {exc}")
|
||||
raise
|
||||
|
||||
print(f"Saved job: {job.id}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_download(args: argparse.Namespace) -> int:
|
||||
ensure_project_exists(args.db, args.project_id)
|
||||
aoi_meta = resolve_aoi(args)
|
||||
|
||||
out_root = args.out
|
||||
data_dir = args.data_dir or out_root / "data"
|
||||
orbit_dir = args.orbit_dir or out_root / "orbits"
|
||||
out_root.mkdir(parents=True, exist_ok=True)
|
||||
manifest = args.manifest or out_root / "manifest.csv"
|
||||
|
||||
job = create_search_job(
|
||||
args.db,
|
||||
project_id=args.project_id,
|
||||
source_type=aoi_meta["source_type"],
|
||||
source_value=aoi_meta["source_value"],
|
||||
region_adcode=aoi_meta["region_adcode"],
|
||||
region_name=aoi_meta["region_name"],
|
||||
start_time=args.start,
|
||||
end_time=args.end,
|
||||
processing_level=args.processing_level,
|
||||
beam_mode=args.beam_mode,
|
||||
flight_direction=args.flight_direction.upper() if args.flight_direction else None,
|
||||
polarization=args.polarization,
|
||||
relative_orbit=args.relative_orbit,
|
||||
max_results=args.max_results,
|
||||
data_dir=str(data_dir),
|
||||
orbit_dir=str(orbit_dir),
|
||||
manifest_path=str(manifest),
|
||||
)
|
||||
record_job_event(args.db, job.id, "info", "Download job created")
|
||||
|
||||
try:
|
||||
results = perform_query(args, aoi_meta["aoi_wkt"])
|
||||
save_query_results(args.db, job.id, results)
|
||||
record_job_event(args.db, job.id, "info", f"Query returned {len(results)} scene(s)")
|
||||
|
||||
print_results(results)
|
||||
write_manifest(results, manifest)
|
||||
print(f"Wrote manifest: {manifest}")
|
||||
|
||||
if not results:
|
||||
update_search_job(
|
||||
args.db,
|
||||
job.id,
|
||||
status="succeeded",
|
||||
result_count=0,
|
||||
data_dir=str(data_dir),
|
||||
orbit_dir=str(orbit_dir),
|
||||
manifest_path=str(manifest),
|
||||
)
|
||||
record_job_event(args.db, job.id, "info", "No scenes matched the query")
|
||||
print(f"Saved job: {job.id}")
|
||||
return 0
|
||||
|
||||
if not args.skip_data:
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
session = build_session(
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
ask_password=args.ask_password,
|
||||
)
|
||||
print(f"Downloading {len(results)} product(s) to {data_dir} ...")
|
||||
results.download(path=str(data_dir), session=session, processes=args.processes)
|
||||
print("Product download finished.")
|
||||
record_job_event(args.db, job.id, "info", f"Product download finished into {data_dir}")
|
||||
record_product_downloads(args.db, job.id, results, data_dir)
|
||||
else:
|
||||
record_job_event(args.db, job.id, "info", "Product download skipped by request")
|
||||
record_product_downloads(args.db, job.id, results, None)
|
||||
|
||||
orbit_failures = 0
|
||||
if not args.skip_orbits:
|
||||
scenes = scene_names_from_results(results)
|
||||
print(f"Downloading orbit files for {len(scenes)} scene(s) to {orbit_dir} ...")
|
||||
orbit_results = download_orbits(scenes, orbit_dir)
|
||||
orbit_failures = summarize_orbit_results(orbit_results)
|
||||
record_orbit_downloads(args.db, job.id, orbit_results)
|
||||
record_job_event(
|
||||
args.db,
|
||||
job.id,
|
||||
"info",
|
||||
f"Orbit download finished: {len(orbit_results) - orbit_failures} ok, {orbit_failures} failed",
|
||||
)
|
||||
else:
|
||||
record_job_event(args.db, job.id, "info", "Orbit download skipped by request")
|
||||
|
||||
final_status = "partial_failed" if orbit_failures else "succeeded"
|
||||
update_search_job(
|
||||
args.db,
|
||||
job.id,
|
||||
status=final_status,
|
||||
result_count=len(results),
|
||||
data_dir=str(data_dir),
|
||||
orbit_dir=str(orbit_dir),
|
||||
manifest_path=str(manifest),
|
||||
)
|
||||
|
||||
print(f"Saved job: {job.id}")
|
||||
if orbit_failures and not args.ignore_orbit_errors:
|
||||
return 1
|
||||
return 0
|
||||
except Exception as exc:
|
||||
update_search_job(
|
||||
args.db,
|
||||
job.id,
|
||||
status="failed",
|
||||
result_count=None,
|
||||
data_dir=str(data_dir),
|
||||
orbit_dir=str(orbit_dir),
|
||||
manifest_path=str(manifest),
|
||||
error_message=str(exc),
|
||||
)
|
||||
record_job_event(args.db, job.id, "error", f"Download job failed: {exc}")
|
||||
raise
|
||||
|
||||
|
||||
def cmd_orbits(args: argparse.Namespace) -> int:
|
||||
scenes = collect_orbit_scenes(
|
||||
scenes=args.scene,
|
||||
scene_file=args.scene_file,
|
||||
data_dir=args.data_dir,
|
||||
)
|
||||
if not scenes:
|
||||
print("No Sentinel-1 scenes found.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Downloading orbit files for {len(scenes)} scene(s) to {args.out} ...")
|
||||
results = download_orbits(scenes, args.out)
|
||||
failures = summarize_orbit_results(results)
|
||||
if failures and not args.ignore_errors:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_regions_import(args: argparse.Namespace) -> int:
|
||||
if not args.source.exists():
|
||||
raise ValueError(f"GeoJSON file does not exist: {args.source}")
|
||||
stats = import_regions(args.db, args.source, simplify_tolerance=args.simplify_tolerance)
|
||||
print(
|
||||
"Imported region catalog: "
|
||||
f"{stats.imported}/{stats.total_features} features "
|
||||
f"from {stats.source_path} with simplify_tolerance={stats.simplify_tolerance}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_regions_search(args: argparse.Namespace) -> int:
|
||||
matches = search_regions(args.db, args.query, limit=args.limit, level=args.level)
|
||||
if not matches:
|
||||
print("No regions matched.")
|
||||
return 0
|
||||
|
||||
for region in matches:
|
||||
bbox_text = ""
|
||||
if region.bbox is not None:
|
||||
bbox_text = ",".join(f"{value:.6f}" for value in region.bbox)
|
||||
print(f"{region.adcode} | {region.level:<8} | {region.label} | {bbox_text}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_regions_export(args: argparse.Namespace) -> int:
|
||||
region = get_region(args.db, args.adcode)
|
||||
if region is None:
|
||||
raise ValueError(f"region not found: {args.adcode}")
|
||||
|
||||
if args.format == "wkt":
|
||||
output = region.wkt if args.raw else region.simplified_wkt
|
||||
elif args.format == "geojson":
|
||||
output = region.geometry_json
|
||||
elif args.format == "bbox":
|
||||
if region.bbox is None:
|
||||
raise ValueError(f"region has no bbox: {args.adcode}")
|
||||
output = ",".join(str(value) for value in region.bbox)
|
||||
else:
|
||||
payload: dict[str, Any] = {
|
||||
"adcode": region.adcode,
|
||||
"name": region.name,
|
||||
"level": region.level,
|
||||
"parent_adcode": region.parent_adcode,
|
||||
"parent_name": region.parent_name,
|
||||
"province_adcode": region.province_adcode,
|
||||
"province_name": region.province_name,
|
||||
"bbox": region.bbox,
|
||||
"simplify_tolerance": region.simplify_tolerance,
|
||||
"label": region.label,
|
||||
}
|
||||
output = json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
|
||||
if args.out:
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.out.write_text(output, encoding="utf-8")
|
||||
print(f"Wrote region export: {args.out}")
|
||||
else:
|
||||
print(output)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_projects_create(args: argparse.Namespace) -> int:
|
||||
project = create_project(
|
||||
args.db,
|
||||
name=args.name,
|
||||
data_root=args.data_root,
|
||||
description=args.description,
|
||||
slug=args.slug,
|
||||
)
|
||||
print(f"Created project: {project.id} | {project.slug} | {project.data_root}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_projects_list(args: argparse.Namespace) -> int:
|
||||
projects = list_projects(args.db)
|
||||
if not projects:
|
||||
print("No projects found.")
|
||||
return 0
|
||||
for project in projects:
|
||||
desc = f" | {project.description}" if project.description else ""
|
||||
print(f"{project.id} | {project.slug} | {project.name} | {project.data_root}{desc}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_projects_show(args: argparse.Namespace) -> int:
|
||||
project = get_project(args.db, args.project_id)
|
||||
if project is None:
|
||||
raise ValueError(f"project not found: {args.project_id}")
|
||||
print(json.dumps(project.__dict__, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_jobs_list(args: argparse.Namespace) -> int:
|
||||
jobs = list_jobs(args.db, project_id=args.project_id, limit=args.limit)
|
||||
if not jobs:
|
||||
print("No jobs found.")
|
||||
return 0
|
||||
for job in jobs:
|
||||
project_text = job.project_id if job.project_id is not None else "-"
|
||||
target = job.region_name or job.source_value or job.source_type
|
||||
print(
|
||||
f"{job.id} | project={project_text} | {job.status:<14} | "
|
||||
f"{job.start_time}..{job.end_time} | {target} | count={job.result_count}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_jobs_show(args: argparse.Namespace) -> int:
|
||||
job = get_job(args.db, args.job_id)
|
||||
if job is None:
|
||||
raise ValueError(f"job not found: {args.job_id}")
|
||||
|
||||
print(json.dumps(job.__dict__, ensure_ascii=False, indent=2))
|
||||
scenes = list_job_scenes(args.db, args.job_id)
|
||||
if scenes:
|
||||
print("\nScenes:")
|
||||
for scene in scenes:
|
||||
size_text = f"{scene['size_mb']:.1f} MB" if scene["size_mb"] is not None else ""
|
||||
print(
|
||||
f" {scene['scene_id']} | {scene['start_time']} | {scene['processing_level']} | "
|
||||
f"product={scene['product_status']} | orbit={scene['orbit_status']} | {size_text}"
|
||||
)
|
||||
if args.events:
|
||||
events = list_job_events(args.db, args.job_id)
|
||||
if events:
|
||||
print("\nEvents:")
|
||||
for event in events:
|
||||
print(f" [{event['created_at']}] {event['level']}: {event['message']}")
|
||||
return 0
|
||||
|
||||
|
||||
def perform_query(args: argparse.Namespace, aoi_wkt: str):
|
||||
params = SearchParams(
|
||||
aoi_wkt=aoi_wkt,
|
||||
start=args.start,
|
||||
end=args.end,
|
||||
processing_level=args.processing_level,
|
||||
beam_mode=args.beam_mode,
|
||||
flight_direction=args.flight_direction,
|
||||
polarization=args.polarization,
|
||||
relative_orbit=args.relative_orbit,
|
||||
max_results=args.max_results,
|
||||
)
|
||||
print("Querying ASF Search ...")
|
||||
return query_scenes(params)
|
||||
|
||||
|
||||
def resolve_aoi(args: argparse.Namespace) -> dict[str, Any]:
|
||||
if args.bbox:
|
||||
return {
|
||||
"aoi_wkt": bbox_to_wkt(args.bbox),
|
||||
"source_type": "bbox",
|
||||
"source_value": args.bbox,
|
||||
"region_adcode": None,
|
||||
"region_name": None,
|
||||
}
|
||||
if args.wkt:
|
||||
return {
|
||||
"aoi_wkt": args.wkt.strip(),
|
||||
"source_type": "wkt",
|
||||
"source_value": args.wkt.strip(),
|
||||
"region_adcode": None,
|
||||
"region_name": None,
|
||||
}
|
||||
if args.aoi_file:
|
||||
return {
|
||||
"aoi_wkt": aoi_file_to_wkt(args.aoi_file),
|
||||
"source_type": "aoi_file",
|
||||
"source_value": str(args.aoi_file),
|
||||
"region_adcode": None,
|
||||
"region_name": None,
|
||||
}
|
||||
if args.region_adcode:
|
||||
region = get_region(args.db, args.region_adcode)
|
||||
if region is None:
|
||||
raise ValueError(f"region not found: {args.region_adcode}")
|
||||
return {
|
||||
"aoi_wkt": region_wkt(args.db, args.region_adcode, simplified=not args.raw_region),
|
||||
"source_type": "region",
|
||||
"source_value": region.label,
|
||||
"region_adcode": region.adcode,
|
||||
"region_name": region.label,
|
||||
}
|
||||
raise ValueError("one AOI input is required")
|
||||
|
||||
|
||||
def print_results(results) -> None:
|
||||
count = len(results)
|
||||
print(f"Found {count} scene(s).")
|
||||
if not count:
|
||||
return
|
||||
|
||||
for index, product in enumerate(results, start=1):
|
||||
props = product.properties
|
||||
scene = scene_name_from_product(product) or props.get("fileID") or "<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,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DEFAULT_DB_PATH = Path(".s1dl/app.db")
|
||||
DEFAULT_API_HOST = "127.0.0.1"
|
||||
DEFAULT_API_PORT = 8010
|
||||
|
||||
|
||||
def configured_db_path() -> Path:
|
||||
raw = os.getenv("S1DL_DB_PATH")
|
||||
if not raw:
|
||||
return DEFAULT_DB_PATH
|
||||
return Path(raw)
|
||||
|
||||
|
||||
def configured_api_host() -> str:
|
||||
return os.getenv("S1DL_API_HOST", DEFAULT_API_HOST)
|
||||
|
||||
|
||||
def configured_api_port() -> int:
|
||||
raw = os.getenv("S1DL_API_PORT")
|
||||
if not raw:
|
||||
return DEFAULT_API_PORT
|
||||
return int(raw)
|
||||
@@ -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,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Sequence
|
||||
|
||||
|
||||
def bbox_to_wkt(value: str) -> str:
|
||||
parts = [part.strip() for part in value.split(",")]
|
||||
if len(parts) != 4:
|
||||
raise ValueError("--bbox must be min_lon,min_lat,max_lon,max_lat")
|
||||
try:
|
||||
min_lon, min_lat, max_lon, max_lat = (float(part) for part in parts)
|
||||
except ValueError as exc:
|
||||
raise ValueError("--bbox values must be numbers") from exc
|
||||
if min_lon >= max_lon or min_lat >= max_lat:
|
||||
raise ValueError("--bbox min values must be smaller than max values")
|
||||
return (
|
||||
f"POLYGON(({min_lon} {min_lat},{max_lon} {min_lat},"
|
||||
f"{max_lon} {max_lat},{min_lon} {max_lat},{min_lon} {min_lat}))"
|
||||
)
|
||||
|
||||
|
||||
def aoi_file_to_wkt(path: Path) -> str:
|
||||
text = path.read_text(encoding="utf-8").strip()
|
||||
if not text:
|
||||
raise ValueError(f"AOI file is empty: {path}")
|
||||
if text[0] != "{":
|
||||
return text
|
||||
|
||||
data = json.loads(text)
|
||||
geometry = geojson_geometry(data)
|
||||
return geometry_to_wkt(geometry)
|
||||
|
||||
|
||||
def geojson_geometry(data: dict[str, Any]) -> dict[str, Any]:
|
||||
kind = data.get("type")
|
||||
if kind == "FeatureCollection":
|
||||
features = data.get("features") or []
|
||||
if not features:
|
||||
raise ValueError("GeoJSON FeatureCollection has no features")
|
||||
return geojson_geometry(features[0])
|
||||
if kind == "Feature":
|
||||
geometry = data.get("geometry")
|
||||
if not geometry:
|
||||
raise ValueError("GeoJSON Feature has no geometry")
|
||||
return geometry
|
||||
if kind in {"Polygon", "MultiPolygon", "Point", "MultiPoint", "LineString"}:
|
||||
return data
|
||||
raise ValueError(f"unsupported GeoJSON type: {kind}")
|
||||
|
||||
|
||||
def geometry_to_wkt(geometry: dict[str, Any]) -> str:
|
||||
kind = geometry.get("type")
|
||||
coordinates = geometry.get("coordinates")
|
||||
if kind == "Point":
|
||||
lon, lat = coordinates
|
||||
return f"POINT({lon} {lat})"
|
||||
if kind == "MultiPoint":
|
||||
return "MULTIPOINT(" + ",".join(f"({lon} {lat})" for lon, lat in coordinates) + ")"
|
||||
if kind == "LineString":
|
||||
return "LINESTRING(" + coords_to_text(coordinates) + ")"
|
||||
if kind == "Polygon":
|
||||
return "POLYGON(" + ",".join(f"({coords_to_text(ring)})" for ring in coordinates) + ")"
|
||||
if kind == "MultiPolygon":
|
||||
polygons = []
|
||||
for polygon in coordinates:
|
||||
rings = ",".join(f"({coords_to_text(ring)})" for ring in polygon)
|
||||
polygons.append(f"({rings})")
|
||||
return "MULTIPOLYGON(" + ",".join(polygons) + ")"
|
||||
raise ValueError(f"unsupported GeoJSON geometry type: {kind}")
|
||||
|
||||
|
||||
def coords_to_text(coords: Iterable[Sequence[float]]) -> str:
|
||||
return ",".join(f"{lon} {lat}" for lon, lat, *_ in coords)
|
||||
@@ -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,370 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
import asf_search as asf
|
||||
|
||||
from sentinel_orbit_downloader.services.asf_client import build_session, credentials_available, scene_name_from_product
|
||||
from sentinel_orbit_downloader.services.orbit_client import download_orbits
|
||||
from sentinel_orbit_downloader.services.project_store import (
|
||||
get_job,
|
||||
get_project,
|
||||
list_job_scenes,
|
||||
record_download_result,
|
||||
record_job_event,
|
||||
update_search_job,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DownloadJobRequest:
|
||||
job_id: int
|
||||
data_dir: Path | None = None
|
||||
orbit_dir: Path | None = None
|
||||
manifest_path: Path | None = None
|
||||
skip_data: bool = False
|
||||
skip_orbits: bool = False
|
||||
processes: int = 2
|
||||
ignore_orbit_errors: bool = False
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DownloadJobPaths:
|
||||
root: Path
|
||||
data_dir: Path
|
||||
orbit_dir: Path
|
||||
manifest_path: Path
|
||||
|
||||
|
||||
def resolve_download_paths(db_path: Path, request: DownloadJobRequest) -> DownloadJobPaths:
|
||||
job = get_job(db_path, request.job_id)
|
||||
if job is None:
|
||||
raise ValueError(f"Job not found: {request.job_id}")
|
||||
|
||||
root = None
|
||||
if request.data_dir is not None:
|
||||
root = request.data_dir.parent if request.orbit_dir is None else request.data_dir.parent
|
||||
elif request.orbit_dir is not None:
|
||||
root = request.orbit_dir.parent
|
||||
elif job.data_dir:
|
||||
root = Path(job.data_dir).parent
|
||||
elif job.project_id is not None:
|
||||
project = get_project(db_path, job.project_id)
|
||||
if project is not None:
|
||||
root = Path(project.data_root) / "jobs" / f"job-{job.id:06d}"
|
||||
if root is None:
|
||||
root = Path(".s1dl") / "downloads" / f"job-{job.id:06d}"
|
||||
|
||||
data_dir = request.data_dir or (Path(job.data_dir) if job.data_dir else root / "data")
|
||||
orbit_dir = request.orbit_dir or (Path(job.orbit_dir) if job.orbit_dir else root / "orbits")
|
||||
manifest_path = request.manifest_path or (
|
||||
Path(job.manifest_path) if job.manifest_path else root / "manifest.csv"
|
||||
)
|
||||
return DownloadJobPaths(root=root, data_dir=data_dir, orbit_dir=orbit_dir, manifest_path=manifest_path)
|
||||
|
||||
|
||||
def queue_download_job(db_path: Path, request: DownloadJobRequest) -> DownloadJobPaths:
|
||||
job = get_job(db_path, request.job_id)
|
||||
if job is None:
|
||||
raise ValueError(f"Job not found: {request.job_id}")
|
||||
scenes = list_job_scenes(db_path, request.job_id)
|
||||
if not scenes:
|
||||
raise ValueError(f"Job has no scenes to download: {request.job_id}")
|
||||
|
||||
paths = resolve_download_paths(db_path, request)
|
||||
paths.root.mkdir(parents=True, exist_ok=True)
|
||||
update_search_job(
|
||||
db_path,
|
||||
request.job_id,
|
||||
status="running",
|
||||
result_count=job.result_count,
|
||||
data_dir=str(paths.data_dir),
|
||||
orbit_dir=str(paths.orbit_dir),
|
||||
manifest_path=str(paths.manifest_path),
|
||||
error_message=None,
|
||||
)
|
||||
record_job_event(
|
||||
db_path,
|
||||
request.job_id,
|
||||
"info",
|
||||
f"Download queued for {len(scenes)} scene(s) into {paths.root}",
|
||||
)
|
||||
return paths
|
||||
|
||||
|
||||
def execute_download_job(db_path: Path, request: DownloadJobRequest) -> None:
|
||||
job = get_job(db_path, request.job_id)
|
||||
if job is None:
|
||||
return
|
||||
|
||||
paths = resolve_download_paths(db_path, request)
|
||||
paths.root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
scenes = list_job_scenes(db_path, request.job_id)
|
||||
scene_ids = [scene["scene_id"] for scene in scenes]
|
||||
if not scene_ids:
|
||||
update_search_job(
|
||||
db_path,
|
||||
request.job_id,
|
||||
status="failed",
|
||||
result_count=0,
|
||||
data_dir=str(paths.data_dir),
|
||||
orbit_dir=str(paths.orbit_dir),
|
||||
manifest_path=str(paths.manifest_path),
|
||||
error_message="No scenes available for download",
|
||||
)
|
||||
record_job_event(db_path, request.job_id, "error", "Download job failed: no scenes available")
|
||||
return
|
||||
|
||||
if not request.skip_data and not credentials_available(request.username, request.password):
|
||||
message = (
|
||||
"Download failed: Sentinel-1 product downloads require NASA Earthdata credentials. "
|
||||
"Fill in Earthdata username/password, or set EARTHDATA_USER and EARTHDATA_PASS first."
|
||||
)
|
||||
for scene_id in scene_ids:
|
||||
record_download_result(
|
||||
db_path,
|
||||
job_id=request.job_id,
|
||||
scene_id=scene_id,
|
||||
kind="product",
|
||||
status="failed",
|
||||
path=None,
|
||||
error_message=message,
|
||||
)
|
||||
record_download_result(
|
||||
db_path,
|
||||
job_id=request.job_id,
|
||||
scene_id=scene_id,
|
||||
kind="orbit",
|
||||
status="skipped",
|
||||
path=None,
|
||||
)
|
||||
update_search_job(
|
||||
db_path,
|
||||
request.job_id,
|
||||
status="failed",
|
||||
result_count=job.result_count,
|
||||
data_dir=str(paths.data_dir),
|
||||
orbit_dir=str(paths.orbit_dir),
|
||||
manifest_path=str(paths.manifest_path),
|
||||
error_message=message,
|
||||
)
|
||||
record_job_event(db_path, request.job_id, "error", message)
|
||||
return
|
||||
|
||||
product_error: str | None = None
|
||||
if request.skip_data:
|
||||
record_job_event(db_path, request.job_id, "info", "Product download skipped by request")
|
||||
record_product_skips(db_path, request.job_id, scene_ids)
|
||||
else:
|
||||
results = asf.granule_search(scene_ids)
|
||||
products = {scene_name_from_product(product): product for product in results}
|
||||
ordered_products = [products[scene_id] for scene_id in scene_ids if scene_id in products]
|
||||
missing_scene_ids = [scene_id for scene_id in scene_ids if scene_id not in products]
|
||||
if missing_scene_ids:
|
||||
record_job_event(
|
||||
db_path,
|
||||
request.job_id,
|
||||
"warning",
|
||||
f"{len(missing_scene_ids)} scene(s) could not be reloaded from ASF search",
|
||||
)
|
||||
for scene_id in missing_scene_ids:
|
||||
record_download_result(
|
||||
db_path,
|
||||
job_id=request.job_id,
|
||||
scene_id=scene_id,
|
||||
kind="product",
|
||||
status="failed",
|
||||
path=None,
|
||||
error_message="Scene was not returned by ASF granule_search",
|
||||
)
|
||||
if request.skip_orbits:
|
||||
record_download_result(
|
||||
db_path,
|
||||
job_id=request.job_id,
|
||||
scene_id=scene_id,
|
||||
kind="orbit",
|
||||
status="skipped",
|
||||
path=None,
|
||||
)
|
||||
paths.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
session = build_session(username=request.username, password=request.password)
|
||||
results.download(path=str(paths.data_dir), session=session, processes=request.processes)
|
||||
record_job_event(
|
||||
db_path,
|
||||
request.job_id,
|
||||
"info",
|
||||
f"Product download finished into {paths.data_dir}",
|
||||
)
|
||||
except Exception as exc:
|
||||
product_error = str(exc)
|
||||
record_job_event(db_path, request.job_id, "error", f"Product download error: {exc}")
|
||||
record_product_downloads(db_path, request.job_id, ordered_products, paths.data_dir, product_error)
|
||||
|
||||
orbit_failures = 0
|
||||
if request.skip_orbits:
|
||||
record_job_event(db_path, request.job_id, "info", "Orbit download skipped by request")
|
||||
record_orbit_skips(db_path, request.job_id, scene_ids)
|
||||
else:
|
||||
paths.orbit_dir.mkdir(parents=True, exist_ok=True)
|
||||
orbit_results = download_orbits(scene_ids, paths.orbit_dir)
|
||||
orbit_failures = sum(1 for result in orbit_results if result.error)
|
||||
for result in orbit_results:
|
||||
record_download_result(
|
||||
db_path,
|
||||
job_id=request.job_id,
|
||||
scene_id=result.scene,
|
||||
kind="orbit",
|
||||
status="succeeded" if result.error is None else "failed",
|
||||
path=str(result.path) if result.path is not None else None,
|
||||
error_message=result.error,
|
||||
)
|
||||
record_job_event(
|
||||
db_path,
|
||||
request.job_id,
|
||||
"info",
|
||||
f"Orbit download finished: {len(orbit_results) - orbit_failures} ok, {orbit_failures} failed",
|
||||
)
|
||||
|
||||
product_failures = count_download_failures(db_path, request.job_id, "product")
|
||||
effective_orbit_failures = 0 if request.ignore_orbit_errors else orbit_failures
|
||||
final_status = "succeeded"
|
||||
if product_failures or effective_orbit_failures:
|
||||
final_status = "partial_failed"
|
||||
if product_failures and (request.skip_orbits or effective_orbit_failures == len(scene_ids)):
|
||||
final_status = "failed"
|
||||
|
||||
update_search_job(
|
||||
db_path,
|
||||
request.job_id,
|
||||
status=final_status,
|
||||
result_count=job.result_count,
|
||||
data_dir=str(paths.data_dir),
|
||||
orbit_dir=str(paths.orbit_dir),
|
||||
manifest_path=str(paths.manifest_path),
|
||||
error_message=product_error,
|
||||
)
|
||||
except Exception as exc:
|
||||
update_search_job(
|
||||
db_path,
|
||||
request.job_id,
|
||||
status="failed",
|
||||
result_count=job.result_count,
|
||||
data_dir=str(paths.data_dir),
|
||||
orbit_dir=str(paths.orbit_dir),
|
||||
manifest_path=str(paths.manifest_path),
|
||||
error_message=str(exc),
|
||||
)
|
||||
record_job_event(db_path, request.job_id, "error", f"Download execution failed: {exc}")
|
||||
|
||||
|
||||
def record_product_downloads(
|
||||
db_path: Path,
|
||||
job_id: int,
|
||||
products: Sequence,
|
||||
data_dir: Path | None,
|
||||
fallback_error: str | None = None,
|
||||
) -> None:
|
||||
for product in products:
|
||||
scene_id = scene_name_from_product(product)
|
||||
if scene_id is None:
|
||||
continue
|
||||
props = product.properties
|
||||
if data_dir is None:
|
||||
record_download_result(
|
||||
db_path,
|
||||
job_id=job_id,
|
||||
scene_id=scene_id,
|
||||
kind="product",
|
||||
status="skipped",
|
||||
path=None,
|
||||
url=string_value(props.get("url")),
|
||||
bytes_total=float(props["bytes"]) if props.get("bytes") is not None else None,
|
||||
bytes_done=0,
|
||||
)
|
||||
continue
|
||||
|
||||
expected = expected_product_path(product, data_dir)
|
||||
exists = expected.exists()
|
||||
record_download_result(
|
||||
db_path,
|
||||
job_id=job_id,
|
||||
scene_id=scene_id,
|
||||
kind="product",
|
||||
status="succeeded" if exists else "failed",
|
||||
path=str(expected) if exists else None,
|
||||
url=string_value(props.get("url")),
|
||||
bytes_total=float(props["bytes"]) if props.get("bytes") is not None else None,
|
||||
bytes_done=float(props["bytes"]) if exists and props.get("bytes") is not None else None,
|
||||
error_message=None if exists else (fallback_error or "Downloaded file not found at expected path"),
|
||||
)
|
||||
|
||||
|
||||
def record_product_skips(db_path: Path, job_id: int, scene_ids: Sequence[str]) -> None:
|
||||
for scene_id in scene_ids:
|
||||
record_download_result(
|
||||
db_path,
|
||||
job_id=job_id,
|
||||
scene_id=scene_id,
|
||||
kind="product",
|
||||
status="skipped",
|
||||
path=None,
|
||||
)
|
||||
|
||||
|
||||
def record_orbit_skips(db_path: Path, job_id: int, scene_ids: Sequence[str]) -> None:
|
||||
for scene_id in scene_ids:
|
||||
record_download_result(
|
||||
db_path,
|
||||
job_id=job_id,
|
||||
scene_id=scene_id,
|
||||
kind="orbit",
|
||||
status="skipped",
|
||||
path=None,
|
||||
)
|
||||
|
||||
|
||||
def expected_product_path(product, data_dir: Path) -> Path:
|
||||
props = product.properties
|
||||
file_name = props.get("fileName")
|
||||
if file_name:
|
||||
return data_dir / str(file_name)
|
||||
url = props.get("url")
|
||||
if url:
|
||||
return data_dir / Path(str(url)).name
|
||||
scene = scene_name_from_product(product) or "unknown_scene"
|
||||
return data_dir / f"{scene}.zip"
|
||||
|
||||
|
||||
def string_value(value) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return str(value)
|
||||
|
||||
|
||||
def count_download_failures(db_path: Path, job_id: int, kind: str) -> int:
|
||||
from sentinel_orbit_downloader.storage.db import connect
|
||||
|
||||
if kind == "product":
|
||||
column = "product_status"
|
||||
elif kind == "orbit":
|
||||
column = "orbit_status"
|
||||
else:
|
||||
raise ValueError(f"unsupported download kind: {kind}")
|
||||
|
||||
with connect(db_path) as connection:
|
||||
row = connection.execute(
|
||||
f"""
|
||||
SELECT COUNT(*) AS failure_count
|
||||
FROM job_scenes
|
||||
WHERE job_id = ? AND {column} = 'failed'
|
||||
""",
|
||||
(job_id,),
|
||||
).fetchone()
|
||||
return int(row["failure_count"])
|
||||
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
import s1_orbits
|
||||
from s1_orbits.exceptions import InvalidSceneError, OrbitNotFoundError
|
||||
|
||||
SCENE_RE = re.compile(r"^(S1[A-D]_.+?)(?:\.SAFE|\.zip)?$", re.IGNORECASE)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OrbitResult:
|
||||
scene: str
|
||||
path: Path | None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def normalize_scene_name(value: str) -> str | None:
|
||||
name = Path(value).name.strip()
|
||||
for suffix in (".SAFE", ".safe", ".ZIP", ".zip"):
|
||||
if name.endswith(suffix):
|
||||
name = name[: -len(suffix)]
|
||||
break
|
||||
match = SCENE_RE.match(name)
|
||||
if not match:
|
||||
return None
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def unique(values: Iterable[str]) -> list[str]:
|
||||
seen = set()
|
||||
output = []
|
||||
for value in values:
|
||||
if value in seen:
|
||||
continue
|
||||
seen.add(value)
|
||||
output.append(value)
|
||||
return output
|
||||
|
||||
|
||||
def collect_orbit_scenes(
|
||||
scenes: Sequence[str] | None = None,
|
||||
scene_file: Path | None = None,
|
||||
data_dir: Path | None = None,
|
||||
) -> list[str]:
|
||||
if scenes:
|
||||
return unique(filter(None, (normalize_scene_name(scene) for scene in scenes)))
|
||||
|
||||
if scene_file:
|
||||
scene_names = []
|
||||
for line in scene_file.read_text(encoding="utf-8").splitlines():
|
||||
clean = line.strip()
|
||||
if not clean or clean.startswith("#"):
|
||||
continue
|
||||
scene_names.append(normalize_scene_name(clean))
|
||||
return unique(filter(None, scene_names))
|
||||
|
||||
if not data_dir:
|
||||
return []
|
||||
|
||||
found = []
|
||||
for path in data_dir.iterdir():
|
||||
if not path.name.upper().startswith(("S1A_", "S1B_", "S1C_", "S1D_")):
|
||||
continue
|
||||
scene = normalize_scene_name(path.name)
|
||||
if scene:
|
||||
found.append(scene)
|
||||
return unique(found)
|
||||
|
||||
|
||||
def scene_names_from_results(results) -> list[str]:
|
||||
from .asf_client import scene_name_from_product
|
||||
|
||||
scenes = [scene_name_from_product(product) for product in results]
|
||||
return unique(scene for scene in scenes if scene)
|
||||
|
||||
|
||||
def download_orbits(scenes: Sequence[str], orbit_dir: Path) -> list[OrbitResult]:
|
||||
orbit_dir.mkdir(parents=True, exist_ok=True)
|
||||
results: list[OrbitResult] = []
|
||||
for scene in scenes:
|
||||
try:
|
||||
path = s1_orbits.fetch_for_scene(scene, dir=orbit_dir)
|
||||
results.append(OrbitResult(scene=scene, path=path))
|
||||
except (InvalidSceneError, OrbitNotFoundError) as exc:
|
||||
message = str(exc) or exc.__class__.__name__
|
||||
results.append(OrbitResult(scene=scene, path=None, error=message))
|
||||
return results
|
||||
@@ -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,864 @@
|
||||
const DEFAULT_CATALOG_PATH = "\u5168\u56fd\u884c\u653f\u533a.geojson";
|
||||
const FINAL_STATUSES = new Set(["succeeded", "failed", "partial_failed", "canceled"]);
|
||||
|
||||
const api = {
|
||||
async request(path, options = {}) {
|
||||
const init = { ...options };
|
||||
init.headers = {
|
||||
...(options.body ? { "Content-Type": "application/json" } : {}),
|
||||
...(options.headers || {}),
|
||||
};
|
||||
const response = await fetch(path, init);
|
||||
if (!response.ok) {
|
||||
let detail = response.statusText;
|
||||
try {
|
||||
const payload = await response.json();
|
||||
detail = payload.detail || JSON.stringify(payload);
|
||||
} catch (_) {}
|
||||
throw new Error(detail);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
health() {
|
||||
return this.request("/api/health");
|
||||
},
|
||||
earthdataSettings() {
|
||||
return this.request("/api/settings/earthdata");
|
||||
},
|
||||
saveEarthdataSettings(payload) {
|
||||
return this.request("/api/settings/earthdata", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
clearEarthdataSettings() {
|
||||
return this.request("/api/settings/earthdata", {
|
||||
method: "DELETE",
|
||||
});
|
||||
},
|
||||
importRegions(payload) {
|
||||
return this.request("/api/regions/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
searchRegions(query) {
|
||||
return this.request(`/api/regions/search?q=${encodeURIComponent(query)}`);
|
||||
},
|
||||
regionDetail(adcode) {
|
||||
return this.request(`/api/regions/${encodeURIComponent(adcode)}`);
|
||||
},
|
||||
projects() {
|
||||
return this.request("/api/projects");
|
||||
},
|
||||
createProject(payload) {
|
||||
return this.request("/api/projects", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
jobs(projectId) {
|
||||
const suffix = projectId ? `?project_id=${projectId}&limit=50` : "?limit=50";
|
||||
return this.request(`/api/jobs${suffix}`);
|
||||
},
|
||||
job(jobId) {
|
||||
return this.request(`/api/jobs/${jobId}?include_events=true`);
|
||||
},
|
||||
createSearchJob(payload) {
|
||||
return this.request("/api/search-jobs", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
createDownloadJob(payload) {
|
||||
return this.request("/api/download-jobs", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
deleteJob(jobId, force = false) {
|
||||
return this.request(`/api/jobs/${jobId}?force=${force ? "true" : "false"}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
},
|
||||
clearJobs(projectId, force = false) {
|
||||
const projectPart = projectId ? `project_id=${projectId}&` : "";
|
||||
return this.request(`/api/jobs?${projectPart}force=${force ? "true" : "false"}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const state = {
|
||||
projects: [],
|
||||
selectedProjectId: null,
|
||||
regions: [],
|
||||
selectedRegion: null,
|
||||
selectedRegionDetail: null,
|
||||
earthdataSettings: null,
|
||||
jobs: [],
|
||||
selectedJobId: null,
|
||||
selectedJobDetail: null,
|
||||
pollTimer: null,
|
||||
};
|
||||
|
||||
const el = {
|
||||
healthDot: document.getElementById("healthDot"),
|
||||
healthLabel: document.getElementById("healthLabel"),
|
||||
healthDetail: document.getElementById("healthDetail"),
|
||||
catalogPath: document.getElementById("catalogPath"),
|
||||
simplifyTolerance: document.getElementById("simplifyTolerance"),
|
||||
projectName: document.getElementById("projectName"),
|
||||
projectPath: document.getElementById("projectPath"),
|
||||
projectDesc: document.getElementById("projectDesc"),
|
||||
projectList: document.getElementById("projectList"),
|
||||
messageBar: document.getElementById("messageBar"),
|
||||
regionKeyword: document.getElementById("regionKeyword"),
|
||||
regionList: document.getElementById("regionList"),
|
||||
regionDetailCard: document.getElementById("regionDetailCard"),
|
||||
startDate: document.getElementById("startDate"),
|
||||
endDate: document.getElementById("endDate"),
|
||||
maxResults: document.getElementById("maxResults"),
|
||||
processingLevel: document.getElementById("processingLevel"),
|
||||
beamMode: document.getElementById("beamMode"),
|
||||
flightDirection: document.getElementById("flightDirection"),
|
||||
polarization: document.getElementById("polarization"),
|
||||
relativeOrbit: document.getElementById("relativeOrbit"),
|
||||
manifestPath: document.getElementById("manifestPath"),
|
||||
rawRegion: document.getElementById("rawRegion"),
|
||||
selectionSummary: document.getElementById("selectionSummary"),
|
||||
dataDir: document.getElementById("dataDir"),
|
||||
orbitDir: document.getElementById("orbitDir"),
|
||||
skipData: document.getElementById("skipData"),
|
||||
skipOrbits: document.getElementById("skipOrbits"),
|
||||
downloadProcesses: document.getElementById("downloadProcesses"),
|
||||
ignoreOrbitErrors: document.getElementById("ignoreOrbitErrors"),
|
||||
earthdataUser: document.getElementById("earthdataUser"),
|
||||
earthdataPass: document.getElementById("earthdataPass"),
|
||||
earthdataSummary: document.getElementById("earthdataSummary"),
|
||||
jobFilterSummary: document.getElementById("jobFilterSummary"),
|
||||
jobList: document.getElementById("jobList"),
|
||||
deleteJobBtn: document.getElementById("deleteJobBtn"),
|
||||
clearJobsBtn: document.getElementById("clearJobsBtn"),
|
||||
jobMeta: document.getElementById("jobMeta"),
|
||||
sceneTableBody: document.getElementById("sceneTableBody"),
|
||||
eventList: document.getElementById("eventList"),
|
||||
};
|
||||
|
||||
function setMessage(text, tone = "info") {
|
||||
el.messageBar.textContent = text;
|
||||
el.messageBar.className = `message ${tone}`;
|
||||
}
|
||||
|
||||
function setHealth(ok, label, detail) {
|
||||
el.healthDot.className = `status-dot ${ok ? "ok" : "error"}`;
|
||||
el.healthLabel.textContent = label;
|
||||
el.healthDetail.textContent = detail;
|
||||
}
|
||||
|
||||
function makeChip(text) {
|
||||
const span = document.createElement("span");
|
||||
span.className = "chip";
|
||||
span.textContent = text;
|
||||
return span;
|
||||
}
|
||||
|
||||
function makeStatusPill(value) {
|
||||
const span = document.createElement("span");
|
||||
span.className = `status-pill ${value || ""}`.trim();
|
||||
span.textContent = value || "-";
|
||||
return span;
|
||||
}
|
||||
|
||||
function renderEmpty(container, text) {
|
||||
container.innerHTML = `<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 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 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 (state.selectedRegion) {
|
||||
el.selectionSummary.appendChild(makeChip(`行政区 ${state.selectedRegion.label}`));
|
||||
}
|
||||
el.selectionSummary.appendChild(makeChip(`时间 ${el.startDate.value} - ${el.endDate.value}`));
|
||||
el.selectionSummary.appendChild(makeChip(`产品 ${el.processingLevel.value}`));
|
||||
if (el.beamMode.value) {
|
||||
el.selectionSummary.appendChild(makeChip(`波束 ${el.beamMode.value}`));
|
||||
}
|
||||
if (state.selectedJobId) {
|
||||
el.selectionSummary.appendChild(makeChip(`当前任务 #${state.selectedJobId}`));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHealth() {
|
||||
try {
|
||||
const health = await api.health();
|
||||
setHealth(true, "服务已连接", health.db_path);
|
||||
} catch (error) {
|
||||
setHealth(false, "服务不可用", error.message || String(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEarthdataSettings() {
|
||||
state.earthdataSettings = await api.earthdataSettings();
|
||||
if (state.earthdataSettings && state.earthdataSettings.username && !el.earthdataUser.value) {
|
||||
el.earthdataUser.value = state.earthdataSettings.username;
|
||||
}
|
||||
renderEarthdataSummary();
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
state.projects = await api.projects();
|
||||
if (!state.selectedProjectId && state.projects.length) {
|
||||
state.selectedProjectId = state.projects[0].id;
|
||||
autoFillProjectPaths(state.projects[0]);
|
||||
} else {
|
||||
const project = currentProject();
|
||||
if (project) {
|
||||
autoFillProjectPaths(project);
|
||||
}
|
||||
}
|
||||
renderProjects();
|
||||
updateSelectionSummary();
|
||||
}
|
||||
|
||||
async function loadRegions() {
|
||||
const keyword = el.regionKeyword.value.trim();
|
||||
if (!keyword) {
|
||||
state.regions = [];
|
||||
state.selectedRegion = null;
|
||||
state.selectedRegionDetail = null;
|
||||
renderRegions();
|
||||
renderRegionDetail();
|
||||
updateSelectionSummary();
|
||||
return;
|
||||
}
|
||||
|
||||
state.regions = await api.searchRegions(keyword);
|
||||
state.selectedRegion = state.regions[0] || null;
|
||||
renderRegions();
|
||||
if (state.selectedRegion) {
|
||||
await loadRegionDetail(state.selectedRegion.adcode);
|
||||
} else {
|
||||
state.selectedRegionDetail = null;
|
||||
renderRegionDetail();
|
||||
}
|
||||
updateSelectionSummary();
|
||||
}
|
||||
|
||||
async function loadRegionDetail(adcode) {
|
||||
state.selectedRegionDetail = await api.regionDetail(adcode);
|
||||
renderRegionDetail();
|
||||
}
|
||||
|
||||
async function loadJobs() {
|
||||
state.jobs = await api.jobs(state.selectedProjectId);
|
||||
if (state.selectedJobId && !state.jobs.some((job) => job.id === state.selectedJobId)) {
|
||||
state.selectedJobId = null;
|
||||
state.selectedJobDetail = null;
|
||||
}
|
||||
renderJobs();
|
||||
updateSelectionSummary();
|
||||
}
|
||||
|
||||
async function loadSelectedJobDetail() {
|
||||
if (!state.selectedJobId) {
|
||||
state.selectedJobDetail = null;
|
||||
renderJobDetail();
|
||||
return;
|
||||
}
|
||||
state.selectedJobDetail = await api.job(state.selectedJobId);
|
||||
const updatedJob = state.selectedJobDetail.job;
|
||||
state.jobs = state.jobs.map((job) => (job.id === updatedJob.id ? updatedJob : job));
|
||||
renderJobs();
|
||||
renderJobDetail();
|
||||
updateSelectionSummary();
|
||||
}
|
||||
|
||||
async function importCatalog() {
|
||||
const payload = {
|
||||
source_path: el.catalogPath.value.trim() || DEFAULT_CATALOG_PATH,
|
||||
simplify_tolerance: Number(el.simplifyTolerance.value || 0.01),
|
||||
};
|
||||
const result = await api.importRegions(payload);
|
||||
setMessage(`行政区已导入:${result.imported}/${result.total_features}`, "success");
|
||||
await loadRegions();
|
||||
}
|
||||
|
||||
async function saveEarthdataSettings() {
|
||||
const username = el.earthdataUser.value.trim();
|
||||
const password = el.earthdataPass.value;
|
||||
if (!username || !password) {
|
||||
throw new Error("保存 Earthdata 凭据时必须填写用户名和密码");
|
||||
}
|
||||
const response = await api.saveEarthdataSettings({ username, password });
|
||||
state.earthdataSettings = response;
|
||||
el.earthdataPass.value = "";
|
||||
renderEarthdataSummary();
|
||||
setMessage("Earthdata 凭据已保存到本地配置", "success");
|
||||
}
|
||||
|
||||
async function clearEarthdataSettings() {
|
||||
if (!window.confirm("确定清除本地保存的 Earthdata 用户名和密码吗?")) {
|
||||
return;
|
||||
}
|
||||
await api.clearEarthdataSettings();
|
||||
state.earthdataSettings = { username: null, has_password: false };
|
||||
el.earthdataUser.value = "";
|
||||
el.earthdataPass.value = "";
|
||||
renderEarthdataSummary();
|
||||
setMessage("Earthdata 本地凭据已清除", "success");
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
const payload = {
|
||||
name: el.projectName.value.trim(),
|
||||
data_root: el.projectPath.value.trim(),
|
||||
description: emptyToNull(el.projectDesc.value),
|
||||
};
|
||||
if (!payload.name || !payload.data_root) {
|
||||
throw new Error("项目名称和数据根目录不能为空");
|
||||
}
|
||||
const project = await api.createProject(payload);
|
||||
state.selectedProjectId = project.id;
|
||||
setMessage(`项目已创建:${project.name}`, "success");
|
||||
await loadProjects();
|
||||
await loadJobs();
|
||||
}
|
||||
|
||||
async function createSearchJob() {
|
||||
if (!state.selectedProjectId) {
|
||||
throw new Error("请先选择项目");
|
||||
}
|
||||
if (!state.selectedRegion) {
|
||||
throw new Error("请先选择行政区");
|
||||
}
|
||||
|
||||
const payload = {
|
||||
project_id: state.selectedProjectId,
|
||||
region_adcode: state.selectedRegion.adcode,
|
||||
start: el.startDate.value,
|
||||
end: el.endDate.value,
|
||||
processing_level: el.processingLevel.value,
|
||||
beam_mode: emptyToNull(el.beamMode.value),
|
||||
flight_direction: emptyToNull(el.flightDirection.value),
|
||||
polarization: emptyToNull(el.polarization.value),
|
||||
relative_orbit: numberOrNull(el.relativeOrbit.value),
|
||||
max_results: Number(el.maxResults.value || 20),
|
||||
manifest_path: emptyToNull(el.manifestPath.value),
|
||||
raw_region: el.rawRegion.checked,
|
||||
};
|
||||
|
||||
const response = await api.createSearchJob(payload);
|
||||
state.selectedJobId = response.job.id;
|
||||
state.selectedJobDetail = {
|
||||
job: response.job,
|
||||
scenes: response.scenes,
|
||||
events: [],
|
||||
};
|
||||
setMessage(`检索完成:job #${response.job.id},场景 ${response.scenes.length}`, "success");
|
||||
await loadJobs();
|
||||
await loadSelectedJobDetail();
|
||||
}
|
||||
|
||||
async function queueDownloadJob() {
|
||||
if (!state.selectedJobId) {
|
||||
throw new Error("请先选择任务");
|
||||
}
|
||||
|
||||
const payload = {
|
||||
job_id: state.selectedJobId,
|
||||
data_dir: emptyToNull(el.dataDir.value),
|
||||
orbit_dir: emptyToNull(el.orbitDir.value),
|
||||
skip_data: el.skipData.value === "true",
|
||||
skip_orbits: el.skipOrbits.value === "true",
|
||||
processes: Number(el.downloadProcesses.value || 2),
|
||||
ignore_orbit_errors: el.ignoreOrbitErrors.checked,
|
||||
username: emptyToNull(el.earthdataUser.value),
|
||||
password: emptyToNull(el.earthdataPass.value),
|
||||
};
|
||||
|
||||
const response = await api.createDownloadJob(payload);
|
||||
setMessage(`下载任务已排队:job #${response.job.id}`, "success");
|
||||
await loadSelectedJobDetail();
|
||||
}
|
||||
|
||||
async function deleteSelectedJob() {
|
||||
if (!state.selectedJobId) {
|
||||
throw new Error("请先选择任务");
|
||||
}
|
||||
|
||||
const selectedJob =
|
||||
(state.selectedJobDetail && state.selectedJobDetail.job) ||
|
||||
state.jobs.find((job) => job.id === state.selectedJobId) ||
|
||||
null;
|
||||
const isRunning = Boolean(selectedJob && selectedJob.status === "running");
|
||||
const message = isRunning
|
||||
? "当前任务仍显示为运行中。强制删除只会清理记录,不能保证后台下载立刻停止。确定继续删除吗?"
|
||||
: `确定删除任务 #${state.selectedJobId} 吗?`;
|
||||
|
||||
if (!window.confirm(message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deletedJobId = state.selectedJobId;
|
||||
const response = await api.deleteJob(deletedJobId, isRunning);
|
||||
state.selectedJobId = null;
|
||||
state.selectedJobDetail = null;
|
||||
state.jobs = state.jobs.filter((job) => job.id !== deletedJobId);
|
||||
renderJobs();
|
||||
renderJobDetail();
|
||||
updateSelectionSummary();
|
||||
setMessage(`已删除 ${response.deleted_count} 个任务`, "success");
|
||||
}
|
||||
|
||||
async function clearCurrentJobs() {
|
||||
if (!state.selectedProjectId) {
|
||||
throw new Error("请先选择项目");
|
||||
}
|
||||
|
||||
const project = currentProject();
|
||||
const projectName = project ? project.name : `#${state.selectedProjectId}`;
|
||||
if (!window.confirm(`确定清空项目“${projectName}”下的非运行任务吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await api.clearJobs(state.selectedProjectId, false);
|
||||
const deletedIds = new Set(response.deleted_job_ids || []);
|
||||
if (state.selectedJobId && deletedIds.has(state.selectedJobId)) {
|
||||
state.selectedJobId = null;
|
||||
state.selectedJobDetail = null;
|
||||
}
|
||||
state.jobs = state.jobs.filter((job) => !deletedIds.has(job.id));
|
||||
renderJobs();
|
||||
renderJobDetail();
|
||||
updateSelectionSummary();
|
||||
setMessage(`已清空 ${response.deleted_count} 个任务`, "success");
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (state.pollTimer) {
|
||||
window.clearInterval(state.pollTimer);
|
||||
state.pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function syncPolling() {
|
||||
const activeJob = state.selectedJobDetail && state.selectedJobDetail.job;
|
||||
if (!activeJob || FINAL_STATUSES.has(activeJob.status)) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
if (state.pollTimer) {
|
||||
return;
|
||||
}
|
||||
state.pollTimer = window.setInterval(async () => {
|
||||
if (!state.selectedJobId) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await loadSelectedJobDetail();
|
||||
} catch (_) {}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function runAction(fn, pendingText) {
|
||||
return async () => {
|
||||
try {
|
||||
if (pendingText) {
|
||||
setMessage(pendingText, "info");
|
||||
}
|
||||
await fn();
|
||||
} catch (error) {
|
||||
setMessage(error.message || String(error), "error");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
document.getElementById("saveEarthdataBtn").addEventListener("click", runAction(saveEarthdataSettings, "正在保存 Earthdata 凭据"));
|
||||
document.getElementById("clearEarthdataBtn").addEventListener("click", runAction(clearEarthdataSettings, "正在清除 Earthdata 凭据"));
|
||||
document.getElementById("importCatalogBtn").addEventListener("click", runAction(importCatalog, "正在导入行政区"));
|
||||
document.getElementById("createProjectBtn").addEventListener("click", runAction(createProject, "正在创建项目"));
|
||||
document.getElementById("refreshProjectsBtn").addEventListener("click", runAction(loadProjects, "正在刷新项目"));
|
||||
document.getElementById("searchRegionsBtn").addEventListener("click", runAction(loadRegions, "正在查询行政区"));
|
||||
document.getElementById("clearRegionsBtn").addEventListener("click", () => {
|
||||
state.regions = [];
|
||||
state.selectedRegion = null;
|
||||
state.selectedRegionDetail = null;
|
||||
el.regionKeyword.value = "";
|
||||
renderRegions();
|
||||
renderRegionDetail();
|
||||
updateSelectionSummary();
|
||||
setMessage("行政区查询已清空", "info");
|
||||
});
|
||||
document.getElementById("runSearchBtn").addEventListener("click", runAction(createSearchJob, "正在检索 Sentinel-1"));
|
||||
document.getElementById("queueDownloadBtn").addEventListener("click", runAction(queueDownloadJob, "正在排队下载任务"));
|
||||
document.getElementById("deleteJobBtn").addEventListener("click", runAction(deleteSelectedJob, "正在删除任务"));
|
||||
document.getElementById("clearJobsBtn").addEventListener("click", runAction(clearCurrentJobs, "正在清空任务"));
|
||||
document.getElementById("refreshJobsBtn").addEventListener("click", runAction(async () => {
|
||||
await loadJobs();
|
||||
await loadSelectedJobDetail();
|
||||
}, "正在刷新任务"));
|
||||
document.getElementById("refreshDetailBtn").addEventListener("click", runAction(loadSelectedJobDetail, "正在刷新任务详情"));
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
bindEvents();
|
||||
if (!el.catalogPath.value) {
|
||||
el.catalogPath.value = DEFAULT_CATALOG_PATH;
|
||||
}
|
||||
el.healthDetail.textContent = window.location.origin;
|
||||
setMessage("正在初始化工作台", "info");
|
||||
await loadHealth();
|
||||
await loadEarthdataSettings();
|
||||
await loadProjects();
|
||||
await loadRegions();
|
||||
await loadJobs();
|
||||
await loadSelectedJobDetail();
|
||||
setMessage("工作台已就绪", "success");
|
||||
}
|
||||
|
||||
bootstrap().catch((error) => {
|
||||
setMessage(error.message || String(error), "error");
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
<!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 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" value="2">
|
||||
</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 用户名和密码。你可以手填后直接下载,也可以先保存到本地配置。当前阶段只下载原始产品包和匹配的 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>
|
||||
@@ -0,0 +1,612 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.11, <3.14"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asf-search"
|
||||
version = "12.0.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "dateparser" },
|
||||
{ name = "numpy" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "pytz" },
|
||||
{ name = "requests" },
|
||||
{ name = "shapely" },
|
||||
{ name = "tenacity" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/15/07/874419685c2e79950a8bb2adff2fe079af8a4d77a9a579a291ec6662fd53/asf_search-12.0.7.tar.gz", hash = "sha256:e9ad6e87da7ed000547228308c05653a741eeb7020a3c080d67ae3a91c161e72", size = 1408830, upload-time = "2026-04-13T19:22:34.194Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/f4/c52dad8cbf90b16e7c891df44a14584559e7ee0ddf906bb357cc415b3a26/asf_search-12.0.7-py3-none-any.whl", hash = "sha256:9e0d644f49e36cb5414982ca5363cba49cb9e6e02ea4efbe040f2f4054ae31f4", size = 121238, upload-time = "2026-04-13T19:22:32.769Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.4.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dateparser"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "pytz" },
|
||||
{ name = "regex" },
|
||||
{ name = "tzlocal" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/46/2d/a0ccdb78788064fa0dc901b8524e50615c42be1d78b78d646d0b28d09180/dateparser-1.4.0.tar.gz", hash = "sha256:97a21840d5ecdf7630c584f673338a5afac5dfe84f647baf4d7e8df98f9354a4", size = 321512, upload-time = "2026-03-26T09:56:10.292Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/0b/3c3bb7cbe757279e693a0be6049048012f794d01f81099609ecd53b899f0/dateparser-1.4.0-py3-none-any.whl", hash = "sha256:7902b8e85d603494bf70a5a0b1decdddb2270b9c6e6b2bc8a57b93476c0df378", size = 300379, upload-time = "2026-03-26T09:56:08.409Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.136.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "2.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799, upload-time = "2026-03-29T13:18:15.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552, upload-time = "2026-03-29T13:18:18.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566, upload-time = "2026-03-29T13:18:21.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482, upload-time = "2026-03-29T13:18:23.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376, upload-time = "2026-03-29T13:18:26.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137, upload-time = "2026-03-29T13:18:30.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414, upload-time = "2026-03-29T13:18:33.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397, upload-time = "2026-03-29T13:18:37.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499, upload-time = "2026-03-29T13:18:40.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257, upload-time = "2026-03-29T13:18:42.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775, upload-time = "2026-03-29T13:18:45.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491, upload-time = "2026-03-29T13:21:38.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830, upload-time = "2026-03-29T13:21:41.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927, upload-time = "2026-03-29T13:21:44.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557, upload-time = "2026-03-29T13:21:47.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253, upload-time = "2026-03-29T13:21:50.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552, upload-time = "2026-03-29T13:21:54.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.46.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/22/a2/1ba90a83e85a3f94c796b184f3efde9c72f2830dcda493eea8d59ba78e6d/pydantic_core-2.46.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ab124d49d0459b2373ecf54118a45c28a1e6d4192a533fbc915e70f556feb8e5", size = 2106740, upload-time = "2026-04-20T14:41:20.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/f6/99ae893c89a0b9d3daec9f95487aa676709aa83f67643b3f0abaf4ab628a/pydantic_core-2.46.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cca67d52a5c7a16aed2b3999e719c4bcf644074eac304a5d3d62dd70ae7d4b2c", size = 1948293, upload-time = "2026-04-20T14:43:42.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/b8/2e8e636dc9e3f16c2e16bf0849e24be82c5ee82c603c65fc0326666328fc/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c024e08c0ba23e6fd68c771a521e9d6a792f2ebb0fa734296b36394dc30390e", size = 1973222, upload-time = "2026-04-20T14:41:57.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/36/0e730beec4d83c5306f417afbd82ff237d9a21e83c5edf675f31ed84c1fe/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6645ce7eec4928e29a1e3b3d5c946621d105d3e79f0c9cddf07c2a9770949287", size = 2053852, upload-time = "2026-04-20T14:40:43.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/f0/3071131f47e39136a17814576e0fada9168569f7f8c0e6ac4d1ede6a4958/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a712c7118e6c5ea96562f7b488435172abb94a3c53c22c9efc1412264a45cbbe", size = 2221134, upload-time = "2026-04-20T14:43:03.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/a9/a2dc023eec5aa4b02a467874bad32e2446957d2adcab14e107eab502e978/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a868ef3ff206343579021c40faf3b1edc64b1cc508ff243a28b0a514ccb050", size = 2279785, upload-time = "2026-04-20T14:41:19.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/44/93f489d16fb63fbd41c670441536541f6e8cfa1e5a69f40bc9c5d30d8c90/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc7e8c32db809aa0f6ea1d6869ebc8518a65d5150fdfad8bcae6a49ae32a22e2", size = 2089404, upload-time = "2026-04-20T14:43:10.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/78/8692e3aa72b2d004f7a5d937f1dfdc8552ba26caf0bec75f342c40f00dec/pydantic_core-2.46.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3481bd1341dc85779ee506bc8e1196a277ace359d89d28588a9468c3ecbe63fa", size = 2114898, upload-time = "2026-04-20T14:44:51.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/62/e83133f2e7832532060175cebf1f13748f4c7e7e7165cdd1f611f174494b/pydantic_core-2.46.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8690eba565c6d68ffd3a8655525cbdd5246510b44a637ee2c6c03a7ebfe64d3c", size = 2157856, upload-time = "2026-04-20T14:43:46.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/ec/6a500e3ad7718ee50583fae79c8651f5d37e3abce1fa9ae177ae65842c53/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4de88889d7e88d50d40ee5b39d5dac0bcaef9ba91f7e536ac064e6b2834ecccf", size = 2180168, upload-time = "2026-04-20T14:42:00.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/53/8267811054b1aa7fc1dc7ded93812372ef79a839f5e23558136a6afbfde1/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:e480080975c1ef7f780b8f99ed72337e7cc5efea2e518a20a692e8e7b278eb8b", size = 2322885, upload-time = "2026-04-20T14:41:05.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/c1/1c0acdb3aa0856ddc4ecc55214578f896f2de16f400cf51627eb3c26c1c4/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de3a5c376f8cd94da9a1b8fd3dd1c16c7a7b216ed31dc8ce9fd7a22bf13b836e", size = 2360328, upload-time = "2026-04-20T14:41:43.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/d0/ef39cd0f4a926814f360e71c1adeab48ad214d9727e4deb48eedfb5bce1a/pydantic_core-2.46.3-cp311-cp311-win32.whl", hash = "sha256:fc331a5314ffddd5385b9ee9d0d2fee0b13c27e0e02dad71b1ae5d6561f51eeb", size = 1979464, upload-time = "2026-04-20T14:43:12.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/9c/f41951b0d858e343f1cf09398b2a7b3014013799744f2c4a8ad6a3eec4f2/pydantic_core-2.46.3-cp311-cp311-win_amd64.whl", hash = "sha256:b5b9c6cf08a8a5e502698f5e153056d12c34b8fb30317e0c5fd06f45162a6346", size = 2070837, upload-time = "2026-04-20T14:41:47.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/1e/264a17cd582f6ed50950d4d03dd5fefd84e570e238afe1cb3e25cf238769/pydantic_core-2.46.3-cp311-cp311-win_arm64.whl", hash = "sha256:5dfd51cf457482f04ec49491811a2b8fd5b843b64b11eecd2d7a1ee596ea78a6", size = 2053647, upload-time = "2026-04-20T14:42:27.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/7f/03dbad45cd3aa9083fbc93c210ae8b005af67e4136a14186950a747c6874/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:9715525891ed524a0a1eb6d053c74d4d4ad5017677fb00af0b7c2644a31bae46", size = 2105683, upload-time = "2026-04-20T14:42:19.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/22/4dc186ac8ea6b257e9855031f51b62a9637beac4d68ac06bee02f046f836/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:9d2f400712a99a013aff420ef1eb9be077f8189a36c1e3ef87660b4e1088a874", size = 1940052, upload-time = "2026-04-20T14:43:59.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/ca/d376391a5aff1f2e8188960d7873543608130a870961c2b6b5236627c116/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd2aab0e2e9dc2daf36bd2686c982535d5e7b1d930a1344a7bb6e82baab42a76", size = 1988172, upload-time = "2026-04-20T14:41:17.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/6b/523b9f85c23788755d6ab949329de692a2e3a584bc6beb67fef5e035aa9d/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e9d76736da5f362fabfeea6a69b13b7f2be405c6d6966f06b2f6bfff7e64531", size = 2128596, upload-time = "2026-04-20T14:40:41.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/da/99d40830684f81dec901cac521b5b91c095394cc1084b9433393cde1c2df/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:13afdd885f3d71280cf286b13b310ee0f7ccfefd1dbbb661514a474b726e2f25", size = 2107973, upload-time = "2026-04-20T14:42:06.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/a5/87024121818d75bbb2a98ddbaf638e40e7a18b5e0f5492c9ca4b1b316107/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f91c0aff3e3ee0928edd1232c57f643a7a003e6edf1860bc3afcdc749cb513f3", size = 1947191, upload-time = "2026-04-20T14:43:14.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/62/0c1acfe10945b83a6a59d19fbaa92f48825381509e5701b855c08f13db76/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6529d1d128321a58d30afcc97b49e98836542f68dd41b33c2e972bb9e5290536", size = 2123791, upload-time = "2026-04-20T14:43:22.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/3e/3b2393b4c8f44285561dc30b00cf307a56a2eff7c483a824db3b8221ca51/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:975c267cff4f7e7272eacbe50f6cc03ca9a3da4c4fbd66fffd89c94c1e311aa1", size = 2153197, upload-time = "2026-04-20T14:44:27.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/75/5af02fb35505051eee727c061f2881c555ab4f8ddb2d42da715a42c9731b/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2b8e4f2bbdf71415c544b4b1138b8060db7b6611bc927e8064c769f64bed651c", size = 2181073, upload-time = "2026-04-20T14:43:20.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/92/7e0e1bd9ca3c68305db037560ca2876f89b2647deb2f8b6319005de37505/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e61ea8e9fff9606d09178f577ff8ccdd7206ff73d6552bcec18e1033c4254b85", size = 2315886, upload-time = "2026-04-20T14:44:04.826Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/d8/101655f27eaf3e44558ead736b2795d12500598beed4683f279396fa186e/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b504bda01bafc69b6d3c7a0c7f039dcf60f47fab70e06fe23f57b5c75bdc82b8", size = 2360528, upload-time = "2026-04-20T14:40:47.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/0f/1c34a74c8d07136f0d729ffe5e1fdab04fbdaa7684f61a92f92511a84a15/pydantic_core-2.46.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff", size = 2184144, upload-time = "2026-04-20T14:42:57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytz"
|
||||
version = "2026.1.post1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "2026.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/3a246dbf05666918bd3664d9d787f84a9108f6f43cc953a077e4a7dfdb7e/regex-2026.4.4.tar.gz", hash = "sha256:e08270659717f6973523ce3afbafa53515c4dc5dcad637dc215b6fd50f689423", size = 416000, upload-time = "2026-04-03T20:56:28.155Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/7a/617356cbecdb452812a5d42f720d6d5096b360d4a4c1073af700ea140ad2/regex-2026.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b4c36a85b00fadb85db9d9e90144af0a980e1a3d2ef9cd0f8a5bef88054657c6", size = 489415, upload-time = "2026-04-03T20:53:11.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/e6/bf057227144d02e3ba758b66649e87531d744dda5f3254f48660f18ae9d8/regex-2026.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dcb5453ecf9cd58b562967badd1edbf092b0588a3af9e32ee3d05c985077ce87", size = 291205, upload-time = "2026-04-03T20:53:13.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/3b/637181b787dd1a820ba1c712cee2b4144cd84a32dc776ca067b12b2d70c8/regex-2026.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6aa809ed4dc3706cc38594d67e641601bd2f36d5555b2780ff074edfcb136cf8", size = 289225, upload-time = "2026-04-03T20:53:16.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/21/bac05d806ed02cd4b39d9c8e5b5f9a2998c94c3a351b7792e80671fa5315/regex-2026.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33424f5188a7db12958246a54f59a435b6cb62c5cf9c8d71f7cc49475a5fdada", size = 792434, upload-time = "2026-04-03T20:53:17.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/17/c65d1d8ae90b772d5758eb4014e1e011bb2db353fc4455432e6cc9100df7/regex-2026.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d346fccdde28abba117cc9edc696b9518c3307fbfcb689e549d9b5979018c6d", size = 861730, upload-time = "2026-04-03T20:53:18.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/64/933321aa082a2c6ee2785f22776143ba89840189c20d3b6b1d12b6aae16b/regex-2026.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:415a994b536440f5011aa77e50a4274d15da3245e876e5c7f19da349caaedd87", size = 906495, upload-time = "2026-04-03T20:53:20.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/ea/4c8d306e9c36ac22417336b1e02e7b358152c34dc379673f2d331143725f/regex-2026.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21e5eb86179b4c67b5759d452ea7c48eb135cd93308e7a260aa489ed2eb423a4", size = 799810, upload-time = "2026-04-03T20:53:22.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/ce/7605048f00e1379eba89d610c7d644d8f695dc9b26d3b6ecfa3132b872ff/regex-2026.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:312ec9dd1ae7d96abd8c5a36a552b2139931914407d26fba723f9e53c8186f86", size = 774242, upload-time = "2026-04-03T20:53:25.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/77/283e0d5023fde22cd9e86190d6d9beb21590a452b195ffe00274de470691/regex-2026.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0d2b28aa1354c7cd7f71b7658c4326f7facac106edd7f40eda984424229fd59", size = 781257, upload-time = "2026-04-03T20:53:26.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/fb/7f3b772be101373c8626ed34c5d727dcbb8abd42a7b1219bc25fd9a3cc04/regex-2026.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:349d7310eddff40429a099c08d995c6d4a4bfaf3ff40bd3b5e5cb5a5a3c7d453", size = 854490, upload-time = "2026-04-03T20:53:29.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/30/56547b80f34f4dd2986e1cdd63b1712932f63b6c4ce2f79c50a6cd79d1c2/regex-2026.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e7ab63e9fe45a9ec3417509e18116b367e89c9ceb6219222a3396fa30b147f80", size = 763544, upload-time = "2026-04-03T20:53:30.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/2f/ce060fdfea8eff34a8997603532e44cdb7d1f35e3bc253612a8707a90538/regex-2026.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fe896e07a5a2462308297e515c0054e9ec2dd18dfdc9427b19900b37dfe6f40b", size = 844442, upload-time = "2026-04-03T20:53:32.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/44/810cb113096a1dacbe82789fbfab2823f79d19b7f1271acecb7009ba9b88/regex-2026.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:eb59c65069498dbae3c0ef07bbe224e1eaa079825a437fb47a479f0af11f774f", size = 789162, upload-time = "2026-04-03T20:53:34.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/96/9647dd7f2ecf6d9ce1fb04dfdb66910d094e10d8fe53e9c15096d8aa0bd2/regex-2026.4.4-cp311-cp311-win32.whl", hash = "sha256:2a5d273181b560ef8397c8825f2b9d57013de744da9e8257b8467e5da8599351", size = 266227, upload-time = "2026-04-03T20:53:35.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/80/74e13262460530c3097ff343a17de9a34d040a5dc4de9cf3a8241faab51c/regex-2026.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:9542ccc1e689e752594309444081582f7be2fdb2df75acafea8a075108566735", size = 278399, upload-time = "2026-04-03T20:53:37.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/3c/39f19f47f19dcefa3403f09d13562ca1c0fd07ab54db2bc03148f3f6b46a/regex-2026.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:b5f9fb784824a042be3455b53d0b112655686fdb7a91f88f095f3fee1e2a2a54", size = 270473, upload-time = "2026-04-03T20:53:38.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/28/b972a4d3df61e1d7bcf1b59fdb3cddef22f88b6be43f161bb41ebc0e4081/regex-2026.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c07ab8794fa929e58d97a0e1796b8b76f70943fa39df225ac9964615cf1f9d52", size = 490434, upload-time = "2026-04-03T20:53:40.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/20/30041446cf6dc3e0eab344fc62770e84c23b6b68a3b657821f9f80cb69b4/regex-2026.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c785939dc023a1ce4ec09599c032cc9933d258a998d16ca6f2b596c010940eb", size = 292061, upload-time = "2026-04-03T20:53:41.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/c8/3baa06d75c98c46d4cc4262b71fd2edb9062b5665e868bca57859dadf93a/regex-2026.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b1ce5c81c9114f1ce2f9288a51a8fd3aeea33a0cc440c415bf02da323aa0a76", size = 289628, upload-time = "2026-04-03T20:53:43.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/87/3accf55634caad8c0acab23f5135ef7d4a21c39f28c55c816ae012931408/regex-2026.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:760ef21c17d8e6a4fe8cf406a97cf2806a4df93416ccc82fc98d25b1c20425be", size = 796651, upload-time = "2026-04-03T20:53:45.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/0c/aaa2c83f34efedbf06f61cb1942c25f6cf1ee3b200f832c4d05f28306c2e/regex-2026.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7088fcdcb604a4417c208e2169715800d28838fefd7455fbe40416231d1d47c1", size = 865916, upload-time = "2026-04-03T20:53:47.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/f6/8c6924c865124643e8f37823eca845dc27ac509b2ee58123685e71cd0279/regex-2026.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07edca1ba687998968f7db5bc355288d0c6505caa7374f013d27356d93976d13", size = 912287, upload-time = "2026-04-03T20:53:49.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/0e/a9f6f81013e0deaf559b25711623864970fe6a098314e374ccb1540a4152/regex-2026.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f657a7c1c6ec51b5e0ba97c9817d06b84ea5fa8d82e43b9405de0defdc2b9", size = 801126, upload-time = "2026-04-03T20:53:51.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/61/3a0cc8af2dc0c8deb48e644dd2521f173f7e6513c6e195aad9aa8dd77ac5/regex-2026.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b69102a743e7569ebee67e634a69c4cb7e59d6fa2e1aa7d3bdbf3f61435f62d", size = 776788, upload-time = "2026-04-03T20:53:52.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/0b/8bb9cbf21ef7dee58e49b0fdb066a7aded146c823202e16494a36777594f/regex-2026.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dac006c8b6dda72d86ea3d1333d45147de79a3a3f26f10c1cf9287ca4ca0ac3", size = 785184, upload-time = "2026-04-03T20:53:55.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/c2/d3e80e8137b25ee06c92627de4e4d98b94830e02b3e6f81f3d2e3f504cf5/regex-2026.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:50a766ee2010d504554bfb5f578ed2e066898aa26411d57e6296230627cdefa0", size = 859913, upload-time = "2026-04-03T20:53:57.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/e6/9d5d876157d969c804622456ef250017ac7a8f83e0e14f903b9e6df5ce95/regex-2026.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9e2f5217648f68e3028c823df58663587c1507a5ba8419f4fdfc8a461be76043", size = 765732, upload-time = "2026-04-03T20:53:59.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/80/b568935b4421388561c8ed42aff77247285d3ae3bb2a6ca22af63bae805e/regex-2026.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39d8de85a08e32632974151ba59c6e9140646dcc36c80423962b1c5c0a92e244", size = 852152, upload-time = "2026-04-03T20:54:01.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/29/f0f81217e21cd998245da047405366385d5c6072048038a3d33b37a79dc0/regex-2026.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:55d9304e0e7178dfb1e106c33edf834097ddf4a890e2f676f6c5118f84390f73", size = 789076, upload-time = "2026-04-03T20:54:03.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1d/1d957a61976ab9d4e767dd4f9d04b66cc0c41c5e36cf40e2d43688b5ae6f/regex-2026.4.4-cp312-cp312-win32.whl", hash = "sha256:04bb679bc0bde8a7bfb71e991493d47314e7b98380b083df2447cda4b6edb60f", size = 266700, upload-time = "2026-04-03T20:54:05.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/5c/bf575d396aeb58ea13b06ef2adf624f65b70fafef6950a80fc3da9cae3bc/regex-2026.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:db0ac18435a40a2543dbb3d21e161a6c78e33e8159bd2e009343d224bb03bb1b", size = 277768, upload-time = "2026-04-03T20:54:07.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/27/049df16ec6a6828ccd72add3c7f54b4df029669bea8e9817df6fff58be90/regex-2026.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:4ce255cc05c1947a12989c6db801c96461947adb7a59990f1360b5983fab4983", size = 270568, upload-time = "2026-04-03T20:54:09.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/83/c4373bc5f31f2cf4b66f9b7c31005bd87fe66f0dce17701f7db4ee79ee29/regex-2026.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:62f5519042c101762509b1d717b45a69c0139d60414b3c604b81328c01bd1943", size = 490273, upload-time = "2026-04-03T20:54:11.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/f8/fe62afbcc3cf4ad4ac9adeaafd98aa747869ae12d3e8e2ac293d0593c435/regex-2026.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3790ba9fb5dd76715a7afe34dbe603ba03f8820764b1dc929dd08106214ed031", size = 291954, upload-time = "2026-04-03T20:54:13.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/92/4712b9fe6a33d232eeb1c189484b80c6c4b8422b90e766e1195d6e758207/regex-2026.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fae3c6e795d7678963f2170152b0d892cf6aee9ee8afc8c45e6be38d5107fe7", size = 289487, upload-time = "2026-04-03T20:54:15.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/2c/f83b93f85e01168f1070f045a42d4c937b69fdb8dd7ae82d307253f7e36e/regex-2026.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:298c3ec2d53225b3bf91142eb9691025bab610e0c0c51592dde149db679b3d17", size = 796646, upload-time = "2026-04-03T20:54:18.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/55/61a2e17bf0c4dc57e11caf8dd11771280d8aaa361785f9e3bc40d653f4a7/regex-2026.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9638791082eaf5b3ac112c587518ee78e083a11c4b28012d8fe2a0f536dfb17", size = 865904, upload-time = "2026-04-03T20:54:20.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/32/1ac8ed1b5a346b5993a3d256abe0a0f03b0b73c8cc88d928537368ac65b6/regex-2026.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae3e764bd4c5ff55035dc82a8d49acceb42a5298edf6eb2fc4d328ee5dd7afae", size = 912304, upload-time = "2026-04-03T20:54:22.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/47/2ee5c613ab546f0eddebf9905d23e07beb933416b1246c2d8791d01979b4/regex-2026.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffa81f81b80047ba89a3c69ae6a0f78d06f4a42ce5126b0eb2a0a10ad44e0b2e", size = 801126, upload-time = "2026-04-03T20:54:24.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/cd/41dacd129ca9fd20bd7d02f83e0fad83e034ac8a084ec369c90f55ef37e2/regex-2026.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f56ebf9d70305307a707911b88469213630aba821e77de7d603f9d2f0730687d", size = 776772, upload-time = "2026-04-03T20:54:26.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/6d/5af0b588174cb5f46041fa7dd64d3fd5cd2fe51f18766703d1edc387f324/regex-2026.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:773d1dfd652bbffb09336abf890bfd64785c7463716bf766d0eb3bc19c8b7f27", size = 785228, upload-time = "2026-04-03T20:54:28.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/3b/f5a72b7045bd59575fc33bf1345f156fcfd5a8484aea6ad84b12c5a82114/regex-2026.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d51d20befd5275d092cdffba57ded05f3c436317ee56466c8928ac32d960edaf", size = 860032, upload-time = "2026-04-03T20:54:30.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/a4/72a317003d6fcd7a573584a85f59f525dfe8f67e355ca74eb6b53d66a5e2/regex-2026.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0a51cdb3c1e9161154f976cb2bef9894bc063ac82f31b733087ffb8e880137d0", size = 765714, upload-time = "2026-04-03T20:54:32.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/1e/5672e16f34dbbcb2560cc7e6a2fbb26dfa8b270711e730101da4423d3973/regex-2026.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ae5266a82596114e41fb5302140e9630204c1b5f325c770bec654b95dd54b0aa", size = 852078, upload-time = "2026-04-03T20:54:34.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/0d/c813f0af7c6cc7ed7b9558bac2e5120b60ad0fa48f813e4d4bd55446f214/regex-2026.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c882cd92ec68585e9c1cf36c447ec846c0d94edd706fe59e0c198e65822fd23b", size = 789181, upload-time = "2026-04-03T20:54:36.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/6d/a344608d1adbd2a95090ddd906cec09a11be0e6517e878d02a5123e0917f/regex-2026.4.4-cp313-cp313-win32.whl", hash = "sha256:05568c4fbf3cb4fa9e28e3af198c40d3237cf6041608a9022285fe567ec3ad62", size = 266690, upload-time = "2026-04-03T20:54:38.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/07/54049f89b46235ca6f45cd6c88668a7050e77d4a15555e47dd40fde75263/regex-2026.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:3384df51ed52db0bea967e21458ab0a414f67cdddfd94401688274e55147bb81", size = 277733, upload-time = "2026-04-03T20:54:40.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/21/61366a8e20f4d43fb597708cac7f0e2baadb491ecc9549b4980b2be27d16/regex-2026.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:acd38177bd2c8e69a411d6521760806042e244d0ef94e2dd03ecdaa8a3c99427", size = 270565, upload-time = "2026-04-03T20:54:41.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/1e/3a2b9672433bef02f5d39aa1143ca2c08f311c1d041c464a42be9ae648dc/regex-2026.4.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f94a11a9d05afcfcfa640e096319720a19cc0c9f7768e1a61fceee6a3afc6c7c", size = 494126, upload-time = "2026-04-03T20:54:43.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/4b/c132a4f4fe18ad3340d89fcb56235132b69559136036b845be3c073142ed/regex-2026.4.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:36bcb9d6d1307ab629edc553775baada2aefa5c50ccc0215fbfd2afcfff43141", size = 293882, upload-time = "2026-04-03T20:54:45.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/5f/eaa38092ce7a023656280f2341dbbd4ad5f05d780a70abba7bb4f4bea54c/regex-2026.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261c015b3e2ed0919157046d768774ecde57f03d8fa4ba78d29793447f70e717", size = 292334, upload-time = "2026-04-03T20:54:47.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/f6/dd38146af1392dac33db7074ab331cec23cced3759167735c42c5460a243/regex-2026.4.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c228cf65b4a54583763645dcd73819b3b381ca8b4bb1b349dee1c135f4112c07", size = 811691, upload-time = "2026-04-03T20:54:49.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/f0/dc54c2e69f5eeec50601054998ec3690d5344277e782bd717e49867c1d29/regex-2026.4.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd2630faeb6876fb0c287f664d93ddce4d50cd46c6e88e60378c05c9047e08ca", size = 871227, upload-time = "2026-04-03T20:54:51.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/af/cb16bd5dc61621e27df919a4449bbb7e5a1034c34d307e0a706e9cc0f3e3/regex-2026.4.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a50ab11b7779b849472337191f3a043e27e17f71555f98d0092fa6d73364520", size = 917435, upload-time = "2026-04-03T20:54:52.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/71/8b260897f22996b666edd9402861668f45a2ca259f665ac029e6104a2d7d/regex-2026.4.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0734f63afe785138549fbe822a8cfeaccd1bae814c5057cc0ed5b9f2de4fc883", size = 816358, upload-time = "2026-04-03T20:54:54.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/60/775f7f72a510ef238254906c2f3d737fc80b16ca85f07d20e318d2eea894/regex-2026.4.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4ee50606cb1967db7e523224e05f32089101945f859928e65657a2cbb3d278b", size = 785549, upload-time = "2026-04-03T20:54:57.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/42/34d289b3627c03cf381e44da534a0021664188fa49ba41513da0b4ec6776/regex-2026.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6c1818f37be3ca02dcb76d63f2c7aaba4b0dc171b579796c6fbe00148dfec6b1", size = 801364, upload-time = "2026-04-03T20:54:58.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/20/f6ecf319b382a8f1ab529e898b222c3f30600fcede7834733c26279e7465/regex-2026.4.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f5bfc2741d150d0be3e4a0401a5c22b06e60acb9aa4daa46d9e79a6dcd0f135b", size = 866221, upload-time = "2026-04-03T20:55:00.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/6a/9f16d3609d549bd96d7a0b2aee1625d7512ba6a03efc01652149ef88e74d/regex-2026.4.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:504ffa8a03609a087cad81277a629b6ce884b51a24bd388a7980ad61748618ff", size = 772530, upload-time = "2026-04-03T20:55:03.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/f6/aa9768bc96a4c361ac96419fbaf2dcdc33970bb813df3ba9b09d5d7b6d96/regex-2026.4.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70aadc6ff12e4b444586e57fc30771f86253f9f0045b29016b9605b4be5f7dfb", size = 856989, upload-time = "2026-04-03T20:55:05.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/b4/c671db3556be2473ae3e4bb7a297c518d281452871501221251ea4ecba57/regex-2026.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f4f83781191007b6ef43b03debc35435f10cad9b96e16d147efe84a1d48bdde4", size = 803241, upload-time = "2026-04-03T20:55:07.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/5c/83e3b1d89fa4f6e5a1bc97b4abd4a9a97b3c1ac7854164f694f5f0ba98a0/regex-2026.4.4-cp313-cp313t-win32.whl", hash = "sha256:e014a797de43d1847df957c0a2a8e861d1c17547ee08467d1db2c370b7568baa", size = 269921, upload-time = "2026-04-03T20:55:09.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/07/077c387121f42cdb4d92b1301133c0d93b5709d096d1669ab847dda9fe2e/regex-2026.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:b15b88b0d52b179712632832c1d6e58e5774f93717849a41096880442da41ab0", size = 281240, upload-time = "2026-04-03T20:55:11.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/22/ead4a4abc7c59a4d882662aa292ca02c8b617f30b6e163bc1728879e9353/regex-2026.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:586b89cdadf7d67bf86ae3342a4dcd2b8d70a832d90c18a0ae955105caf34dbe", size = 272440, upload-time = "2026-04-03T20:55:13.365Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.33.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "s1-orbits"
|
||||
version = "0.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/8b/3ba0f9f8d5bf760e8a649516dfde46183b3f4649fabe1d71c1a5596cdc87/s1_orbits-0.2.0.tar.gz", hash = "sha256:9ff7561c7fb76490b6a0c232e61f64226899c8424f49ddd05825eb166d0daeb3", size = 8953, upload-time = "2025-07-22T18:34:12.815Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/0a/0e7d9741406a044f01a918894b850067c9c7b7e92e17c533cf5fdcac4fb4/s1_orbits-0.2.0-py3-none-any.whl", hash = "sha256:c1d2c42f8d99628efd21b5445fa8d0c9f9d2f575c28d30a29dd42ff305df99c5", size = 4989, upload-time = "2025-07-22T18:34:12.005Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sentinel-orbit-downloader"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "asf-search" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "s1-orbits" },
|
||||
{ name = "shapely" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "asf-search", specifier = ">=12.0.7" },
|
||||
{ name = "fastapi", specifier = ">=0.116.1" },
|
||||
{ name = "s1-orbits", specifier = ">=0.2.0" },
|
||||
{ name = "shapely", specifier = ">=2.1.2" },
|
||||
{ name = "uvicorn", specifier = ">=0.35.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shapely"
|
||||
version = "2.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/8d/1ff672dea9ec6a7b5d422eb6d095ed886e2e523733329f75fdcb14ee1149/shapely-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:91121757b0a36c9aac3427a651a7e6567110a4a67c97edf04f8d55d4765f6618", size = 1820038, upload-time = "2025-09-24T13:50:15.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/ce/28fab8c772ce5db23a0d86bf0adaee0c4c79d5ad1db766055fa3dab442e2/shapely-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:16a9c722ba774cf50b5d4541242b4cce05aafd44a015290c82ba8a16931ff63d", size = 1626039, upload-time = "2025-09-24T13:50:16.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/8b/868b7e3f4982f5006e9395c1e12343c66a8155c0374fdc07c0e6a1ab547d/shapely-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cc4f7397459b12c0b196c9efe1f9d7e92463cbba142632b4cc6d8bbbbd3e2b09", size = 3001519, upload-time = "2025-09-24T13:50:18.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/02/58b0b8d9c17c93ab6340edd8b7308c0c5a5b81f94ce65705819b7416dba5/shapely-2.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:136ab87b17e733e22f0961504d05e77e7be8c9b5a8184f685b4a91a84efe3c26", size = 3110842, upload-time = "2025-09-24T13:50:21.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/61/8e389c97994d5f331dcffb25e2fa761aeedfb52b3ad9bcdd7b8671f4810a/shapely-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:16c5d0fc45d3aa0a69074979f4f1928ca2734fb2e0dde8af9611e134e46774e7", size = 4021316, upload-time = "2025-09-24T13:50:23.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/d4/9b2a9fe6039f9e42ccf2cb3e84f219fd8364b0c3b8e7bbc857b5fbe9c14c/shapely-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ddc759f72b5b2b0f54a7e7cde44acef680a55019eb52ac63a7af2cf17cb9cd2", size = 4178586, upload-time = "2025-09-24T13:50:25.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/f6/9840f6963ed4decf76b08fd6d7fed14f8779fb7a62cb45c5617fa8ac6eab/shapely-2.1.2-cp311-cp311-win32.whl", hash = "sha256:2fa78b49485391224755a856ed3b3bd91c8455f6121fee0db0e71cefb07d0ef6", size = 1543961, upload-time = "2025-09-24T13:50:26.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/1e/3f8ea46353c2a33c1669eb7327f9665103aa3a8dfe7f2e4ef714c210b2c2/shapely-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:c64d5c97b2f47e3cd9b712eaced3b061f2b71234b3fc263e0fcf7d889c6559dc", size = 1722856, upload-time = "2025-09-24T13:50:28.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644, upload-time = "2025-09-24T13:50:44.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887, upload-time = "2025-09-24T13:50:46.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931, upload-time = "2025-09-24T13:50:48.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855, upload-time = "2025-09-24T13:50:50.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960, upload-time = "2025-09-24T13:50:51.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851, upload-time = "2025-09-24T13:50:53.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890, upload-time = "2025-09-24T13:50:55.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151, upload-time = "2025-09-24T13:50:57.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130, upload-time = "2025-09-24T13:50:58.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802, upload-time = "2025-09-24T13:50:59.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460, upload-time = "2025-09-24T13:51:02.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223, upload-time = "2025-09-24T13:51:04.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760, upload-time = "2025-09-24T13:51:06.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078, upload-time = "2025-09-24T13:51:08.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178, upload-time = "2025-09-24T13:51:10.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tenacity"
|
||||
version = "9.1.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2026.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzlocal"
|
||||
version = "5.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.46.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user